瀏覽代碼

增加表格导出插件

kagg886 2 月之前
父節點
當前提交
636d5c04bc

+ 6 - 2
src/api/assist/type.ts

@@ -47,7 +47,7 @@ export type ChatRequest = {
 	message: Message[]
 }
 
-export type ChatResponseType = 'message' | 'toolcall' | 'toolres' | 'error'
+export type ChatResponseType = 'message' | 'toolcall' | 'toolres' | 'error' | 'datamsg'
 
 export type ChatResponseBase<T = ChatResponseType> = {
 	type: T
@@ -81,7 +81,11 @@ export type MetaResponse = ChatResponseBase<'meta'> & {
 	meta: string
 }
 
-export type ChatResponse = Text | ToolCallRequest | ToolCallResponse | ErrorResponse
+export type DataResponse = ChatResponseBase<'datamsg'> & {
+	data: string
+}
+
+export type ChatResponse = Text | ToolCallRequest | ToolCallResponse | ErrorResponse | DataResponse
 
 // 大语言模型配置相关类型定义
 

+ 225 - 0
src/components/markdown/plugins/impl/VueTable.vue

@@ -0,0 +1,225 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import { ElDialog, ElButton } from 'element-plus'
+import { View, Download } from '@element-plus/icons-vue'
+
+interface TableData {
+  headers: string[]
+  rows: string[][]
+}
+
+interface Props {
+  data: string
+}
+
+const props = defineProps<Props>()
+
+const showDialog = ref(false)
+
+const tableData = computed<TableData>(() => {
+  try {
+    return JSON.parse(decodeURIComponent(props.data))
+  } catch (e) {
+    console.error('解析表格数据失败:', e)
+    return { headers: [], rows: [] }
+  }
+})
+
+const handleView = () => {
+  showDialog.value = true
+}
+
+const handleExport = () => {
+  try {
+    // 创建CSV内容
+    const csvContent = generateCSV(tableData.value)
+
+    // 创建Blob对象
+    const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
+
+    // 创建下载链接
+    const link = document.createElement('a')
+    const url = URL.createObjectURL(blob)
+    link.setAttribute('href', url)
+    link.setAttribute('download', `table_data_${new Date().getTime()}.csv`)
+    link.style.visibility = 'hidden'
+
+    // 触发下载
+    document.body.appendChild(link)
+    link.click()
+    document.body.removeChild(link)
+
+    // 清理URL对象
+    URL.revokeObjectURL(url)
+  } catch (error) {
+    console.error('导出CSV失败:', error)
+  }
+}
+
+// 生成CSV内容的辅助函数
+const generateCSV = (data: TableData): string => {
+  const { headers, rows } = data
+
+  // 处理CSV字段的函数,处理包含逗号、引号和换行符的情况
+  const escapeCSVField = (field: string): string => {
+    // 移除HTML标签
+    const cleanField = field.replace(/<[^>]*>/g, '')
+
+    // 如果字段包含逗号、引号或换行符,需要用引号包围并转义内部引号
+    if (cleanField.includes(',') || cleanField.includes('"') || cleanField.includes('\n')) {
+      return `"${cleanField.replace(/"/g, '""')}"`
+    }
+    return cleanField
+  }
+
+  // 构建CSV内容
+  const csvLines: string[] = []
+
+  // 添加表头
+  if (headers.length > 0) {
+    csvLines.push(headers.map(escapeCSVField).join(','))
+  }
+
+  // 添加数据行
+  rows.forEach(row => {
+    csvLines.push(row.map(escapeCSVField).join(','))
+  })
+
+  // 添加BOM以支持Excel正确显示中文
+  return '\uFEFF' + csvLines.join('\n')
+}
+
+</script>
+
+<template>
+  <div>
+    <!-- 操作按钮 -->
+    <div class="table-actions" v-if="tableData.headers.length > 0">
+			<el-button
+				type="success"
+				:icon="Download"
+				size="small"
+				@click="handleExport"
+				plain
+			>
+				导出
+			</el-button>
+      <el-button
+        type="primary"
+        :icon="View"
+        size="small"
+        @click="handleView"
+        plain
+      >
+        查看
+      </el-button>
+
+    </div>
+
+    <table v-if="tableData.headers.length > 0">
+      <thead>
+        <tr>
+          <th
+            v-for="(header, index) in tableData.headers"
+            :key="index"
+            class="table-header"
+          >
+            {{ header }}
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr
+          v-for="(row, rowIndex) in tableData.rows"
+          :key="rowIndex"
+          class="table-row"
+          :class="{ 'striped': rowIndex % 2 === 1 }"
+        >
+          <td
+            v-for="(cell, cellIndex) in row"
+            :key="cellIndex"
+            class="table-cell"
+          >
+            <span class="table-cell-content" v-html="cell"></span>
+          </td>
+        </tr>
+      </tbody>
+    </table>
+    <div v-else class="empty-table">
+      暂无数据
+    </div>
+
+    <!-- 查看对话框 -->
+    <el-dialog
+      v-model="showDialog"
+      title="表格详情"
+      width="80%"
+      :before-close="() => showDialog = false"
+    >
+      <div>
+        <table class="dialog-table" v-if="tableData.headers.length > 0">
+          <thead>
+            <tr>
+              <th
+                v-for="(header, index) in tableData.headers"
+                :key="index"
+                class="dialog-table-header"
+              >
+                {{ header }}
+              </th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr
+              v-for="(row, rowIndex) in tableData.rows"
+              :key="rowIndex"
+              class="dialog-table-row"
+              :class="{ 'striped': rowIndex % 2 === 1 }"
+            >
+              <td
+                v-for="(cell, cellIndex) in row"
+                :key="cellIndex"
+                class="dialog-table-cell"
+              >
+                <span class="dialog-table-cell-content" v-html="cell"></span>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="showDialog = false">关闭</el-button>
+          <el-button type="primary" @click="handleExport">导出数据</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped lang="scss">
+
+.table-actions {
+  z-index: 10;
+  display: flex;
+	flex-direction: row-reverse;
+  gap: 8px;
+
+  .el-button {
+    --el-button-size: 24px;
+  }
+}
+
+.empty-table {
+  padding: 40px;
+  text-align: center;
+  color: var(--el-text-color-placeholder);
+  font-size: 14px;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+}
+</style>

+ 163 - 0
src/components/markdown/plugins/table.ts

@@ -0,0 +1,163 @@
+import MarkdownIt from "markdown-it";
+import type {RenderRule} from "markdown-it/lib/renderer.mjs";
+import type Token from "markdown-it/lib/token.mjs";
+import {defineMarkdownPlugin} from "../type/markdown.ts";
+import {h} from "vue";
+import VueTable from "/@/components/markdown/plugins/impl/VueTable.vue";
+
+// 解析表格数据的接口
+interface TableData {
+    headers: string[];
+    rows: string[][];
+}
+
+// 解析表格tokens获取数据
+function parseTableData(tokens: Token[], startIdx: number): TableData {
+    const headers: string[] = [];
+    const rows: string[][] = [];
+    let currentRow: string[] = [];
+    let inHeader = false;
+    let inBody = false;
+    let cellContent = '';
+
+    for (let i = startIdx; i < tokens.length; i++) {
+        const token = tokens[i];
+
+        if (token.type === 'table_close') {
+            break;
+        }
+
+        if (token.type === 'thead_open') {
+            inHeader = true;
+            continue;
+        }
+
+        if (token.type === 'thead_close') {
+            inHeader = false;
+            continue;
+        }
+
+        if (token.type === 'tbody_open') {
+            inBody = true;
+            continue;
+        }
+
+        if (token.type === 'tbody_close') {
+            inBody = false;
+            continue;
+        }
+
+        if (token.type === 'tr_open') {
+            currentRow = [];
+            continue;
+        }
+
+        if (token.type === 'tr_close') {
+            if (inHeader && currentRow.length > 0) {
+                headers.push(...currentRow);
+            } else if (inBody && currentRow.length > 0) {
+                rows.push([...currentRow]);
+            }
+            currentRow = [];
+            continue;
+        }
+
+        if (token.type === 'th_open' || token.type === 'td_open') {
+            cellContent = '';
+            continue;
+        }
+
+        if (token.type === 'th_close' || token.type === 'td_close') {
+            currentRow.push(cellContent.trim());
+            cellContent = '';
+            continue;
+        }
+
+        if (token.type === 'inline' && token.children) {
+            // 处理内联内容
+            for (const child of token.children) {
+                if (child.type === 'text') {
+                    cellContent += child.content;
+                } else if (child.type === 'code_inline') {
+                    cellContent += `\`${child.content}\``;
+                } else if (child.type === 'strong_open') {
+                    cellContent += '**';
+                } else if (child.type === 'strong_close') {
+                    cellContent += '**';
+                } else if (child.type === 'em_open') {
+                    cellContent += '*';
+                } else if (child.type === 'em_close') {
+                    cellContent += '*';
+                }
+            }
+        }
+    }
+
+    return { headers, rows };
+}
+
+// 渲染表格
+const renderTable: RenderRule = (tokens: Token[], idx: number) => {
+    const tableData = parseTableData(tokens, idx + 1);
+
+    if (tableData.headers.length === 0 && tableData.rows.length === 0) {
+        return '<div style="padding: 16px;background-color: #fff5f5;border: 1px solid #fed7d7;border-radius: 6px;color: #c53030;margin: 16px 0;">表格数据为空</div>';
+    }
+
+    // 生成完整HTML
+    return `<table-container style="width: 100%;margin: 16px 0;" data="${encodeURIComponent(JSON.stringify(tableData))}"></table-container>`;
+}
+
+const TablePlugin = defineMarkdownPlugin({
+    tagName: 'table-container',
+    mdItPlugin: function (md: MarkdownIt) {
+        // 保存原始的表格渲染器
+        const defaultTableOpen = md.renderer.rules.table_open ?? function (tokens, idx, options, _env, renderer) {
+            return renderer.renderToken(tokens, idx, options);
+        }
+
+        const defaultTableClose = md.renderer.rules.table_close ?? function (tokens, idx, options, _env, renderer) {
+            return renderer.renderToken(tokens, idx, options);
+        }
+
+        // 重写表格渲染器
+        md.renderer.rules.table_open = function (tokens, idx, options, env, renderer) {
+            return renderTable(tokens, idx, options, env, renderer);
+        }
+
+        // 表格关闭时不输出任何内容,因为我们在table_open时已经处理了整个表格
+        md.renderer.rules.table_close = function () {
+            return '';
+        }
+
+        // // 隐藏所有其他表格相关的token渲染
+        const hideTokens = ['thead_open', 'thead_close','tbody_open','tbody_close','tr_open','tr_close'];
+        hideTokens.forEach(tokenType => {
+            md.renderer.rules[tokenType] = function () {
+                return ''
+            }
+        });
+
+
+        const notDisplayTokens = ['th_open','td_open']
+        notDisplayTokens.forEach(tokenType => {
+            md.renderer.rules[tokenType] = function () {
+                return '<div style="display: none">'
+            }
+        });
+        const notDisplayTokensEnd = ['th_close','td_close']
+        notDisplayTokensEnd.forEach(tokenType => {
+            md.renderer.rules[tokenType] = function () {
+                return '</div>'
+            }
+        });
+    },
+    renderer: (node: {attribs: Record<string, string>}) => {
+        return h(VueTable, {
+            data: node.attribs.data,
+            style: 'width: 100%;margin: 16px 0;'
+        });
+    }
+});
+
+export default TablePlugin;

+ 1 - 1
src/components/markdown/type/markdown.ts

@@ -9,7 +9,7 @@ export type MarkdownPlugin<Settings> = {
 	settings: Settings
 }
 
-export function defineMarkdownPlugin<Settings>(
+export function defineMarkdownPlugin<Settings = {}>(
 	data: Omit<MarkdownPlugin<Settings>, 'settings'>
 
 	// eslint-disable-next-line no-unused-vars

+ 77 - 25
src/views/assistant/index.vue

@@ -1,10 +1,11 @@
 <script setup lang="ts">
 import { ref, nextTick, onMounted, computed, onUnmounted, reactive, watch, isReactive } from 'vue'
 import { Local } from '/@/utils/storage'
-import { User, ChatDotRound, Delete, Edit, Check, Close } from '@element-plus/icons-vue'
+import { User, ChatDotRound, Delete, Edit, Check, Close, ArrowDown } from '@element-plus/icons-vue'
 import { MarkdownPlugin } from '/@/components/markdown/type/markdown'
 import EChartsPlugin from '/@/components/markdown/plugins/echarts'
 import ToolsLoadingPlugin from '/@/components/markdown/plugins/tools-loading'
+import TablePlugin from '/@/components/markdown/plugins/table'
 import Markdown from '/@/components/markdown/Markdown.vue'
 import assist from '/@/api/assist'
 import { ChatResponse, LmConfigInfo, LmSession, Message } from '/@/api/assist/type'
@@ -13,7 +14,7 @@ import { Setting as EleSetting } from '@element-plus/icons-vue'
 import { useRouter } from 'vue-router'
 import { ElMessage } from 'element-plus'
 
-const plugins: Array<MarkdownPlugin<any>> = [EChartsPlugin(), ToolsLoadingPlugin()]
+const plugins: Array<MarkdownPlugin<any>> = [EChartsPlugin(), ToolsLoadingPlugin(), TablePlugin()]
 
 //聊天管理接口
 // 消息列表
@@ -230,7 +231,9 @@ const chatInternal = (rtn: Message, context: Message[] = messages.value) => {
 					rtn.content += resp.message
 					break
 				case 'toolres': {
-					rtn.render_content += `
+
+					if (showToolCalls.value) {
+						rtn.render_content += `
 \`\`\`tools-loading
 resp
 ${resp.response.name}
@@ -238,6 +241,8 @@ ${resp.response.data.replace('\n', '')}
 \`\`\`
 
 `
+					}
+
 					messages.value.push({
 						id: messages.value.length,
 						tool_call_id: resp.response.id,
@@ -252,7 +257,8 @@ ${resp.response.data.replace('\n', '')}
 				}
 
 				case 'toolcall': {
-					rtn.render_content += `
+					if (showToolCalls.value) {
+						rtn.render_content += `
 \`\`\`tools-loading
 request
 ${resp.request.name}
@@ -260,6 +266,8 @@ ${resp.request.data.replace('\n', '')}
 \`\`\`
 
 `
+					}
+
 					rtn.tool_calls?.push({
 						id: resp.request.id,
 						type: 'function',
@@ -465,6 +473,10 @@ const canSendMessage = computed(() => {
 
 const router = useRouter()
 const redirectToModelManager = () => router.push('manage/model')
+
+// 设置面板相关状态
+const showSettingsPanel = ref(false)
+const showToolCalls = ref(false)
 </script>
 
 <template>
@@ -473,7 +485,26 @@ const redirectToModelManager = () => router.push('manage/model')
 		<el-aside width="300px" class="chat-sidebar">
 			<div class="sidebar-header">
 				<h3>对话历史</h3>
-				<el-button round :icon="EleSetting" size="small" @click="redirectToModelManager"></el-button>
+				<el-dropdown v-model="showSettingsPanel" trigger="click" placement="bottom-end">
+					<el-button round :icon="EleSetting" size="small">
+						<el-icon class="el-icon--right">
+							<ArrowDown />
+						</el-icon>
+					</el-button>
+					<template #dropdown>
+						<el-dropdown-menu>
+							<el-dropdown-item class="settings-item">
+								<div class="settings-row">
+									<span class="settings-label">显示工具调用</span>
+									<el-switch v-model="showToolCalls" size="small" />
+								</div>
+							</el-dropdown-item>
+							<el-dropdown-item @click="redirectToModelManager">
+								<span class="settings-label">模型管理</span>
+							</el-dropdown-item>
+						</el-dropdown-menu>
+					</template>
+				</el-dropdown>
 			</div>
 			<el-scrollbar class="conversation-list" v-loading="loadConversations">
 				<div
@@ -596,7 +627,16 @@ const redirectToModelManager = () => router.push('manage/model')
 									{{ message.render_content }}
 								</div>
 								<div class="user-message-actions">
-									<el-button type="primary" size="small" @click="replaceMessage(messages.indexOf(message))" class="retry-btn" plain> 重试 </el-button>
+									<el-button
+										type="primary"
+										size="small"
+										@click="replaceMessage(messages.indexOf(message))"
+										class="retry-btn"
+										plain
+										:disabled="isConversationActive"
+									>
+										重试
+									</el-button>
 								</div>
 							</div>
 							<el-avatar class="message-avatar" :src="getUserInfos.avatar" :icon="User" />
@@ -617,25 +657,7 @@ const redirectToModelManager = () => router.push('manage/model')
 					<!-- 标题和描述 -->
 					<div class="empty-text">
 						<h2 class="empty-title">开始新的对话</h2>
-						<p class="empty-description">选择工具和模型,然后在下方输入您的问题开始对话</p>
 					</div>
-
-					<!-- 快速开始提示 -->
-					<div class="quick-start">
-						<div class="quick-start-item">
-							<span class="step-number">1</span>
-							<span class="step-text">选择需要的工具</span>
-						</div>
-						<div class="quick-start-item">
-							<span class="step-number">2</span>
-							<span class="step-text">选择AI模型</span>
-						</div>
-						<div class="quick-start-item">
-							<span class="step-number">3</span>
-							<span class="step-text">输入问题并发送</span>
-						</div>
-					</div>
-
 					<!-- 示例问题 -->
 					<div class="example-questions">
 						<h4>试试这些问题:</h4>
@@ -703,7 +725,7 @@ const redirectToModelManager = () => router.push('manage/model')
 
 					<!-- 按钮组 -->
 					<div class="button-group">
-						<el-button type="warning" size="small" @click="clearMessage" style="margin-left: 12px" :loading="loadingClearMessage"> 清空 </el-button>
+						<el-button v-show="messages.length !== 0" type="warning" size="small" @click="clearMessage" style="margin-left: 12px" :loading="loadingClearMessage"> 清空 </el-button>
 						<el-button
 							v-if="!isConversationActive"
 							type="primary"
@@ -769,6 +791,7 @@ const redirectToModelManager = () => router.push('manage/model')
 	border-bottom: 1px solid var(--el-border-color-light);
 	display: flex;
 	justify-content: space-between;
+	align-items: center;
 
 	h3 {
 		margin: 0;
@@ -778,6 +801,35 @@ const redirectToModelManager = () => router.push('manage/model')
 	}
 }
 
+/* 设置面板样式 */
+:deep(.el-dropdown-menu) {
+	.settings-item {
+		padding: 0;
+
+		&:hover {
+			background: none;
+		}
+	}
+
+	.settings-row {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		padding: 8px 16px;
+		min-width: 160px;
+
+		&:hover {
+			background: var(--el-fill-color-light);
+		}
+	}
+
+	.settings-label {
+		font-size: 14px;
+		color: var(--el-text-color-primary);
+		flex: 1;
+	}
+}
+
 .conversation-list {
 	flex: 1;
 	padding: 10px;