소스 검색

\ feat: 前端 UI 优化 - 工具调用过程可视化

- 添加工具调用过程面板(深色主题,可折叠)
- 单个工具调用项显示(工具名、参数、结果)
- 三种状态样式:执行中(黄色脉冲)、成功(绿色)、失败(红色)
- 修复流式输出 tool_call 事件(添加 tool_use_delta 处理)
- 分离显示:工具过程面板 + AI 最终响应
- 动画效果:spinner、脉冲边框、平滑过渡

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname 1 일 전
부모
커밋
f6ba0da306
2개의 변경된 파일427개의 추가작업 그리고 80개의 파일을 삭제
  1. 20 0
      backend/app.py
  2. 407 80
      frontend/index.html

+ 20 - 0
backend/app.py

@@ -212,6 +212,22 @@ def chat_stream():
                                 response_text += text
                                 yield f"event: token\ndata: {json_module.dumps({'text': text})}\n\n"
 
+                            # 工具名称增量
+                            elif delta_type == "tool_use_delta":
+                                # 获取工具名称和参数增量
+                                delta_name = getattr(event.delta, "name", None)
+                                delta_input = getattr(event.delta, "input", None)
+
+                                if current_tool_index >= 0 and current_tool_index < len(tool_use_blocks):
+                                    if delta_name is not None:
+                                        tool_use_blocks[current_tool_index]["name"] = delta_name
+                                    if delta_input is not None:
+                                        # 更新输入参数
+                                        current_input = tool_use_blocks[current_tool_index]["input"]
+                                        if isinstance(delta_input, dict):
+                                            current_input.update(delta_input)
+                                            tool_use_blocks[current_tool_index]["input"] = current_input
+
                             # 工具参数增量 - input_json_delta
                             elif delta_type == "input_json_delta":
                                 # 累积 partial_json 构建完整参数
@@ -241,6 +257,10 @@ def chat_stream():
                     # 处理工具调用
                     yield f"event: tools_start\ndata: {json_module.dumps({'count': len(tool_use_blocks)})}\n\n"
 
+                    # 为每个工具调用发送 tool_call 事件
+                    for tool_block in tool_use_blocks:
+                        yield f"event: tool_call\ndata: {json_module.dumps({'tool': tool_block['name'], 'args': tool_block['input'], 'tool_id': tool_block['id']})}\n\n"
+
                     tool_results = run_async(conv_manager.tool_handler.process_tool_use_blocks(
                         tool_use_blocks
                     ))

+ 407 - 80
frontend/index.html

@@ -24,6 +24,227 @@
             0%, 80%, 100% { transform: scale(0); }
             40% { transform: scale(1); }
         }
