From c7f2fe0c27aca6fcd01f993871d7e40c09095632 Mon Sep 17 00:00:00 2001 From: thzxx Date: Sun, 19 Apr 2026 19:01:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B7=A5=E5=85=B7=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E4=BB=8E=E8=81=8A=E5=A4=A9=E5=8C=BA=E5=9F=9F=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=88=B0=E5=B7=A5=E4=BD=9C=E7=A9=BA=E9=97=B4=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工作空间新增「🔧 工具」页签,专门展示工具调用卡片 - 工具执行时自动切换到工具页签 - run_command 执行时自动切换到终端页签 - AI 回复消息不再显示工具卡片,保持聊天区域简洁 - 工具页签支持清空、自动滚动、状态统计 --- src/renderer/components/chat-area.ts | 16 -- src/renderer/components/input-area.ts | 13 +- src/renderer/components/workspace-panel.ts | 232 ++++++++++++++++++++- src/renderer/index.html | 17 ++ src/renderer/styles/style.css | 144 ++++++++++++- 5 files changed, 390 insertions(+), 32 deletions(-) diff --git a/src/renderer/components/chat-area.ts b/src/renderer/components/chat-area.ts index dd26cf9..50017d4 100644 --- a/src/renderer/components/chat-area.ts +++ b/src/renderer/components/chat-area.ts @@ -157,22 +157,6 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void { contentHtml = contentHtml.replace('
', ''); } - // v5.1.2 工具卡片移至文字上方(先展示工具执行过程,再展示最终回答) - if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) { - let toolHtml = '
'; - for (const tc of msg.toolCalls) { - toolHtml += renderToolCallCard(tc); - } - toolHtml += '
'; - // 插入到 msg-content 之前 - const contentIdx = contentHtml.indexOf('
'); - if (contentIdx >= 0) { - contentHtml = contentHtml.slice(0, contentIdx) + toolHtml + contentHtml.slice(contentIdx); - } else { - contentHtml = toolHtml + contentHtml; - } - } - const stats: string[] = []; const statClasses: string[] = []; if (msg.timestamp) { stats.push(formatTime(msg.timestamp)); statClasses.push(''); } diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index e9bdc85..5b8ee8b 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -12,6 +12,7 @@ import { appendToolCallCardToPlaceholder, updateToolCallCardInPlaceholder } from './chat-area.js'; import { showToast } from './toast.js'; +import { addToolCard, updateToolCard } from './workspace-panel.js'; import { ChatDB } from '../db/chat-db.js'; import { OllamaAPI } from '../api/ollama.js'; import { runAgentLoop } from '../services/agent-engine.js'; @@ -288,19 +289,19 @@ async function handleRetry(): Promise { onThinking: (thinking) => updateLastAssistantMessage('', thinking, null), onContent: (content) => updateLastAssistantMessage(content, null, null), onToolCallStart: (call) => { - appendToolCallCardToPlaceholder({ + addToolCard({ name: call.function.name, arguments: call.function.arguments, result: null, status: 'running', timestamp: Date.now() }); }, onToolCallResult: (name, result, call) => { - updateToolCallCardInPlaceholder({ + updateToolCard({ name, arguments: call.function.arguments, result, status: result.success ? 'success' : 'error', timestamp: Date.now() }); }, onToolCallError: (name, error, call) => { - updateToolCallCardInPlaceholder({ + updateToolCard({ name, arguments: call.function.arguments, result: { success: false, error }, status: 'error', timestamp: Date.now() }); @@ -890,19 +891,19 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio updateLastAssistantMessage(assistantContent, thinkContent || null, null); }, onToolCallStart: (call) => { - appendToolCallCardToPlaceholder({ + addToolCard({ name: call.function.name, arguments: call.function.arguments, result: null, status: 'running', timestamp: Date.now() }); }, onToolCallResult: (name, result, call) => { - updateToolCallCardInPlaceholder({ + updateToolCard({ name, arguments: call.function.arguments, result, status: result.success ? 'success' : 'error', timestamp: Date.now() }); }, onToolCallError: (name, error, call) => { - updateToolCallCardInPlaceholder({ + updateToolCard({ name, arguments: call.function.arguments, result: { success: false, error }, status: 'error', timestamp: Date.now() }); diff --git a/src/renderer/components/workspace-panel.ts b/src/renderer/components/workspace-panel.ts index 05823a3..5a9d8a4 100644 --- a/src/renderer/components/workspace-panel.ts +++ b/src/renderer/components/workspace-panel.ts @@ -5,6 +5,15 @@ import { logInfo, logError, logDebug } from '../services/log-service.js'; +// ── 工具卡片类型 ── +interface ToolCallRecord { + name: string; + arguments: Record; + result: Record | null; + status: string; + timestamp: number; +} + // ── 状态 ── interface TerminalSession { id: string; @@ -30,7 +39,7 @@ interface FileNode { } let panelVisible = true; // 常驻显示,不可关闭 -let activeTab: 'terminal' | 'files' = 'terminal'; +let activeTab: 'terminal' | 'tools' | 'files' = 'terminal'; let terminal: TerminalSession | null = null; // 唯一终端 let workspaceDir = ''; let currentFileDir = ''; @@ -38,6 +47,10 @@ let fileTree: FileNode[] = []; let previewFile: { name: string; content: string; path: string } | null = null; let _counter = 0; +/** 工具调用卡片列表 */ +let toolCards: ToolCallRecord[] = []; +let toolAutoScroll = true; + /** 当前正在运行的 AI 命令(用于用户手动终止时通知 AI) */ let currentAiCommand: string | null = null; @@ -206,6 +219,7 @@ export async function initWorkspacePanel(): Promise { function bindEvents(): void { // Tab 切换 document.querySelector('#wsTabTerminal')?.addEventListener('click', () => switchTab('terminal')); + document.querySelector('#wsTabTools')?.addEventListener('click', () => switchTab('tools')); document.querySelector('#wsTabFiles')?.addEventListener('click', () => switchTab('files')); // 停止按钮 @@ -214,6 +228,9 @@ function bindEvents(): void { // 清空按钮 document.querySelector('#wsClearBtn')?.addEventListener('click', clearTerminal); + // 工具清空按钮 + document.querySelector('#wsToolsClearBtn')?.addEventListener('click', clearToolCards); + // 文件刷新 document.querySelector('#wsFileRefreshBtn')?.addEventListener('click', () => { loadFiles(currentFileDir || workspaceDir); @@ -235,7 +252,7 @@ function bindEvents(): void { } // ── 面板控制 ──(常驻显示,无需切换) -function switchTab(tab: 'terminal' | 'files'): void { +function switchTab(tab: 'terminal' | 'tools' | 'files'): void { activeTab = tab; renderPanel(); @@ -438,23 +455,25 @@ export function renderPanel(): void { if (!panel) return; const termTab = document.querySelector('#wsTabTerminal') as HTMLElement; + const toolsTab = document.querySelector('#wsTabTools') as HTMLElement; const filesTab = document.querySelector('#wsTabFiles') as HTMLElement; - if (termTab) { - termTab.classList.toggle('active', activeTab === 'terminal'); - } - if (filesTab) { - filesTab.classList.toggle('active', activeTab === 'files'); - } + if (termTab) termTab.classList.toggle('active', activeTab === 'terminal'); + if (toolsTab) toolsTab.classList.toggle('active', activeTab === 'tools'); + if (filesTab) filesTab.classList.toggle('active', activeTab === 'files'); const termContent = document.querySelector('#wsTerminalContent') as HTMLElement; + const toolsContent = document.querySelector('#wsToolsContent') as HTMLElement; const filesContent = document.querySelector('#wsFilesContent') as HTMLElement; if (termContent) termContent.style.display = activeTab === 'terminal' ? '' : 'none'; + if (toolsContent) toolsContent.style.display = activeTab === 'tools' ? '' : 'none'; if (filesContent) filesContent.style.display = activeTab === 'files' ? '' : 'none'; if (activeTab === 'terminal') { renderTerminal(); + } else if (activeTab === 'tools') { + renderToolCalls(); } else { renderFileList(); } @@ -617,6 +636,197 @@ function formatSize(bytes: number): string { return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } +// ── 工具调用管理 ── + +/** 添加工具调用卡片(由外部调用) */ +export function addToolCard(tc: ToolCallRecord): void { + toolCards.push(tc); + // 如果是 run_command,自动切到终端 tab + if (tc.name === 'run_command') { + switchToTab('terminal'); + } else { + switchToTab('tools'); + } + renderToolCalls(); + updateToolsHint(); +} + +/** 更新工具调用卡片状态(由外部调用) */ +export function updateToolCard(tc: ToolCallRecord): void { + // 找到最后一个同名且 running 的卡片 + for (let i = toolCards.length - 1; i >= 0; i--) { + if (toolCards[i].name === tc.name && toolCards[i].status === 'running') { + toolCards[i] = tc; + break; + } + } + renderToolCalls(); + updateToolsHint(); +} + +function clearToolCards(): void { + toolCards = []; + renderToolCalls(); + updateToolsHint(); +} + +function updateToolsHint(): void { + const hint = document.querySelector('#wsToolsHint') as HTMLElement; + if (!hint) return; + const running = toolCards.filter(c => c.status === 'running'); + if (running.length > 0) { + hint.textContent = `⏳ 执行中: ${running.map(c => getToolDisplayName(c.name)).join(', ')}`; + hint.classList.add('running'); + } else { + const total = toolCards.length; + const success = toolCards.filter(c => c.status === 'success').length; + const error = toolCards.filter(c => c.status === 'error').length; + hint.textContent = total > 0 ? `共 ${total} 个工具调用(✅${success} ❌${error})` : '等待工具调用...'; + hint.classList.remove('running'); + } +} + +function renderToolCalls(): void { + const container = document.querySelector('#wsToolsOutput') as HTMLElement; + if (!container) return; + + if (toolCards.length === 0) { + container.innerHTML = '
等待工具调用...
'; + return; + } + + let html = ''; + for (const tc of toolCards) { + html += renderToolCard(tc); + } + container.innerHTML = html; + + if (toolAutoScroll) { + container.scrollTop = container.scrollHeight; + } + + container.onscroll = () => { + toolAutoScroll = container.scrollHeight - container.scrollTop - container.clientHeight < 50; + }; +} + +function getToolDisplayName(name: string): string { + const names: Record = { + read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录', + search_files: '搜索文件', create_directory: '创建目录', delete_file: '删除文件', run_command: '执行命令', + move_file: '移动文件', copy_file: '复制文件', web_fetch: '网页抓取', append_file: '追加文件', + edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件', + diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取', + git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索', + memory_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆', + skill_list: '技能列表', skill_view: '查看技能', + session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', + browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容', + browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器' + }; + return names[name] || name; +} + +function getToolIcon(name: string): string { + const icons: Record = { + read_file: '📄', write_file: '✏️', list_directory: '📁', + search_files: '🔍', create_directory: '📂', delete_file: '🗑️', run_command: '💻', + move_file: '📦', copy_file: '📋', web_fetch: '🌐', append_file: '➕', + edit_file: '✂️', get_file_info: 'ℹ️', tree: '🌳', download_file: '⬇️', + diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚', + git: '🔖', compress: '🗜️', web_search: '🔍', + memory_search: '🧠', memory_add: '💾', memory_replace: '✏️', memory_remove: '🗑️', + skill_list: '🎯', skill_view: '👁️', + session_list: '📋', session_read: '📖', spawn_task: '🤖', + browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰', + browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌' + }; + return icons[name] || '🔧'; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderToolCard(tc: ToolCallRecord): string { + const icon = getToolIcon(tc.name); + const name = getToolDisplayName(tc.name); + const statusLabels: Record = { + pending: '⏳ 等待确认', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消' + }; + const status = statusLabels[tc.status] || tc.status; + const args = tc.arguments || {}; + + let paramsHtml = ''; + if (args.path) paramsHtml += `
路径: ${escapeHtml(String(args.path))}
`; + if (args.command) paramsHtml += `
命令: ${escapeHtml(String(args.command))}
`; + if (args.query) paramsHtml += `
查询: ${escapeHtml(String(args.query))}
`; + if (args.url) paramsHtml += `
URL: ${escapeHtml(String(args.url))}
`; + if (args.source) paramsHtml += `
源: ${escapeHtml(String(args.source))}
`; + if (args.destination) paramsHtml += `
目标: ${escapeHtml(String(args.destination))}
`; + if (args.selector) paramsHtml += `
选择器: ${escapeHtml(String(args.selector))}
`; + if (args.action) paramsHtml += `
操作: ${escapeHtml(String(args.action))}
`; + + let resultHtml = ''; + if (tc.result) { + const r = tc.result as Record; + if (r.success) { + if (tc.name === 'run_command') { + const stdout = String(r.stdout || '').slice(0, 500); + const stderr = String(r.stderr || '').slice(0, 300); + if (stdout) resultHtml += `
${escapeHtml(stdout)}${String(r.stdout || '').length > 500 ? '\n...' : ''}
`; + if (stderr) resultHtml += `
${escapeHtml(stderr)}
`; + resultHtml += `
exit: ${r.exitCode || 0}
`; + } else if (tc.name === 'web_search' && r.results) { + const results = r.results as Array<{ title: string; url: string }>; + resultHtml = results.slice(0, 5).map((item, i) => + `
[${i + 1}] ${escapeHtml(item.title)}
${escapeHtml(item.url)}
` + ).join(''); + resultHtml += `
共 ${r.total || results.length} 条结果
`; + } else if (tc.name === 'read_file' && r.content) { + const content = String(r.content); + const lines = content.split('\n'); + const preview = lines.slice(0, 15).join('\n'); + resultHtml = `
${escapeHtml(preview)}${lines.length > 15 ? `\n... (共 ${lines.length} 行)` : ''}
`; + } else if (tc.name === 'list_directory' && r.entries) { + const entries = r.entries as Array<{ name: string; type: string }>; + resultHtml = entries.slice(0, 20).map(e => + `
${e.type === 'directory' ? '📁' : '📄'} ${escapeHtml(e.name)}
` + ).join(''); + if (entries.length > 20) resultHtml += `
... 共 ${entries.length} 项
`; + } else if (tc.name === 'write_file') { + resultHtml = `
✅ 已写入 ${escapeHtml(String(r.path || ''))}
`; + } else if (tc.name === 'delete_file') { + resultHtml = `
✅ 已删除 ${escapeHtml(String(r.path || ''))}
`; + } else if (tc.name === 'create_directory') { + resultHtml = `
✅ 已创建 ${escapeHtml(String(r.path || ''))}
`; + } else { + // 通用成功显示 + const summary = JSON.stringify(r).slice(0, 300); + resultHtml = `
${escapeHtml(summary)}${JSON.stringify(r).length > 300 ? '\n...' : ''}
`; + } + } else { + resultHtml = `
${escapeHtml(String(r.error || '未知错误'))}
`; + } + } + + const statusClass = tc.status === 'running' ? 'tool-call-running' : tc.status === 'error' ? 'tool-call-error' : ''; + + return `
+
+ ${icon} + ${escapeHtml(name)} + ${status} +
+ ${paramsHtml ? `
${paramsHtml}
` : ''} + ${resultHtml ? `
${resultHtml}
` : ''} +
`; +} + // ── 外部调用接口 ── /** 获取工作空间目录 */ @@ -624,3 +834,9 @@ export function getWorkspaceDirPath(): string { return workspaceDir; } +/** 外部自动切换 tab */ +export function switchToTab(tab: 'terminal' | 'tools' | 'files'): void { + activeTab = tab; + renderPanel(); +} + diff --git a/src/renderer/index.html b/src/renderer/index.html index 2507a36..3e38db1 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -170,6 +170,7 @@ 🖥️ 工作空间
+
@@ -195,6 +196,22 @@ + + +