From 6ae61f665281748a361e9778cf58a8f0d1b66a19 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Mon, 25 Aug 2025 13:57:47 +0800 Subject: [PATCH] =?UTF-8?q?refactor(=E6=99=BA=E8=83=BD=E4=BD=93=E7=BB=84?= =?UTF-8?q?=E4=BB=B6):=20=E9=87=8D=E6=9E=84=E9=83=A8=E5=88=86=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E4=BC=98=E5=8C=96=E9=83=A8=E5=88=86=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/update.md | 1 + web/src/apis/base.js | 2 +- web/src/assets/css/base.css | 2 + web/src/components/AgentChatComponent.vue | 1271 ++---------------- web/src/components/AgentConfigSidebar.vue | 29 +- web/src/components/AgentMessageComponent.vue | 77 +- web/src/components/ChatSidebarComponent.vue | 8 +- web/src/components/DatabaseHeader.vue | 7 +- web/src/components/FileTable.vue | 9 + web/src/components/MessageInputComponent.vue | 12 +- web/src/stores/agent.js | 195 ++- web/src/utils/agentValidator.js | 126 ++ web/src/utils/chatExporter.js | 522 +++++++ web/src/utils/errorHandler.js | 148 ++ web/src/utils/messageProcessor.js | 248 ++++ web/src/utils/scrollController.js | 152 +++ web/src/views/GraphView.vue | 3 + 17 files changed, 1559 insertions(+), 1253 deletions(-) create mode 100644 web/src/utils/agentValidator.js create mode 100644 web/src/utils/chatExporter.js create mode 100644 web/src/utils/errorHandler.js create mode 100644 web/src/utils/messageProcessor.js create mode 100644 web/src/utils/scrollController.js diff --git a/docs/changelog/update.md b/docs/changelog/update.md index 94a813c9..c3b9efdb 100644 --- a/docs/changelog/update.md +++ b/docs/changelog/update.md @@ -28,6 +28,7 @@ - [ ] 使用其他的聊天记录管理方法(现在是基于 LangGraph 的 Memory 实现的) - [ ] 添加测试脚本,覆盖最常见的功能 +- [ ] 添加数据库迁移的脚本 - [ ] 封装现有工具为 mcp(stdio)调用 - [ ] 支持额外 mcp 配置(代码端) - [ ] 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息 diff --git a/web/src/apis/base.js b/web/src/apis/base.js index 81732927..16f3d178 100644 --- a/web/src/apis/base.js +++ b/web/src/apis/base.js @@ -181,7 +181,7 @@ export function apiSuperAdminPut(url, data = {}, options = {}) { * @param {boolean} requiresAuth - 是否需要认证 * @returns {Promise} - 请求结果 */ -export function apiDelete(url, options = {}, requiresAuth = false) { +export function apiDelete(url, options = {}, requiresAuth = true) { return apiRequest(url, { method: 'DELETE', ...options }, requiresAuth) } diff --git a/web/src/assets/css/base.css b/web/src/assets/css/base.css index 11b11bde..e2153af4 100644 --- a/web/src/assets/css/base.css +++ b/web/src/assets/css/base.css @@ -41,6 +41,8 @@ --main-color: #016179; + /* 鲜艳一点的 */ + --main-bright: #0188a6; --secondry-color: #4e616d; --error-color: #ba1a1a; diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index c60a6f0d..104f46d9 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -33,11 +33,11 @@ 新对话 -
+
{{ agentStore.currentThread?.title }}
-
+
@@ -59,15 +59,15 @@ 正在加载历史记录...
-
+

{{ currentAgent ? currentAgent.name : '请选择一个智能体开始对话' }}

{{ currentAgent ? currentAgent.description : '不同的智能体有不同的专长和能力' }}

-
+
-
- - -
-
+
@@ -120,18 +108,18 @@
-
+
-

请注意辨别内容的可靠性

+

请注意辨别内容的可靠性

@@ -140,7 +128,7 @@ @@ -1517,7 +544,7 @@ const mergeMessageChunk = (chunks) => { .inputer-init { margin: 40px auto; - width: 80%; + width: 90%; max-width: 800px; } } @@ -1848,4 +875,4 @@ const mergeMessageChunk = (chunks) => { .hide-text { display: none; } - \ No newline at end of file + diff --git a/web/src/components/AgentConfigSidebar.vue b/web/src/components/AgentConfigSidebar.vue index f577451e..0d15bd37 100644 --- a/web/src/components/AgentConfigSidebar.vue +++ b/web/src/components/AgentConfigSidebar.vue @@ -4,7 +4,7 @@ @@ -581,14 +581,6 @@ watch(() => props.isOpen, (newVal) => { .agent-info { .agent-basic-info { - margin-bottom: 16px; - - h4 { - margin: 0 0 8px 0; - font-size: 18px; - font-weight: 600; - color: var(--gray-900); - } .agent-description { margin: 0 0 12px 0; @@ -688,6 +680,13 @@ watch(() => props.isOpen, (newVal) => { .system-prompt-input { resize: vertical; + background: var(--gray-50); + border: 1px solid var(--gray-200); + padding: 8px 12px; + + &:focus { + outline: none; + } } .config-select, diff --git a/web/src/components/AgentMessageComponent.vue b/web/src/components/AgentMessageComponent.vue index 2bab6940..029cd338 100644 --- a/web/src/components/AgentMessageComponent.vue +++ b/web/src/components/AgentMessageComponent.vue @@ -5,28 +5,28 @@
-
+
-

{{ parsedMessage.reasoning_content.trim() }}

+

{{ parsedData.reasoning_content }}

- -
+
@@ -132,14 +132,6 @@ const emit = defineEmits(['retry', 'retryStoppedMessage', 'openRefs']); const reasoningActiveKey = ref(['show']); const expandedToolCalls = ref(new Set()); // 展开的工具调用集合 - -// 计算属性:内容为空且正在加载 -const isEmptyAndLoading = computed(() => { - const isEmpty = !props.message.content || props.message.content.length === 0; - const isLoading = props.message.status === 'init' && props.isProcessing - return isEmpty && isLoading; -}); - // 引入智能体 store const agentStore = useAgentStore(); const { availableTools } = storeToRefs(agentStore); @@ -152,25 +144,26 @@ const getToolNameByToolCall = (toolCall) => { return tool ? tool.name : toolId; }; -const parsedMessage = computed(() => { - const message = props.message; - if (message.content) { - // 匹配完整的 ... 标签 - const thinkContent = message.content.match(/(.*?)<\/think>/s); - if (thinkContent) { - message.reasoning_content = thinkContent[1]; - message.content = message.content.replace(/(.*?)<\/think>/s, ''); - } else { - // 匹配未闭合的 标签,处理加载中的情况 - const incompleteThinkContent = message.content.match(/(.*?)$/s); - if (incompleteThinkContent) { - message.reasoning_content = incompleteThinkContent[1]; - message.content = message.content.replace(/(.*?)$/s, ''); - } - } +const parsedData = computed(() => { + // Start with default values from the prop to avoid mutation. + let content = props.message.content || ''; + let reasoning_content = props.message.additional_kwargs?.reasoning_content || ''; + + // Regex to find ... or an unclosed ... at the end of the string. + const thinkRegex = /(.*?)<\/think>|(.*?)$/s; + const thinkMatch = content.match(thinkRegex); + + if (thinkMatch) { + // The captured reasoning is in either group 1 (closed tag) or 2 (unclosed tag). + reasoning_content = (thinkMatch[1] || thinkMatch[2] || '').trim(); + // Remove the entire matched block from the original content. + content = content.replace(thinkMatch[0], '').trim(); } - message.reasoning_content = message.reasoning_content || message.additional_kwargs?.reasoning_content || ''; - return message; + + return { + content, + reasoning_content, + }; }); const toggleToolCall = (toolCallId) => { @@ -503,12 +496,12 @@ const toggleToolCall = (toolCallId) => { } - + + +
+
+

${chatTitle}

+
+ 智能体: ${agentName} + ${agentDescription ? `
描述: ${agentDescription}` : ''} +
+
+ 导出时间: ${exportTime} +
+
+
+ ${messagesHTML} +
+ +
+ +`; + } + + /** + * 获取CSS样式 + * @returns {string} CSS样式字符串 + */ + static getCSS() { + return ` + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 1.6; + color: #333; + background-color: white; + margin: 0; + padding: 0; + } + + .container { + max-width: 1000px; + margin: 0 auto; + background: white; + min-height: 100vh; + } + + .header { + background: #f8f9fa; + border-bottom: 2px solid #e9ecef; + padding: 24px; + text-align: center; + } + + .header h1 { + font-size: 24px; + margin-bottom: 8px; + color: #212529; + font-weight: 600; + } + + .header .agent-info { + font-size: 14px; + color: #6c757d; + margin-bottom: 12px; + } + + .header .export-info { + font-size: 12px; + color: #868e96; + padding-top: 12px; + border-top: 1px solid #dee2e6; + } + + .messages { + padding: 32px 48px; + max-width: 100%; + } + + .message { + margin-bottom: 32px; + max-width: 100%; + } + + .message:last-child { + margin-bottom: 0; + } + + .message-header { + display: flex; + align-items: center; + margin-bottom: 12px; + font-size: 14px; + color: #666; + } + + .avatar { + font-size: 16px; + margin-right: 8px; + } + + .sender { + font-weight: 600; + margin-right: 12px; + } + + .time { + font-size: 12px; + color: #999; + } + + .message-content { + padding: 16px 20px; + border-radius: 8px; + width: 100%; + max-width: 100%; + } + + .user-message .message-content { + color: white; + background: #1C6586; + border: 1px solid #1C6586; + width: fit-content; + } + + .ai-message .message-content { + background: white; + border: 1px solid #e9ecef; + } + + .reasoning-section { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 6px; + padding: 12px; + margin-bottom: 16px; + } + + .reasoning-header { + font-size: 13px; + font-weight: 600; + color: #495057; + margin-bottom: 8px; + display: flex; + align-items: center; + } + + .reasoning-content { + font-size: 14px; + color: #6c757d; + font-style: italic; + line-height: 1.5; + } + + .tool-calls { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #e9ecef; + } + + .tool-call { + background: #fff8e1; + border: 1px solid #ffe082; + border-radius: 6px; + padding: 12px; + margin-bottom: 12px; + } + + .tool-call:last-child { + margin-bottom: 0; + } + + .tool-call-header { + font-size: 14px; + color: #f57f17; + margin-bottom: 8px; + font-weight: 600; + } + + .tool-call-args { + background: rgba(0,0,0,0.04); + border-radius: 4px; + padding: 8px; + margin-bottom: 8px; + } + + .tool-call-args pre { + font-size: 12px; + color: #666; + white-space: pre-wrap; + word-break: break-all; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + } + + .tool-call-result { + background: #e8f5e8; + border: 1px solid #c8e6c9; + border-radius: 4px; + padding: 8px; + word-break: break-all; + } + + .tool-result-header { + font-size: 12px; + color: #2e7d32; + font-weight: 600; + margin-bottom: 4px; + } + + .tool-result-content { + font-size: 13px; + color: #388e3c; + } + + .footer { + background: #f8f9fa; + text-align: center; + padding: 16px; + font-size: 12px; + color: #666; + border-top: 1px solid #e9ecef; + } + + .footer a { + color: #007bff; + text-decoration: none; + } + + @media (max-width: 768px) { + .messages { + padding: 24px 16px; + } + + .header { + padding: 16px; + } + + .user-message .message-content { + margin-left: 10%; + } + + .ai-message .message-content { + margin-right: 10%; + } + } + + @media (max-width: 480px) { + .user-message .message-content, + .ai-message .message-content { + margin-left: 0; + margin-right: 0; + } + } + + @media print { + body { + background: white; + margin: 0; + padding: 0; + } + + .container { + box-shadow: none; + border-radius: 0; + max-width: 100%; + } + + .header { + background: #f8f9fa !important; + -webkit-print-color-adjust: exact; + } + + .messages { + padding: 20px; + } + + .user-message .message-content { + background: #e3f2fd !important; + -webkit-print-color-adjust: exact; + } + + .reasoning-section { + background: #f8f9fa !important; + -webkit-print-color-adjust: exact; + } + } + `; + } +} + +export default ChatExporter; \ No newline at end of file diff --git a/web/src/utils/errorHandler.js b/web/src/utils/errorHandler.js new file mode 100644 index 00000000..53f653ec --- /dev/null +++ b/web/src/utils/errorHandler.js @@ -0,0 +1,148 @@ +import { message } from 'ant-design-vue'; + +/** + * 统一错误处理工具类 + */ +export class ErrorHandler { + /** + * 处理通用错误 + * @param {Error} error - 错误对象 + * @param {string} context - 错误上下文 + * @param {Object} options - 配置选项 + */ + static handleError(error, context = '操作', options = {}) { + const { + showMessage = true, + logToConsole = true, + customMessage = null, + severity = 'error' + } = options; + + // 控制台日志 + if (logToConsole) { + console.error(`${context}失败:`, error); + } + + // 用户提示 + if (showMessage) { + const displayMessage = customMessage || this.getErrorMessage(error, context); + + switch (severity) { + case 'warning': + message.warning(displayMessage); + break; + case 'info': + message.info(displayMessage); + break; + case 'error': + default: + message.error(displayMessage); + break; + } + } + + return error; + } + + /** + * 获取错误消息 + * @param {Error} error - 错误对象 + * @param {string} context - 错误上下文 + * @returns {string} 错误消息 + */ + static getErrorMessage(error, context) { + if (error?.message) { + return `${context}失败: ${error.message}`; + } + return `${context}失败`; + } + + /** + * 处理网络请求错误 + * @param {Error} error - 错误对象 + * @param {string} context - 错误上下文 + */ + static handleNetworkError(error, context = '网络请求') { + let customMessage = null; + + if (error?.code === 'NETWORK_ERROR') { + customMessage = '网络连接失败,请检查网络设置'; + } else if (error?.status === 401) { + customMessage = '认证失败,请重新登录'; + } else if (error?.status === 403) { + customMessage = '权限不足,无法执行此操作'; + } else if (error?.status === 404) { + customMessage = '请求的资源不存在'; + } else if (error?.status >= 500) { + customMessage = '服务器错误,请稍后重试'; + } + + return this.handleError(error, context, { customMessage }); + } + + /** + * 处理聊天相关错误 + * @param {Error} error - 错误对象 + * @param {string} operation - 操作类型 + */ + static handleChatError(error, operation) { + const contextMap = { + 'send': '发送消息', + 'create': '创建对话', + 'delete': '删除对话', + 'rename': '重命名对话', + 'load': '加载对话', + 'export': '导出对话', + 'stream': '流式处理' + }; + + const context = contextMap[operation] || operation; + return this.handleError(error, context); + } + + /** + * 处理验证错误 + * @param {string} message - 验证错误消息 + */ + static handleValidationError(message) { + return this.handleError( + new Error(message), + '输入验证', + { severity: 'warning', customMessage: message } + ); + } + + /** + * 处理异步操作错误 + * @param {Function} asyncFn - 异步函数 + * @param {string} context - 错误上下文 + * @param {Object} options - 配置选项 + */ + static async handleAsync(asyncFn, context, options = {}) { + try { + return await asyncFn(); + } catch (error) { + this.handleError(error, context, options); + throw error; + } + } + + /** + * 创建错误处理装饰器 + * @param {string} context - 错误上下文 + * @param {Object} options - 配置选项 + */ + static createHandler(context, options = {}) { + return (error) => this.handleError(error, context, options); + } +} + +/** + * 快捷方法 + */ +export const handleChatError = ErrorHandler.handleChatError.bind(ErrorHandler); +export const handleNetworkError = ErrorHandler.handleNetworkError.bind(ErrorHandler); +export const handleValidationError = ErrorHandler.handleValidationError.bind(ErrorHandler); +export const handleAsync = ErrorHandler.handleAsync.bind(ErrorHandler); + +export default ErrorHandler; \ No newline at end of file diff --git a/web/src/utils/messageProcessor.js b/web/src/utils/messageProcessor.js new file mode 100644 index 00000000..cff0e453 --- /dev/null +++ b/web/src/utils/messageProcessor.js @@ -0,0 +1,248 @@ +/** + * 消息处理工具类 + */ +export class MessageProcessor { + /** + * 将工具结果与消息合并 + * @param {Array} msgs - 消息数组 + * @returns {Array} 合并后的消息数组 + */ + static convertToolResultToMessages(msgs) { + const toolResponseMap = new Map(); + + // 构建工具响应映射 + for (const item of msgs) { + if (item.type === 'tool' && item.tool_call_id) { + toolResponseMap.set(item.tool_call_id, item); + } + } + + // 合并工具调用和响应 + const convertedMsgs = msgs.map(item => { + if (item.type === 'ai' && item.tool_calls && item.tool_calls.length > 0) { + return { + ...item, + tool_calls: item.tool_calls.map(toolCall => { + const toolResponse = toolResponseMap.get(toolCall.id); + return { + ...toolCall, + tool_call_result: toolResponse || null + }; + }) + }; + } + return item; + }); + + return convertedMsgs; + } + + /** + * 将服务器历史记录转换为对话格式 + * @param {Array} serverHistory - 服务器历史记录 + * @returns {Array} 对话数组 + */ + static convertServerHistoryToMessages(serverHistory) { + // 第一步:将所有tool消息与对应的tool call合并 + const mergedHistory = this.convertToolResultToMessages(serverHistory); + + // 第二步:按照对话分组 + const conversations = []; + let currentConv = null; + + for (const item of mergedHistory) { + if (item.type === 'human') { + currentConv = { + messages: [item], + status: 'loading' + }; + conversations.push(currentConv); + } else if (item.type === 'ai' && currentConv) { + currentConv.messages.push(item); + + if (item.response_metadata?.finish_reason === 'stop') { + item.isLast = true; + currentConv.status = 'finished'; + currentConv = null; + } + } + } + + return conversations; + } + + /** + * 合并消息块 + * @param {Array} chunks - 消息块数组 + * @returns {Object|null} 合并后的消息 + */ + static mergeMessageChunk(chunks) { + if (chunks.length === 0) return null; + + // 深拷贝第一个chunk作为结果 + const result = JSON.parse(JSON.stringify(chunks[0])); + result.content = result.content || ''; + + // 合并后续chunks + for (let i = 1; i < chunks.length; i++) { + const chunk = chunks[i]; + + // 合并内容 + if (chunk.content) { + result.content += chunk.content; + } + + // 合并reasoning_content + if (chunk.reasoning_content) { + if (!result.reasoning_content) { + result.reasoning_content = ''; + } + result.reasoning_content += chunk.reasoning_content; + } + + // 合并additional_kwargs中的reasoning_content + if (chunk.additional_kwargs?.reasoning_content) { + if (!result.additional_kwargs) result.additional_kwargs = {}; + if (!result.additional_kwargs.reasoning_content) { + result.additional_kwargs.reasoning_content = ''; + } + result.additional_kwargs.reasoning_content += chunk.additional_kwargs.reasoning_content; + } + + // 合并tool_calls + MessageProcessor._mergeToolCalls(result, chunk); + } + + // 处理AIMessageChunk类型 + if (result.type === 'AIMessageChunk') { + result.type = 'ai'; + if (result.additional_kwargs?.tool_calls) { + result.tool_calls = result.additional_kwargs.tool_calls; + } + } + + return result; + } + + /** + * 合并工具调用 + * @private + * @param {Object} result - 结果对象 + * @param {Object} chunk - 当前块 + */ + static _mergeToolCalls(result, chunk) { + if (chunk.additional_kwargs?.tool_calls) { + if (!result.additional_kwargs) result.additional_kwargs = {}; + if (!result.additional_kwargs.tool_calls) result.additional_kwargs.tool_calls = []; + + for (const toolCall of chunk.additional_kwargs.tool_calls) { + const existingToolCall = result.additional_kwargs.tool_calls.find( + t => (t.id === toolCall.id || t.index === toolCall.index) + ); + + if (existingToolCall) { + // 合并相同ID的tool call + if (existingToolCall.function && toolCall.function) { + existingToolCall.function.arguments += toolCall.function.arguments; + } + } else { + // 添加新的tool call + result.additional_kwargs.tool_calls.push(JSON.parse(JSON.stringify(toolCall))); + } + } + } + } + + /** + * 处理流式响应数据块 + * @param {Object} data - 响应数据 + * @param {Object} onGoingConv - 进行中的对话对象 + * @param {Object} state - 状态对象 + * @param {Function} getAgentHistory - 获取历史记录函数 + * @param {Function} handleError - 错误处理函数 + */ + static async processResponseChunk(data, onGoingConv, state, getAgentHistory, handleError) { + try { + switch (data.status) { + case 'init': + // 代表服务端收到请求并返回第一个响应 + state.waitingServerResponse = false; + onGoingConv.msgChunks[data.request_id] = [data.msg]; + break; + + case 'loading': + if (data.msg.id) { + if (!onGoingConv.msgChunks[data.msg.id]) { + onGoingConv.msgChunks[data.msg.id] = []; + } + onGoingConv.msgChunks[data.msg.id].push(data.msg); + } + break; + + case 'error': + console.error("流式处理出错:", data.message); + handleError(new Error(data.message), 'stream'); + break; + + case 'finished': + await getAgentHistory(); + break; + + default: + console.warn('未知的响应状态:', data.status); + } + } catch (error) { + handleError(error, 'stream'); + } + } + + /** + * 处理流式响应 + * @param {Response} response - 响应对象 + * @param {Function} processChunk - 处理块的函数 + * @param {Function} scrollToBottom - 滚动到底部函数 + * @param {Function} handleError - 错误处理函数 + */ + static async handleStreamResponse(response, processChunk, scrollToBottom, handleError) { + try { + const reader = response.body.getReader(); + let buffer = ''; + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // 保留最后一行可能不完整的内容 + + for (const line of lines) { + if (line.trim()) { + try { + const data = JSON.parse(line.trim()); + await processChunk(data); + } catch (e) { + console.debug('解析JSON出错:', e.message); + } + } + } + await scrollToBottom(); + } + + // 处理缓冲区中可能剩余的内容 + if (buffer.trim()) { + try { + const data = JSON.parse(buffer.trim()); + await processChunk(data); + } catch (e) { + console.warn('最终缓冲区内容无法解析:', buffer); + } + } + } catch (error) { + handleError(error, 'stream'); + } + } +} + +export default MessageProcessor; \ No newline at end of file diff --git a/web/src/utils/scrollController.js b/web/src/utils/scrollController.js new file mode 100644 index 00000000..002d4d55 --- /dev/null +++ b/web/src/utils/scrollController.js @@ -0,0 +1,152 @@ +import { nextTick } from 'vue'; + +/** + * 滚动控制工具类 + */ +export class ScrollController { + constructor(containerSelector = '.chat', options = {}) { + this.containerSelector = containerSelector; + this.options = { + threshold: 100, + scrollDelay: 150, + retryDelays: [50, 150, 300], + ...options + }; + + this.scrollTimer = null; + this.isUserScrolling = false; + this.shouldAutoScroll = true; + + // Bind the context of 'this' for the event handler + this.handleScroll = this.handleScroll.bind(this); + } + + /** + * 获取滚动容器 + * @returns {Element|null} + */ + getContainer() { + return document.querySelector(this.containerSelector); + } + + /** + * 检查是否在底部 + * @returns {boolean} + */ + isAtBottom() { + const container = this.getContainer(); + if (!container) return false; + + const { threshold } = this.options; + return container.scrollHeight - container.scrollTop - container.clientHeight <= threshold; + } + + /** + * 处理滚动事件 + */ + handleScroll() { + if (this.scrollTimer) { + clearTimeout(this.scrollTimer); + } + + // 标记用户正在滚动 + this.isUserScrolling = true; + + // 检查是否在底部 + const atBottom = this.isAtBottom(); + this.shouldAutoScroll = atBottom; + + // 滚动结束后一段时间重置用户滚动状态 + this.scrollTimer = setTimeout(() => { + this.isUserScrolling = false; + }, this.options.scrollDelay); + } + + /** + * 智能滚动到底部 + * @param {boolean} force - 是否强制滚动 + */ + async scrollToBottom(force = false) { + await nextTick(); + + // 只有在应该自动滚动时才执行(除非强制) + if (!force && !this.shouldAutoScroll) return; + + const container = this.getContainer(); + if (!container) return; + + const scrollOptions = { + top: container.scrollHeight, + behavior: 'smooth' + }; + + // 立即滚动 + container.scrollTo(scrollOptions); + + // 多次重试确保滚动成功 + this.options.retryDelays.forEach((delay, index) => { + setTimeout(() => { + if (force || this.shouldAutoScroll) { + const behavior = index === this.options.retryDelays.length - 1 ? 'auto' : 'smooth'; + container.scrollTo({ + top: container.scrollHeight, + behavior + }); + } + }, delay); + }); + } + + /** + * 启用自动滚动 + */ + enableAutoScroll() { + this.shouldAutoScroll = true; + } + + /** + * 禁用自动滚动 + */ + disableAutoScroll() { + this.shouldAutoScroll = false; + } + + /** + * 获取滚动状态 + */ + getScrollState() { + return { + isUserScrolling: this.isUserScrolling, + shouldAutoScroll: this.shouldAutoScroll, + isAtBottom: this.isAtBottom() + }; + } + + /** + * 清理定时器 + */ + cleanup() { + if (this.scrollTimer) { + clearTimeout(this.scrollTimer); + this.scrollTimer = null; + } + } + + /** + * 重置滚动状态 + */ + reset() { + this.cleanup(); + this.isUserScrolling = false; + this.shouldAutoScroll = true; + } +} + +/** + * 创建默认的滚动控制器实例 + */ +export const createScrollController = (containerSelector, options) => { + return new ScrollController(containerSelector, options); +}; + +export default ScrollController; \ No newline at end of file diff --git a/web/src/views/GraphView.vue b/web/src/views/GraphView.vue index f8d81f83..81b123cd 100644 --- a/web/src/views/GraphView.vue +++ b/web/src/views/GraphView.vue @@ -63,6 +63,9 @@ ok-text="添加到图数据库" cancel-text="取消" :confirm-loading="state.processing">
+
+

上传的文件内容参考 test/data/A_Dream_of_Red_Mansions_tiny.jsonl 中的格式:

+