+
+        /* 工具调用面板样式 */
+        .tool-call-panel {
+            background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+            border-radius: 8px;
+            margin: 12px 0;
+            overflow: hidden;
+            border: 1px solid #3a3a5a;
+        }
+
+        .tool-panel-header {
+            background: rgba(255, 255, 255, 0.05);
+            padding: 10px 14px;
+            cursor: pointer;
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            transition: background 0.2s;
+        }
+
+        .tool-panel-header:hover {
+            background: rgba(255, 255, 255, 0.08);
+        }
+
+        .tool-panel-title {
+            display: flex;
+            align-items: center;
+            gap: 8px;
+            color: #e0e0e0;
+            font-size: 13px;
+            font-weight: 500;
+        }
+
+        .tool-panel-content {
+            padding: 0;
+            max-height: 0;
+            overflow: hidden;
+            transition: max-height 0.3s ease-out, padding 0.3s ease-out;
+        }
+
+        .tool-panel-content.expanded {
+            max-height: 800px;
+            padding: 12px;
+            overflow-y: auto;
+        }
+
+        .tool-panel-content::-webkit-scrollbar {
+            width: 6px;
+        }
+
+        .tool-panel-content::-webkit-scrollbar-track {
+            background: rgba(255, 255, 255, 0.05);
+        }
+
+        .tool-panel-content::-webkit-scrollbar-thumb {
+            background: rgba(255, 255, 255, 0.2);
+            border-radius: 3px;
+        }
+
+        /* 工具调用项样式 */
+        .tool-call-item {
+            background: rgba(255, 255, 255, 0.03);
+            border-radius: 6px;
+            padding: 10px 12px;
+            margin-bottom: 8px;
+            border-left: 3px solid #555;
+            transition: all 0.2s;
+        }
+
+        .tool-call-item:last-child {
+            margin-bottom: 0;
+        }
+
+        .tool-call-item.pending {
+            border-left-color: #f59e0b;
+            animation: pulse-border 1.5s infinite;
+        }
+
+        .tool-call-item.success {
+            border-left-color: #10b981;
+        }
+
+        .tool-call-item.error {
+            border-left-color: #ef4444;
+        }
+
+        @keyframes pulse-border {
+            0%, 100% { border-left-color: #f59e0b; }
+            50% { border-left-color: #fbbf24; }
+        }
+
+        .tool-call-header {
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            margin-bottom: 6px;
+        }
+
+        .tool-name {
+            color: #e0e0e0;
+            font-size: 13px;
+            font-weight: 500;
+            display: flex;
+            align-items: center;
+            gap: 6px;
+        }
+
+        .tool-status {
+            font-size: 11px;
+            padding: 2px 8px;
+            border-radius: 10px;
+            font-weight: 500;
+        }
+
+        .tool-status.pending {
+            background: rgba(245, 158, 11, 0.2);
+            color: #fbbf24;
+        }
+
+        .tool-status.success {
+            background: rgba(16, 185, 129, 0.2);
+            color: #34d399;
+        }
+
+        .tool-status.error {
+            background: rgba(239, 68, 68, 0.2);
+            color: #f87171;
+        }
+
+        .tool-params {
+            background: rgba(0, 0, 0, 0.3);
+            border-radius: 4px;
+            padding: 8px;
+            margin-top: 6px;
+            font-size: 11px;
+            color: #9ca3af;
+            overflow-x: auto;
+        }
+
+        .tool-params::-webkit-scrollbar {
+            height: 4px;
+        }
+
+        .tool-params::-webkit-scrollbar-thumb {
+            background: rgba(255, 255, 255, 0.2);
+            border-radius: 2px;
+        }
+
+        .tool-result {
+            background: rgba(16, 185, 129, 0.1);
+            border-radius: 4px;
+            padding: 8px;
+            margin-top: 6px;
+            font-size: 11px;
+            color: #6ee7b7;
+        }
+
+        .tool-result.error {
+            background: rgba(239, 68, 68, 0.1);
+            color: #fca5a5;
+        }
+
+        .tool-result-content {
+            max-height: 100px;
+            overflow-y: auto;
+            word-break: break-all;
+        }
+
+        .tool-result-content::-webkit-scrollbar {
+            width: 4px;
+        }
+
+        .tool-result-content::-webkit-scrollbar-thumb {
+            background: rgba(255, 255, 255, 0.2);
+            border-radius: 2px;
+        }
+
+        .expand-icon {
+            transition: transform 0.3s;
+        }
+
+        .expand-icon.expanded {
+            transform: rotate(180deg);
+        }
+
+        /* 加载动画 */
+        .spinner {
+            width: 14px;
+            height: 14px;
+            border: 2px solid rgba(251, 191, 36, 0.3);
+            border-top-color: #fbbf24;
+            border-radius: 50%;
+            animation: spin 0.8s linear infinite;
+        }
+
+        @keyframes spin {
+            to { transform: rotate(360deg); }
+        }
+
+        /* 状态指示器 */
+        .process-indicator {
+            display: flex;
+            align-items: center;
+            gap: 6px;
+            padding: 6px 10px;
+            background: rgba(245, 158, 11, 0.1);
+            border-radius: 6px;
+            font-size: 12px;
+            color: #fbbf24;
+        }
+
+        .process-indicator.success {
+            background: rgba(16, 185, 129, 0.1);
+            color: #34d399;
+        }
+
+        .process-indicator .spinner {
+            width: 12px;
+            height: 12px;
+        }
+
         pre {
             background: #1e1e1e;
             color: #d4d4d4;
@@ -34,20 +255,6 @@
         code {
             font-family: 'Courier New', monospace;
         }
-        /* 状态指示器样式 */
-        .status {
-            margin-top: 8px;
-            font-size: 12px;
-            opacity: 0.8;
-        }
-        .status .thinking { color: #FFA500; }
-        .status .tool-calling { color: #4A90E2; }
-        .status .tool-executing { color: #4A90E2; }
-        .status .tool-done { color: #7ED321; }
-        .status .tool-error { color: #D0021B; }
-        .status .complete { color: #7ED321; }
-        .status .warning { color: #F5A623; }
-        .status .error { color: #D0021B; }
     </style>
 </head>
 <body class="bg-gray-100 min-h-screen">
@@ -185,54 +392,122 @@
             if (indicator) indicator.remove();
         }
 
-        // Send message to backend
-        async function sendMessage(message) {
-            if (isTyping) return;
+        // 创建工具调用面板
+        function createToolCallPanel() {
+            const panel = document.createElement('div');
+            panel.className = 'tool-call-panel';
+            panel.innerHTML = `
+                <div class="tool-panel-header" onclick="toggleToolPanel(this)">
+                    <div class="tool-panel-title">
+                        <span class="expand-icon">▼</span>
+                        <span>🔧 工具调用过程</span>
+                        <span class="tool-count">(0)</span>
+                    </div>
+                    <div class="process-indicator">
+                        <div class="spinner"></div>
+                        <span>处理中...</span>
+                    </div>
+                </div>
+                <div class="tool-panel-content expanded">
+                    <div class="tool-calls-container"></div>
+                </div>
+            `;
+            return panel;
+        }
 
-            isTyping = true;
-            sendButton.disabled = true;
+        // 切换工具面板展开/收起
+        function toggleToolPanel(header) {
+            const content = header.nextElementSibling;
+            const icon = header.querySelector('.expand-icon');
+            content.classList.toggle('expanded');
+            icon.classList.toggle('expanded');
+        }
 
-            // Add user message
-            addMessage('user', message);
-            conversationHistory.push({ role: 'user', content: message });
+        // 添加工具调用项
+        function addToolCallItem(container, toolName, params) {
+            const item = document.createElement('div');
+            item.className = 'tool-call-item pending';
+            item.id = `tool-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
 
-            // Show typing indicator
-            addTypingIndicator();
+            const paramsStr = params ? JSON.stringify(params, null, 2) : '{}';
 
-            try {
-                const response = await fetch(`${API_BASE}/api/chat`, {
-                    method: 'POST',
-                    headers: {
-                        'Content-Type': 'application/json',
-                    },
-                    body: JSON.stringify({
-                        message: message,
-                        history: conversationHistory
-                    })
-                });
+            item.innerHTML = `
+                <div class="tool-call-header">
+                    <div class="tool-name">
+                        <span>🔧</span>
+                        <span>${escapeHtml(toolName)}</span>
+                    </div>
+                    <div class="tool-status pending">
+                        <div class="spinner"></div>
+                        <span>执行中</span>
+                    </div>
+                </div>
+                ${params ? `<div class="tool-params"><strong>参数:</strong> <pre style="margin:0;padding:0;background:transparent;font-size:10px;">${escapeHtml(paramsStr)}</pre></div>` : ''}
+            `;
 
-                if (!response.ok) {
-                    throw new Error(`HTTP error! status: ${response.status}`);
-                }
+            container.appendChild(item);
+            return item.id;
+        }
 
-                const data = await response.json();
+        // 更新工具调用状态
+        function updateToolCallStatus(itemId, status, result = null) {
+            const item = document.getElementById(itemId);
+            if (!item) return;
+
+            const statusEl = item.querySelector('.tool-status');
+            item.classList.remove('pending', 'success', 'error');
+
+            if (status === 'success') {
+                item.classList.add('success');
+                statusEl.className = 'tool-status success';
+                statusEl.innerHTML = '✅ 成功';
+
+                if (result) {
+                    const resultDiv = document.createElement('div');
+                    resultDiv.className = 'tool-result';
+                    const resultText = typeof result === 'string' ? result : JSON.stringify(result);
+                    resultDiv.innerHTML = `<strong>结果:</strong><div class="tool-result-content">${escapeHtml(resultText.substring(0, 500))}${resultText.length > 500 ? '...' : ''}</div>`;
+                    item.appendChild(resultDiv);
+                }
+            } else if (status === 'error') {
+                item.classList.add('error');
+                statusEl.className = 'tool-status error';
+                statusEl.innerHTML = '❌ 失败';
+
+                const errorDiv = document.createElement('div');
+                errorDiv.className = 'tool-result error';
+                errorDiv.innerHTML = `<strong>错误:</strong> ${escapeHtml(result || '未知错误')}`;
+                item.appendChild(errorDiv);
+            }
+        }
 
-                // Remove typing indicator
-                removeTypingIndicator();
+        // 更新工具面板状态
+        function updateToolPanelStatus(panel, status, totalCalls = 0) {
+            const indicator = panel.querySelector('.process-indicator');
+            const countEl = panel.querySelector('.tool-count');
+
+            countEl.textContent = `(${totalCalls})`;
+
+            if (status === 'complete') {
+                indicator.className = 'process-indicator success';
+                indicator.innerHTML = `<span>✅ 完成 - ${totalCalls} 个工具调用</span>`;
+            } else if (status === 'error') {
+                indicator.className = 'process-indicator';
+                indicator.style.background = 'rgba(239, 68, 68, 0.1)';
+                indicator.style.color = '#f87171';
+                indicator.innerHTML = `<span>❌ 部分失败</span>`;
+            }
+        }
 
-                // Add assistant message
-                addMessage('assistant', data.response);
-                conversationHistory.push({ role: 'assistant', content: data.response });
+        // HTML 转义
+        function escapeHtml(text) {
+            const div = document.createElement('div');
+            div.textContent = text;
+            return div.innerHTML;
+        }
 
-            } catch (error) {
-                removeTypingIndicator();
-                addMessage('assistant', `错误: ${error.message}`);
-                console.error('Error:', error);
-            } finally {
-                isTyping = false;
-                sendButton.disabled = false;
-                userInput.focus();
-            }
+        function scrollToBottom() {
+            chatMessages.scrollTop = chatMessages.scrollHeight;
         }
 
         // ========== 流式聊天功能 ==========
@@ -296,10 +571,6 @@
             }
         }
 
-        function scrollToBottom() {
-            chatMessages.scrollTop = chatMessages.scrollHeight;
-        }
-
         async function sendMessageStream(message) {
             if (isTyping) return;
 
@@ -314,66 +585,123 @@
             const assistantMsgDiv = document.createElement('div');
             assistantMsgDiv.className = 'flex justify-start';
             assistantMsgDiv.innerHTML = `
-                <div class="message-assistant rounded-lg p-4 max-w-[80%] shadow-sm">
-                    <div class="text" id="currentResponse"></div>
-                    <div class="status" id="currentStatus"></div>
+                <div class="message-assistant rounded-lg p-4 max-w-[80%] shadow-sm" style="max-width: 90%;">
+                    <div class="response-content">
+                        <div class="text" id="currentResponse"></div>
+                    </div>
+                    <div id="toolCallContainer"></div>
                 </div>
             `;
             chatMessages.appendChild(assistantMsgDiv);
 
             const responseDiv = assistantMsgDiv.querySelector('#currentResponse');
-            const statusDiv = assistantMsgDiv.querySelector('#currentStatus');
+            const toolContainer = assistantMsgDiv.querySelector('#toolCallContainer');
+
             let currentText = '';
             let toolCallsCount = 0;
+            let toolCallItems = {};
             let finalResponse = '';
+            let toolCallPanel = null;
+            let toolCallsContainer = null;
 
             await chatStream(message, (eventType, data) => {
                 switch (eventType) {
                     case 'start':
-                        statusDiv.innerHTML = '<span class="thinking">🤔 思考中...</span>';
+                        responseDiv.innerHTML = '<span style="color: #888; font-style: italic;">🤔 AI 正在思考...</span>';
                         break;
 
                     case 'token':
+                        // 移除思考提示
+                        if (currentText === '') {
+                            responseDiv.innerHTML = '';
+                        }
                         currentText += data.text || '';
                         responseDiv.innerHTML = formatMessage(currentText);
                         scrollToBottom();
                         break;
 
                     case 'tool_call':
-                        statusDiv.innerHTML = `<span class="tool-calling">🔧 调用工具: ${data.tool}</span>`;
+                        // 首次工具调用时创建面板
+                        if (!toolCallPanel) {
+                            toolCallPanel = createToolCallPanel();
+                            toolContainer.appendChild(toolCallPanel);
+                            toolCallsContainer = toolCallPanel.querySelector('.tool-calls-container');
+
+                            // 清空思考提示
+                            if (currentText === '' || currentText.includes('正在思考')) {
+                                responseDiv.innerHTML = '<span style="color: #888; font-style: italic;">🔧 正在调用工具...</span>';
+                            }
+                        }
+
+                        // 添加工具调用项
+                        const toolName = data.tool || 'unknown';
+                        const params = data.args || data.params || null;
+                        const itemId = addToolCallItem(toolCallsContainer, toolName, params);
+                        toolCallItems[data.tool_id || toolName] = itemId;
+                        toolCallsCount++;
+                        updateToolPanelStatus(toolCallPanel, 'running', toolCallsCount);
+                        scrollToBottom();
                         break;
 
                     case 'tools_start':
-                        statusDiv.innerHTML = `<span class="tool-executing">⚙️ 执行 ${data.count} 个工具...</span>`;
+                        // 批量工具开始
+                        if (!toolCallPanel) {
+                            toolCallPanel = createToolCallPanel();
+                            toolContainer.appendChild(toolCallPanel);
+                            toolCallsContainer = toolCallPanel.querySelector('.tool-calls-container');
+                        }
+                        if (currentText === '' || currentText.includes('正在思考')) {
+                            responseDiv.innerHTML = '<span style="color: #888; font-style: italic;">🔧 正在调用工具...</span>';
+                        }
+                        scrollToBottom();
                         break;
 
                     case 'tool_done':
-                        toolCallsCount++;
-                        const result = data.result || '';
-                        statusDiv.innerHTML = `<span class="tool-done">✅ ${data.tool}: ${result.substring(0, 50)}${result.length > 50 ? '...' : ''}</span>`;
+                        // 工具执行完成
+                        const toolId = data.tool_id || data.tool;
+                        const itemId2 = toolCallItems[toolId];
+                        if (itemId2) {
+                            const result = data.result || '';
+                            updateToolCallStatus(itemId2, 'success', result);
+                        }
+                        scrollToBottom();
                         break;
 
                     case 'tool_error':
-                        statusDiv.innerHTML = `<span class="tool-error">❌ 工具错误: ${data.error}</span>`;
+                        // 工具执行错误
+                        const errorItemId = toolCallItems[data.tool_id || data.tool];
+                        if (errorItemId) {
+                            updateToolCallStatus(errorItemId, 'error', data.error);
+                        }
+                        if (toolCallPanel) {
+                            updateToolPanelStatus(toolCallPanel, 'error', toolCallsCount);
+                        }
+                        scrollToBottom();
                         break;
 
                     case 'complete':
-                        statusDiv.innerHTML = `<span class="complete">✅ 完成 (${toolCallsCount} 个工具调用)</span>`;
-                        if (data.warning) {
-                            statusDiv.innerHTML += ` <span class="warning">⚠️ ${data.warning}</span>`;
+                        // 完成
+                        if (toolCallPanel) {
+                            updateToolPanelStatus(toolCallPanel, 'complete', toolCallsCount);
                         }
 
-                        // 保存到历史
-                        finalResponse = data.response || currentText;
+                        // 清空临时提示并显示最终响应
+                        if (data.response || currentText) {
+                            finalResponse = data.response || currentText;
+                            responseDiv.innerHTML = formatMessage(finalResponse);
+                        }
                         conversationHistory.push({ role: 'assistant', content: finalResponse });
+                        scrollToBottom();
                         break;
 
                     case 'error':
-                        statusDiv.innerHTML = `<span class="error">❌ 错误: ${data.error}</span>`;
+                        responseDiv.innerHTML = `<span style="color: #ef4444;">❌ 错误: ${escapeHtml(data.error || '未知错误')}</span>`;
+                        if (toolCallPanel) {
+                            updateToolPanelStatus(toolCallPanel, 'error', toolCallsCount);
+                        }
+                        scrollToBottom();
                         break;
                 }
-
-                scrollToBottom();
             });
 
             // 重置状态
@@ -428,12 +756,8 @@
             e.preventDefault();
             const message = userInput.value.trim();
             if (message) {
-                // 默认使用流式输出
                 sendMessageStream(message);
                 userInput.value = '';
-
-                // 如果需要非流式,可以调用原来的 sendMessage()
-                // sendMessage(message);
             }
         });
 
@@ -441,6 +765,9 @@
         loadMCPServers();
         checkHealth();
         userInput.focus();
+
+        // Make toggle function global
+        window.toggleToolPanel = toggleToolPanel;
     </script>
 </body>
 </html>