2
0
Просмотр исходного кода

fix(config): 修复 Novel Platform MCP 配置和优化 Claude API 设置

修复 MCP 配置:
- URL 从占位符更新为正确的模板 238 地址
- 端口从 8081 改为 8080 (统一端口代理)
- 登录路径从 /api/auth/login 改为 /api/v1/auth/login

优化 Claude API 配置:
- 使用 D8D 内部 API 环境变量 (D8D_API_KEY, D8D_API_BASE_URL)
- 默认模型使用 d8d-model (无配额限制)

其他更新:
- 优化流式输出处理
- 前端 UI 改进
- 添加 CLAUDE.md 文档

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Claude AI 15 часов назад
Родитель
Сommit
dbc6064de3
6 измененных файлов с 1428 добавлено и 65 удалено
  1. 150 0
      CLAUDE.md
  2. 26 5
      backend/app.py
  3. 10 9
      backend/config.py
  4. 18 1
      backend/conversation_manager.py
  5. 281 50
      frontend/index.html
  6. 943 0
      frontend/index.html.backup

+ 150 - 0
CLAUDE.md

@@ -0,0 +1,150 @@
+# Claude AI + MCP 工具调用模板项目
+
+## 项目概述
+
+这是一个基于 Claude AI 的 MCP (Model Context Protocol) 工具调用模板项目。项目实现了完整的 AI 对话界面,支持 Claude AI 通过后端服务器调用 MCP 工具,并将结果实时返回给前端。
+
+### 关键配置要求
+
+**服务端口必须是 `8080` 才能被外网访问!**
+
+- **内网访问**: `http://localhost:8080`
+- **外网访问**: `https://d8d-ai-vscode-8080-{workspace_id}-{item_id}-template-6-group.dev.d8d.fun/`
+
+> ⚠️ **重要**: 如果服务运行在其他端口(如 5000),外网将无法访问!
+
+---
+
+## 项目结构
+
+```
+/mnt/code/223-240-template-6/
+├── backend/          # Flask 后端服务
+│   ├── app.py       # 主应用入口
+│   └── requirements.txt
+├── frontend/         # 前端静态文件
+│   └── index.html   # 单页面应用
+└── CLAUDE.md        # 本文档
+```
+
+---
+
+## 开发指南
+
+### 环境准备
+
+```bash
+# 安装 Python 依赖
+cd backend
+pip install -r requirements.txt
+```
+
+### 启动服务
+
+**必须使用端口 8080 启动服务:**
+
+```bash
+cd backend
+python app.py
+```
+
+服务默认将在 `http://localhost:8080` 启动。
+
+### 验证服务
+
+1. 检查服务是否启动:访问 `http://localhost:8080`
+2. 检查外网访问:使用外网地址格式 `https://d8d-ai-vscode-8080-{workspace_id}-{item_id}-template-6-group.dev.d8d.fun/`
+
+---
+
+## 端口配置
+
+### 当前配置
+
+- **Flask 应用端口**: 8080
+- **前端代理配置**: 8080
+
+### 修改端口(不推荐)
+
+如果必须修改端口,需要同时修改以下位置:
+
+1. `backend/app.py` 中的 `port` 参数
+2. `frontend/index.html` 中的 API 请求地址
+
+**注意**: 修改为非 8080 端口后,外网将无法访问服务!
+
+---
+
+## 故障排除
+
+### 问题 1: 外网无法访问
+
+**症状**: 使用外网地址访问时显示连接失败
+
+**原因**: 服务未运行在 8080 端口
+
+**解决方案**:
+```bash
+# 检查服务端口
+netstat -tuln | grep 8080
+
+# 确保后端以端口 8080 启动
+cd backend
+python app.py
+```
+
+### 问题 2: CORS 错误
+
+**症状**: 前端请求被 CORS 策略阻止
+
+**解决方案**: 确认 `backend/app.py` 中已正确配置 CORS:
+
+```python
+from flask_cors import CORS
+CORS(app, resources={r"/*": {"origins": "*"}})
+```
+
+### 问题 3: MCP 工具调用失败
+
+**症状**: AI 响应显示工具调用错误
+
+**检查项目**:
+- 后端日志是否有错误信息
+- MCP 配置是否正确
+- Claude API 密钥是否有效
+
+### 问题 4: 流式输出中断
+
+**症状**: 响应中途停止或不完整
+
+**解决方案**:
+- 检查网络连接稳定性
+- 确认后端没有超时限制
+- 检查前端 EventSource 连接状态
+
+---
+
+## 技术栈
+
+- **后端**: Flask + Python 3.x
+- **前端**: 原生 HTML/CSS/JavaScript
+- **AI**: Claude API (Anthropic)
+- **协议**: MCP (Model Context Protocol)
+
+---
+
+## 开发者注意事项
+
+1. **端口固定**: 服务端口固定为 8080,不可随意更改
+2. **外网地址格式**: `https://d8d-ai-vscode-8080-{workspace_id}-{item_id}-template-6-group.dev.d8d.fun/`
+3. **流式响应**: 使用 Server-Sent Events (SSE) 实现流式输出
+4. **CORS 配置**: 后端需要正确配置 CORS 以支持前端跨域请求
+
+---
+
+## 更新日志
+
+- 2026-03-16: 添加端口配置文档和故障排除指南
+- 2026-03-16: 实现流式输出解决 504 超时问题
+- 2026-03-16: 前端 UI 优化 - 工具调用过程可视化
+- 2026-03-16: 实现完整的 MCP 工具调用管道

+ 26 - 5
backend/app.py

@@ -22,6 +22,22 @@ app.secret_key = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
 auth_sessions: Dict[str, dict] = {}
 
 
+def create_anthropic_client(api_key: str, base_url: str) -> Anthropic:
+    """
+    创建 Anthropic 客户端,支持自定义认证格式
+
+    自定义 API 代理需要 'Authorization: Bearer <token>' 格式,
+    而不是 Anthropic SDK 默认的 'x-api-key' header。
+    """
+    import httpx
+    # 创建自定义 httpx client,设置正确的 Authorization header
+    http_client = httpx.Client(
+        headers={"Authorization": f"Bearer {api_key}"},
+        timeout=120.0
+    )
+    return Anthropic(base_url=base_url, http_client=http_client)
+
+
 @app.route('/')
 def index():
     return send_from_directory('../frontend', 'index.html')
@@ -32,8 +48,8 @@ def static_files(path):
     return send_from_directory('../frontend', path)
 
 
-# 初始化 Claude 客户端
-client = Anthropic(
+# 初始化 Claude 客户端(使用自定义认证格式)
+client = create_anthropic_client(
     api_key=ANTHROPIC_API_KEY,
     base_url=ANTHROPIC_BASE_URL
 )
@@ -268,12 +284,17 @@ def chat_stream():
                     for i, tr in enumerate(tool_results):
                         tool_name = tr.get("tool_name", "")
                         tool_result = tr.get("result", {})
+                        tool_use_id = tr.get("tool_use_id", "")
 
                         # 发送工具完成事件
                         if "error" in tool_result:
-                            yield f"event: tool_error\ndata: {json_module.dumps({'tool': tool_name, 'error': tool_result['error']})}\n\n"
+                            yield f"event: tool_error\ndata: {json_module.dumps({'tool': tool_name, 'tool_id': tool_use_id, 'error': tool_result['error']})}\n\n"
                         else:
-                            yield f"event: tool_done\ndata: {json_module.dumps({'tool': tool_name, 'result': tool_result.get('result', '')[:200]})}\n\n"
+                            result_data = tool_result.get('result', '')
+                            # 限制结果长度避免传输过大
+                            if isinstance(result_data, str) and len(result_data) > 500:
+                                result_data = result_data[:500] + '...'
+                            yield f"event: tool_done\ndata: {json_module.dumps({'tool': tool_name, 'tool_id': tool_use_id, 'result': result_data})}\n\n"
 
                         tool_calls_info.append({
                             "tool": tool_name,
@@ -517,6 +538,6 @@ def auth_status():
 
 
 if __name__ == '__main__':
-    port = int(os.getenv('PORT', 5000))
+    port = int(os.getenv('PORT', 8080))
     debug = os.getenv('DEBUG', 'False').lower() == 'true'
     app.run(host='0.0.0.0', port=port, debug=debug)

+ 10 - 9
backend/config.py

@@ -13,18 +13,18 @@ MCP_SERVERS = {
     },
     "novel-platform-user": {
         "name": "Novel Platform User MCP",
-        "url": "https://d8d-ai-vscode-238-xxxx.dev.d8d.fun/mcp",
+        "url": "https://d8d-ai-vscode-8080-223-238-template-6-group.dev.d8d.fun/mcp/",
         "auth_type": "jwt",
-        "login_url": "/api/auth/login",
-        "base_url": "https://d8d-ai-vscode-8081-223-238-template-6-group.dev.d8d.fun",
+        "login_url": "/api/v1/auth/login",
+        "base_url": "https://d8d-ai-vscode-8080-223-238-template-6-group.dev.d8d.fun",
         "enabled": True
     },
     "novel-platform-admin": {
         "name": "Novel Platform Admin MCP",
-        "url": "https://d8d-ai-vscode-238-xxxx.dev.d8d.fun/admin-mcp",
+        "url": "https://d8d-ai-vscode-8080-223-238-template-6-group.dev.d8d.fun/admin-mcp/",
         "auth_type": "jwt",
-        "login_url": "/api/auth/admin-login",
-        "base_url": "https://d8d-ai-vscode-8081-223-238-template-6-group.dev.d8d.fun",
+        "login_url": "/api/v1/auth/admin-login",
+        "base_url": "https://d8d-ai-vscode-8080-223-238-template-6-group.dev.d8d.fun",
         "enabled": True
     },
 }
@@ -49,6 +49,7 @@ NPM_MCP_SERVERS = {
 }
 
 # Claude API 配置
-ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_AUTH_TOKEN", "")
-ANTHROPIC_BASE_URL = os.getenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
-ANTHROPIC_MODEL = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-20250514")
+# 使用 D8D 内部 Claude API (无配额限制)
+ANTHROPIC_API_KEY = os.getenv("D8D_API_KEY", os.getenv("ANTHROPIC_AUTH_TOKEN", ""))
+ANTHROPIC_BASE_URL = os.getenv("D8D_API_BASE_URL", os.getenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com"))
+ANTHROPIC_MODEL = os.getenv("D8D_MODEL", os.getenv("ANTHROPIC_MODEL", "d8d-model"))

+ 18 - 1
backend/conversation_manager.py

@@ -3,12 +3,28 @@
 """
 import asyncio
 from typing import Dict, List, Any, Optional
+import httpx
 from anthropic import Anthropic
 from mcp_client import MCPClient
 from tool_converter import ToolConverter
 from tool_handler import ToolCallHandler
 
 
+def create_anthropic_client(api_key: str, base_url: str) -> Anthropic:
+    """
+    创建 Anthropic 客户端,支持自定义认证格式
+
+    自定义 API 代理需要 'Authorization: Bearer <token>' 格式,
+    而不是 Anthropic SDK 默认的 'x-api-key' header。
+    """
+    # 创建自定义 httpx client,设置正确的 Authorization header
+    http_client = httpx.Client(
+        headers={"Authorization": f"Bearer {api_key}"},
+        timeout=120.0
+    )
+    return Anthropic(base_url=base_url, http_client=http_client)
+
+
 class ConversationManager:
     """管理包含工具调用的多轮对话"""
 
@@ -25,7 +41,8 @@ class ConversationManager:
         self.session_id = session_id
         self.tool_handler = ToolCallHandler(session_id=session_id)
         self._cached_tools = None
-        self.client = Anthropic(api_key=api_key, base_url=base_url)
+        # 使用自定义 client,支持 Bearer token 认证
+        self.client = create_anthropic_client(api_key, base_url)
 
     async def get_available_tools(self) -> List[Dict[str, Any]]:
         """获取可用的 Claude 格式工具列表(带缓存)"""

+ 281 - 50
frontend/index.html

@@ -3,12 +3,137 @@
 <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>AI MCP Web UI</title>
+    <title>MCP 助手</title>
     <script src="https://cdn.tailwindcss.com"></script>
     <style>
         .chat-container {
             height: calc(100vh - 200px);
         }
+
+        /* 移动端优化 */
+        @media (max-width: 768px) {
+            .chat-container {
+                height: calc(100vh - 180px);
+            }
+
+            /* 头部固定 */
+            header {
+                position: sticky;
+                top: 0;
+                z-index: 50;
+            }
+
+            /* 消息气泡宽度 */
+            .message-bubble {
+                max-width: 85vw !important;
+            }
+
+            /* 输入区域固定 */
+            .input-area {
+                position: sticky;
+                bottom: 0;
+                z-index: 50;
+                background: white;
+                border-top-left-radius: 0;
+                border-top-right-radius: 0;
+            }
+        }
+
+        /* 参数/结果可折叠区域 */
+        .collapsible-section {
+            margin-top: 6px;
+        }
+
+        .collapsible-header {
+            display: flex;
+            align-items: center;
+            gap: 4px;
+            padding: 4px 8px;
+            background: rgba(255, 255, 255, 0.05);
+            border-radius: 4px;
+            cursor: pointer;
+            font-size: 11px;
+            color: #9ca3af;
+            user-select: none;
+        }
+
+        .collapsible-header:hover {
+            background: rgba(255, 255, 255, 0.08);
+        }
+
+        .collapsible-content {
+            max-height: 0;
+            overflow: hidden;
+            transition: max-height 0.3s ease-out;
+        }
+
+        .collapsible-content.expanded {
+            max-height: 300px;
+            overflow-y: auto;
+        }
+
+        .collapsible-icon {
+            transition: transform 0.3s;
+            font-size: 10px;
+        }
+
+        .collapsible-icon.expanded {
+            transform: rotate(90deg);
+        }
+
+        /* MCP 服务器横向滚动 */
+        .mcp-servers-scroll {
+            display: flex;
+            gap: 8px;
+            overflow-x: auto;
+            padding: 4px 0;
+            scrollbar-width: thin;
+            -webkit-overflow-scrolling: touch;
+        }
+
+        .mcp-servers-scroll::-webkit-scrollbar {
+            height: 4px;
+        }
+
+        .mcp-servers-scroll::-webkit-scrollbar-thumb {
+            background: rgba(0, 0, 0, 0.2);
+            border-radius: 2px;
+        }
+
+        .mcp-server-tag {
+            flex-shrink: 0;
+            display: flex;
+            align-items: center;
+            gap: 6px;
+            padding: 8px 12px;
+            border-radius: 8px;
+            font-size: 12px;
+            white-space: nowrap;
+        }
+
+        .mcp-server-tag.enabled {
+            background: #d1fae5;
+            color: #065f46;
+        }
+
+        .mcp-server-tag.disabled {
+            background: #f3f4f6;
+            color: #6b7280;
+        }
+
+        .mcp-status-dot {
+            width: 8px;
+            height: 8px;
+            border-radius: 50%;
+        }
+
+        .mcp-status-dot.online {
+            background: #10b981;
+        }
+
+        .mcp-status-dot.offline {
+            background: #9ca3af;
+        }
         .message-user {
             background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
         }
@@ -64,6 +189,10 @@
             transition: max-height 0.3s ease-out, padding 0.3s ease-out;
         }
 
+        .tool-panel-content.collapsed {
+            max-height: 0;
+        }
+
         .tool-panel-content.expanded {
             max-height: 800px;
             padding: 12px;
@@ -284,7 +413,7 @@
             <div id="chat-messages" class="chat-container overflow-y-auto p-4 space-y-4">
                 <!-- Welcome Message -->
                 <div class="flex justify-start">
-                    <div class="message-assistant rounded-lg p-4 max-w-[80%] shadow-sm">
+                    <div class="message-assistant message-bubble rounded-lg p-4 max-w-[80%] shadow-sm">
                         <p class="text-gray-800">你好!我是 AI MCP Web UI 助手。我可以帮助你:</p>
                         <ul class="mt-2 text-gray-700 list-disc list-inside">
                             <li>与 Claude AI 进行对话</li>
@@ -297,19 +426,21 @@
             </div>
 
             <!-- Input Area -->
-            <div class="border-t border-gray-200 p-4">
+            <div class="border-t border-gray-200 p-4 input-area">
                 <form id="chat-form" class="flex space-x-3">
                     <input
                         type="text"
                         id="user-input"
                         placeholder="输入你的消息..."
                         class="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
+                        style="font-size: 16px;"
                         autocomplete="off"
                     >
                     <button
                         type="submit"
                         id="send-button"
                         class="px-6 py-3 bg-gradient-to-r from-purple-500 to-indigo-600 text-white rounded-lg hover:from-purple-600 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
+                        style="font-size: 16px;"
                     >
                         发送
                     </button>
@@ -319,9 +450,9 @@
 
         <!-- MCP Servers Status -->
         <div class="mt-4 bg-white rounded-lg shadow-md p-4">
-            <h2 class="text-lg font-semibold text-gray-800 mb-3">MCP 服务器状态</h2>
-            <div id="mcp-servers" class="grid grid-cols-1 sm:grid-cols-3 gap-3">
-                <!-- MCP server cards will be inserted here -->
+            <h2 class="text-sm font-semibold text-gray-800 mb-2">MCP 服务器</h2>
+            <div id="mcp-servers" class="mcp-servers-scroll">
+                <!-- MCP server tags will be inserted here -->
             </div>
         </div>
     </div>
@@ -359,7 +490,7 @@
 
             const bubbleClass = role === 'user' ? 'message-user text-white' : 'message-assistant text-gray-800';
             messageDiv.innerHTML = `
-                <div class="${bubbleClass} rounded-lg p-4 max-w-[80%] shadow-sm">
+                <div class="${bubbleClass} message-bubble rounded-lg p-4 max-w-[80%] shadow-sm">
                     <div>${formatMessage(content)}</div>
                 </div>
             `;
@@ -399,8 +530,8 @@
             panel.innerHTML = `
                 <div class="tool-panel-header" onclick="toggleToolPanel(this)">
                     <div class="tool-panel-title">
-                        <span class="expand-icon"></span>
-                        <span>🔧 工具调用过程</span>
+                        <span class="expand-icon"></span>
+                        <span>🔧 工具调用</span>
                         <span class="tool-count">(0)</span>
                     </div>
                     <div class="process-indicator">
@@ -408,7 +539,7 @@
                         <span>处理中...</span>
                     </div>
                 </div>
-                <div class="tool-panel-content expanded">
+                <div class="tool-panel-content">
                     <div class="tool-calls-container"></div>
                 </div>
             `;
@@ -423,36 +554,75 @@
             icon.classList.toggle('expanded');
         }
 
+        // 切换参数/结果折叠区域
+        function toggleCollapsible(header) {
+            const content = header.nextElementSibling;
+            const icon = header.querySelector('.collapsible-icon');
+            content.classList.toggle('expanded');
+            icon.classList.toggle('expanded');
+        }
+
         // 添加工具调用项
-        function addToolCallItem(container, toolName, params) {
+        function addToolCallItem(container, toolName, params, toolUseId = null) {
             const item = document.createElement('div');
             item.className = 'tool-call-item pending';
-            item.id = `tool-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+            const uniqueId = `tool-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+            item.id = uniqueId;
+
+            // 存储 tool_use_id 和 tool_name 以便后续匹配
+            if (toolUseId) {
+                item.dataset.toolUseId = toolUseId;
+            }
+            item.dataset.toolName = toolName;
+
+            console.log('[addToolCallItem] Created item:', { id: uniqueId, toolName, toolUseId });
+
+            // 简化工具名称显示 - 去掉 mcp__ 前缀和下划线
+            let displayName = toolName;
+            if (toolName.startsWith('mcp__')) {
+                displayName = toolName.replace('mcp__', '').replace(/_/g, ' ');
+            }
+            // 首字母大写
+            displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1);
 
             const paramsStr = params ? JSON.stringify(params, null, 2) : '{}';
+            const hasParams = params && Object.keys(params).length > 0;
 
             item.innerHTML = `
                 <div class="tool-call-header">
                     <div class="tool-name">
                         <span>🔧</span>
-                        <span>${escapeHtml(toolName)}</span>
+                        <span>${escapeHtml(displayName)}</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>` : ''}
+                ${hasParams ? `
+                <div class="collapsible-section">
+                    <div class="collapsible-header" onclick="toggleCollapsible(this)">
+                        <span class="collapsible-icon">▶</span>
+                        <span>查看参数</span>
+                    </div>
+                    <div class="collapsible-content">
+                        <div class="tool-params"><pre style="margin:0;padding:0;background:transparent;font-size:10px;">${escapeHtml(paramsStr)}</pre></div>
+                    </div>
+                </div>
+                ` : ''}
             `;
 
             container.appendChild(item);
-            return item.id;
+            return { id: uniqueId, toolUseId: toolUseId, toolName: toolName };
         }
 
         // 更新工具调用状态
         function updateToolCallStatus(itemId, status, result = null) {
             const item = document.getElementById(itemId);
-            if (!item) return;
+            if (!item) {
+                console.warn('[updateToolCallStatus] Item not found:', itemId);
+                return;
+            }
 
             const statusEl = item.querySelector('.tool-status');
             item.classList.remove('pending', 'success', 'error');
@@ -460,23 +630,41 @@
             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);
-                }
+                statusEl.innerHTML = '✅ 完成';
+
+                // 总是显示结果区域(即使结果为空)
+                const resultText = result ? (typeof result === 'string' ? result : JSON.stringify(result, null, 2)) : '';
+                const displayResult = resultText || '(无返回结果)';
+                const truncatedResult = displayResult.substring(0, 1000) + (displayResult.length > 1000 ? '...' : '');
+
+                const resultDiv = document.createElement('div');
+                resultDiv.className = 'collapsible-section';
+                resultDiv.innerHTML = `
+                    <div class="collapsible-header" onclick="toggleCollapsible(this)">
+                        <span class="collapsible-icon">▶</span>
+                        <span>查看结果</span>
+                    </div>
+                    <div class="collapsible-content">
+                        <div class="tool-result"><div class="tool-result-content">${escapeHtml(truncatedResult)}</div></div>
+                    </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 || '未知错误')}`;
+                errorDiv.className = 'collapsible-section';
+                errorDiv.innerHTML = `
+                    <div class="collapsible-header" onclick="toggleCollapsible(this)">
+                        <span class="collapsible-icon">▶</span>
+                        <span>查看错误</span>
+                    </div>
+                    <div class="collapsible-content expanded">
+                        <div class="tool-result error">${escapeHtml(result || '未知错误')}</div>
+                    </div>
+                `;
                 item.appendChild(errorDiv);
             }
         }
@@ -485,12 +673,23 @@
         function updateToolPanelStatus(panel, status, totalCalls = 0) {
             const indicator = panel.querySelector('.process-indicator');
             const countEl = panel.querySelector('.tool-count');
+            const content = panel.querySelector('.tool-panel-content');
+            const icon = panel.querySelector('.expand-icon');
 
             countEl.textContent = `(${totalCalls})`;
 
-            if (status === 'complete') {
+            if (status === 'running') {
+                // 保持处理中状态,不改变
+                indicator.className = 'process-indicator';
+                indicator.innerHTML = `<div class="spinner"></div><span>处理中...</span>`;
+            } else if (status === 'complete') {
                 indicator.className = 'process-indicator success';
                 indicator.innerHTML = `<span>✅ 完成 - ${totalCalls} 个工具调用</span>`;
+                // 自动展开面板以便查看结果
+                if (content && !content.classList.contains('expanded')) {
+                    content.classList.add('expanded');
+                    if (icon) icon.classList.add('expanded');
+                }
             } else if (status === 'error') {
                 indicator.className = 'process-indicator';
                 indicator.style.background = 'rgba(239, 68, 68, 0.1)';
@@ -585,7 +784,7 @@
             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" style="max-width: 90%;">
+                <div class="message-assistant message-bubble rounded-lg p-4 shadow-sm">
                     <div class="response-content">
                         <div class="text" id="currentResponse"></div>
                     </div>
@@ -599,7 +798,7 @@
 
             let currentText = '';
             let toolCallsCount = 0;
-            let toolCallItems = {};
+            let toolCallItems = [];  // 改为数组存储,按索引匹配
             let finalResponse = '';
             let toolCallPanel = null;
             let toolCallsContainer = null;
@@ -636,9 +835,16 @@
                         // 添加工具调用项
                         const toolName = data.tool || 'unknown';
                         const params = data.args || data.params || null;
-                        const itemId = addToolCallItem(toolCallsContainer, toolName, params);
-                        toolCallItems[data.tool_id || toolName] = itemId;
+                        const toolUseId = data.tool_id || null;
+
+                        console.log('[tool_call] Adding tool:', { toolName, toolUseId, params });
+
+                        const itemInfo = addToolCallItem(toolCallsContainer, toolName, params, toolUseId);
+                        toolCallItems.push(itemInfo);
                         toolCallsCount++;
+
+                        console.log('[tool_call] Current toolCallItems:', toolCallItems);
+
                         updateToolPanelStatus(toolCallPanel, 'running', toolCallsCount);
                         scrollToBottom();
                         break;
@@ -657,21 +863,47 @@
                         break;
 
                     case 'tool_done':
-                        // 工具执行完成
-                        const toolId = data.tool_id || data.tool;
-                        const itemId2 = toolCallItems[toolId];
-                        if (itemId2) {
-                            const result = data.result || '';
-                            updateToolCallStatus(itemId2, 'success', result);
+                        // 工具执行完成 - 按 tool_use_id 或 tool_name 匹配
+                        const doneToolUseId = data.tool_id;
+                        const doneToolName = data.tool;
+                        const doneResult = data.result;
+
+                        console.log('[tool_done] Looking for tool:', { tool_id: doneToolUseId, tool: doneToolName });
+                        console.log('[tool_done] Current toolCallItems:', toolCallItems);
+
+                        // 查找匹配的工具调用项
+                        const doneItem = toolCallItems.find(item =>
+                            item.toolUseId === doneToolUseId || item.toolName === doneToolName
+                        );
+
+                        if (doneItem) {
+                            console.log('[tool_done] Found item, updating status:', doneItem.id);
+                            updateToolCallStatus(doneItem.id, 'success', doneResult);
+                        } else {
+                            console.warn('[tool_done] Tool item not found for:', { tool_id: doneToolUseId, tool: doneToolName });
+                            // 如果找不到匹配项,尝试通过 DOM 查找
+                            const fallbackItem = Array.from(document.querySelectorAll('.tool-call-item')).find(el => {
+                                return el.dataset.toolUseId === doneToolUseId || el.dataset.toolName === doneToolName;
+                            });
+                            if (fallbackItem) {
+                                console.log('[tool_done] Found via DOM fallback:', fallbackItem.id);
+                                updateToolCallStatus(fallbackItem.id, 'success', doneResult);
+                            }
                         }
                         scrollToBottom();
                         break;
 
                     case 'tool_error':
-                        // 工具执行错误
-                        const errorItemId = toolCallItems[data.tool_id || data.tool];
-                        if (errorItemId) {
-                            updateToolCallStatus(errorItemId, 'error', data.error);
+                        // 工具执行错误 - 按 tool_use_id 或 tool_name 匹配
+                        const errorToolUseId = data.tool_id;
+                        const errorToolName = data.tool;
+
+                        const errorItem = toolCallItems.find(item =>
+                            item.toolUseId === errorToolUseId || item.toolName === errorToolName
+                        );
+
+                        if (errorItem) {
+                            updateToolCallStatus(errorItem.id, 'error', data.error);
                         }
                         if (toolCallPanel) {
                             updateToolPanelStatus(toolCallPanel, 'error', toolCallsCount);
@@ -681,6 +913,7 @@
 
                     case 'complete':
                         // 完成
+                        console.log('[complete] Tool calls finished, updating panel status');
                         if (toolCallPanel) {
                             updateToolPanelStatus(toolCallPanel, 'complete', toolCallsCount);
                         }
@@ -717,13 +950,10 @@
                 const data = await response.json();
 
                 mcpServers.innerHTML = data.servers.map(server => `
-                    <div class="flex items-center space-x-2 p-3 rounded-lg ${server.enabled ? 'bg-green-50' : 'bg-gray-50'}">
-                        <span class="w-2 h-2 ${server.enabled ? 'bg-green-500' : 'bg-gray-400'} rounded-full"></span>
-                        <div>
-                            <p class="font-medium text-gray-800">${server.name}</p>
-                            <p class="text-xs text-gray-500 truncate">${server.url}</p>
-                            <p class="text-xs text-purple-600">${server.auth_type === 'jwt' ? '🔒 JWT 认证' : '🔓 无需认证'}</p>
-                        </div>
+                    <div class="mcp-server-tag ${server.enabled ? 'enabled' : 'disabled'}">
+                        <span class="mcp-status-dot ${server.enabled ? 'online' : 'offline'}"></span>
+                        <span class="font-medium">${server.name}</span>
+                        ${server.auth_type === 'jwt' ? '<span>🔒</span>' : ''}
                     </div>
                 `).join('');
             } catch (error) {
@@ -766,8 +996,9 @@
         checkHealth();
         userInput.focus();
 
-        // Make toggle function global
+        // Make toggle functions global
         window.toggleToolPanel = toggleToolPanel;
+        window.toggleCollapsible = toggleCollapsible;
     </script>
 </body>
 </html>

+ 943 - 0
frontend/index.html.backup

@@ -0,0 +1,943 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>AI MCP Web UI</title>
+    <script src="https://cdn.tailwindcss.com"></script>
+    <style>
+        .chat-container {
+            height: calc(100vh - 200px);
+        }
+
+        /* 移动端优化 */
+        @media (max-width: 768px) {
+            .chat-container {
+                height: calc(100vh - 180px);
+            }
+
+            /* 头部固定 */
+            header {
+                position: sticky;
+                top: 0;
+                z-index: 50;
+            }
+
+            /* 消息气泡宽度 */
+            .message-bubble {
+                max-width: 85vw !important;
+            }
+
+            /* 输入区域固定 */
+            .input-area {
+                position: sticky;
+                bottom: 0;
+                z-index: 50;
+                background: white;
+                border-top-left-radius: 0;
+                border-top-right-radius: 0;
+            }
+        }
+
+        /* 参数/结果可折叠区域 */
+        .collapsible-section {
+            margin-top: 6px;
+        }
+
+        .collapsible-header {
+            display: flex;
+            align-items: center;
+            gap: 4px;
+            padding: 4px 8px;
+            background: rgba(255, 255, 255, 0.05);
+            border-radius: 4px;
+            cursor: pointer;
+            font-size: 11px;
+            color: #9ca3af;
+            user-select: none;
+        }
+
+        .collapsible-header:hover {
+            background: rgba(255, 255, 255, 0.08);
+        }
+
+        .collapsible-content {
+            max-height: 0;
+            overflow: hidden;
+            transition: max-height 0.3s ease-out;
+        }
+
+        .collapsible-content.expanded {
+            max-height: 300px;
+            overflow-y: auto;
+        }
+
+        .collapsible-icon {
+            transition: transform 0.3s;
+            font-size: 10px;
+        }
+
+        .collapsible-icon.expanded {
+            transform: rotate(90deg);
+        }
+
+        /* MCP 服务器横向滚动 */
+        .mcp-servers-scroll {
+            display: flex;
+            gap: 8px;
+            overflow-x: auto;
+            padding: 4px 0;
+            scrollbar-width: thin;
+            -webkit-overflow-scrolling: touch;
+        }
+
+        .mcp-servers-scroll::-webkit-scrollbar {
+            height: 4px;
+        }
+
+        .mcp-servers-scroll::-webkit-scrollbar-thumb {
+            background: rgba(0, 0, 0, 0.2);
+            border-radius: 2px;
+        }
+
+        .mcp-server-tag {
+            flex-shrink: 0;
+            display: flex;
+            align-items: center;
+            gap: 6px;
+            padding: 8px 12px;
+            border-radius: 8px;
+            font-size: 12px;
+            white-space: nowrap;
+        }
+
+        .mcp-server-tag.enabled {
+            background: #d1fae5;
+            color: #065f46;
+        }
+
+        .mcp-server-tag.disabled {
+            background: #f3f4f6;
+            color: #6b7280;
+        }
+
+        .mcp-status-dot {
+            width: 8px;
+            height: 8px;
+            border-radius: 50%;
+        }
+
+        .mcp-status-dot.online {
+            background: #10b981;
+        }
+
+        .mcp-status-dot.offline {
+            background: #9ca3af;
+        }
+        .message-user {
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+        }
+        .message-assistant {
+            background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
+        }
+        .typing-indicator span {
+            animation: bounce 1.4s infinite ease-in-out both;
+        }
+        .typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
+        .typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
+        @keyframes bounce {
+            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;
+            padding: 1rem;
+            border-radius: 0.5rem;
+            overflow-x: auto;
+        }
+        code {
+            font-family: 'Courier New', monospace;
+        }
+    </style>
+</head>
+<body class="bg-gray-100 min-h-screen">
+    <div class="container mx-auto px-4 py-6 max-w-4xl">
+        <!-- Header -->
+        <header class="bg-white rounded-lg shadow-md p-4 mb-4">
+            <div class="flex items-center justify-between">
+                <div class="flex items-center space-x-3">
+                    <div class="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-lg flex items-center justify-center">
+                        <span class="text-white font-bold">AI</span>
+                    </div>
+                    <div>
+                        <h1 class="text-xl font-bold text-gray-800">AI MCP Web UI</h1>
+                        <p class="text-sm text-gray-500">通用 MCP 服务器 Web 界面</p>
+                    </div>
+                </div>
+                <div id="status-indicator" class="flex items-center space-x-2">
+                    <span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
+                    <span class="text-sm text-gray-600">已连接</span>
+                </div>
+            </div>
+        </header>
+
+        <!-- Chat Container -->
+        <div class="bg-white rounded-lg shadow-md">
+            <!-- Messages -->
+            <div id="chat-messages" class="chat-container overflow-y-auto p-4 space-y-4">
+                <!-- Welcome Message -->
+                <div class="flex justify-start">
+                    <div class="message-assistant message-bubble rounded-lg p-4 max-w-[80%] shadow-sm">
+                        <p class="text-gray-800">你好!我是 AI MCP Web UI 助手。我可以帮助你:</p>
+                        <ul class="mt-2 text-gray-700 list-disc list-inside">
+                            <li>与 Claude AI 进行对话</li>
+                            <li>调用 MCP 服务器工具</li>
+                            <li>自动化各种任务</li>
+                        </ul>
+                        <p class="mt-2 text-gray-600 text-sm">请问有什么可以帮助你的?</p>
+                    </div>
+                </div>
+            </div>
+
+            <!-- Input Area -->
+            <div class="border-t border-gray-200 p-4 input-area">
+                <form id="chat-form" class="flex space-x-3">
+                    <input
+                        type="text"
+                        id="user-input"
+                        placeholder="输入你的消息..."
+                        class="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
+                        style="font-size: 16px;"
+                        autocomplete="off"
+                    >
+                    <button
+                        type="submit"
+                        id="send-button"
+                        class="px-6 py-3 bg-gradient-to-r from-purple-500 to-indigo-600 text-white rounded-lg hover:from-purple-600 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
+                        style="font-size: 16px;"
+                    >
+                        发送
+                    </button>
+                </form>
+            </div>
+        </div>
+
+        <!-- MCP Servers Status -->
+        <div class="mt-4 bg-white rounded-lg shadow-md p-4">
+            <h2 class="text-sm font-semibold text-gray-800 mb-2">MCP 服务器</h2>
+            <div id="mcp-servers" class="mcp-servers-scroll">
+                <!-- MCP server tags will be inserted here -->
+            </div>
+        </div>
+    </div>
+
+    <script>
+        // State
+        let conversationHistory = [];
+        let isTyping = false;
+
+        // DOM Elements
+        const chatMessages = document.getElementById('chat-messages');
+        const chatForm = document.getElementById('chat-form');
+        const userInput = document.getElementById('user-input');
+        const sendButton = document.getElementById('send-button');
+        const mcpServers = document.getElementById('mcp-servers');
+        const statusIndicator = document.getElementById('status-indicator');
+
+        // API Base URL
+        const API_BASE = window.location.origin;
+
+        // Format message content (handle code blocks, etc.)
+        function formatMessage(content) {
+            // Simple markdown-like formatting
+            let formatted = content
+                .replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
+                .replace(/`([^`]+)`/g, '<code class="bg-gray-200 px-1 rounded">$1</code>')
+                .replace(/\n/g, '<br>');
+            return formatted;
+        }
+
+        // Add message to chat
+        function addMessage(role, content) {
+            const messageDiv = document.createElement('div');
+            messageDiv.className = `flex ${role === 'user' ? 'justify-end' : 'justify-start'}`;
+
+            const bubbleClass = role === 'user' ? 'message-user text-white' : 'message-assistant text-gray-800';
+            messageDiv.innerHTML = `
+                <div class="${bubbleClass} message-bubble rounded-lg p-4 max-w-[80%] shadow-sm">
+                    <div>${formatMessage(content)}</div>
+                </div>
+            `;
+
+            chatMessages.appendChild(messageDiv);
+            chatMessages.scrollTop = chatMessages.scrollHeight;
+        }
+
+        // Add typing indicator
+        function addTypingIndicator() {
+            const typingDiv = document.createElement('div');
+            typingDiv.id = 'typing-indicator';
+            typingDiv.className = 'flex justify-start';
+            typingDiv.innerHTML = `
+                <div class="message-assistant rounded-lg p-4 shadow-sm">
+                    <div class="typing-indicator flex space-x-1">
+                        <span class="w-2 h-2 bg-gray-500 rounded-full"></span>
+                        <span class="w-2 h-2 bg-gray-500 rounded-full"></span>
+                        <span class="w-2 h-2 bg-gray-500 rounded-full"></span>
+                    </div>
+                </div>
+            `;
+            chatMessages.appendChild(typingDiv);
+            chatMessages.scrollTop = chatMessages.scrollHeight;
+        }
+
+        // Remove typing indicator
+        function removeTypingIndicator() {
+            const indicator = document.getElementById('typing-indicator');
+            if (indicator) indicator.remove();
+        }
+
+        // 创建工具调用面板
+        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">
+                    <div class="tool-calls-container"></div>
+                </div>
+            `;
+            return panel;
+        }
+
+        // 切换工具面板展开/收起
+        function toggleToolPanel(header) {
+            const content = header.nextElementSibling;
+            const icon = header.querySelector('.expand-icon');
+            content.classList.toggle('expanded');
+            icon.classList.toggle('expanded');
+        }
+
+        // 切换参数/结果折叠区域
+        function toggleCollapsible(header) {
+            const content = header.nextElementSibling;
+            const icon = header.querySelector('.collapsible-icon');
+            content.classList.toggle('expanded');
+            icon.classList.toggle('expanded');
+        }
+
+        // 添加工具调用项
+        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)}`;
+
+            // 简化工具名称显示 - 去掉 mcp__ 前缀和下划线
+            let displayName = toolName;
+            if (toolName.startsWith('mcp__')) {
+                displayName = toolName.replace('mcp__', '').replace(/_/g, ' ');
+            }
+            // 首字母大写
+            displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1);
+
+            const paramsStr = params ? JSON.stringify(params, null, 2) : '{}';
+            const hasParams = params && Object.keys(params).length > 0;
+
+            item.innerHTML = `
+                <div class="tool-call-header">
+                    <div class="tool-name">
+                        <span>🔧</span>
+                        <span>${escapeHtml(displayName)}</span>
+                    </div>
+                    <div class="tool-status pending">
+                        <div class="spinner"></div>
+                        <span>执行中</span>
+                    </div>
+                </div>
+                ${hasParams ? `
+                <div class="collapsible-section">
+                    <div class="collapsible-header" onclick="toggleCollapsible(this)">
+                        <span class="collapsible-icon">▶</span>
+                        <span>查看参数</span>
+                    </div>
+                    <div class="collapsible-content">
+                        <div class="tool-params"><pre style="margin:0;padding:0;background:transparent;font-size:10px;">${escapeHtml(paramsStr)}</pre></div>
+                    </div>
+                </div>
+                ` : ''}
+            `;
+
+            container.appendChild(item);
+            return item.id;
+        }
+
+        // 更新工具调用状态
+        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 resultText = typeof result === 'string' ? result : JSON.stringify(result);
+                    const truncatedResult = resultText.substring(0, 500) + (resultText.length > 500 ? '...' : '');
+
+                    const resultDiv = document.createElement('div');
+                    resultDiv.className = 'collapsible-section';
+                    resultDiv.innerHTML = `
+                        <div class="collapsible-header" onclick="toggleCollapsible(this)">
+                            <span class="collapsible-icon">▶</span>
+                            <span>查看结果</span>
+                        </div>
+                        <div class="collapsible-content">
+                            <div class="tool-result"><div class="tool-result-content">${escapeHtml(truncatedResult)}</div></div>
+                        </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 = 'collapsible-section';
+                errorDiv.innerHTML = `
+                    <div class="collapsible-header" onclick="toggleCollapsible(this)">
+                        <span class="collapsible-icon">▶</span>
+                        <span>查看错误</span>
+                    </div>
+                    <div class="collapsible-content">
+                        <div class="tool-result error">${escapeHtml(result || '未知错误')}</div>
+                    </div>
+                `;
+                item.appendChild(errorDiv);
+            }
+        }
+
+        // 更新工具面板状态
+        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>`;
+            }
+        }
+
+        // HTML 转义
+        function escapeHtml(text) {
+            const div = document.createElement('div');
+            div.textContent = text;
+            return div.innerHTML;
+        }
+
+        function scrollToBottom() {
+            chatMessages.scrollTop = chatMessages.scrollHeight;
+        }
+
+        // ========== 流式聊天功能 ==========
+
+        async function chatStream(message, onEvent) {
+            const events = [];
+
+            try {
+                const response = await fetch('/api/chat/stream', {
+                    method: 'POST',
+                    headers: { 'Content-Type': 'application/json' },
+                    body: JSON.stringify({ message, history: conversationHistory })
+                });
+
+                if (!response.ok) {
+                    throw new Error(`HTTP error! status: ${response.status}`);
+                }
+
+                const reader = response.body.getReader();
+                const decoder = new TextDecoder();
+                let buffer = '';
+
+                while (true) {
+                    const { done, value } = await reader.read();
+                    if (done) break;
+
+                    buffer += decoder.decode(value, { stream: true });
+
+                    // 处理 SSE 格式: "event: xxx\ndata: {...}\n\n"
+                    const lines = buffer.split('\n');
+                    buffer = lines.pop() || '';  // 保留未完成的行
+
+                    for (let i = 0; i < lines.length; i++) {
+                        const line = lines[i].trim();
+                        if (!line) continue;
+
+                        if (line.startsWith('event:')) {
+                            const eventType = line.substring(6).trim();
+                            events.push({ type: eventType, data: null });
+                        } else if (line.startsWith('data:')) {
+                            const data = line.substring(5).trim();
+                            if (events.length > 0) {
+                                events[events.length - 1].data = data;
+                            }
+                        }
+
+                        // 处理完整的事件
+                        while (events.length > 0 && events[0].data) {
+                            const event = events.shift();
+                            try {
+                                const data = JSON.parse(event.data);
+                                onEvent(event.type, data);
+                            } catch (e) {
+                                console.error('Parse error:', e, event.data);
+                            }
+                        }
+                    }
+                }
+            } catch (error) {
+                onEvent('error', { error: error.message });
+            }
+        }
+
+        async function sendMessageStream(message) {
+            if (isTyping) return;
+
+            isTyping = true;
+            sendButton.disabled = true;
+
+            // 添加用户消息
+            addMessage('user', message);
+            conversationHistory.push({ role: 'user', content: message });
+
+            // 创建助手消息容器
+            const assistantMsgDiv = document.createElement('div');
+            assistantMsgDiv.className = 'flex justify-start';
+            assistantMsgDiv.innerHTML = `
+                <div class="message-assistant message-bubble rounded-lg p-4 shadow-sm">
+                    <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 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':
+                        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':
+                        // 首次工具调用时创建面板
+                        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':
+                        // 批量工具开始
+                        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':
+                        // 工具执行完成
+                        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':
+                        // 工具执行错误
+                        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':
+                        // 完成
+                        if (toolCallPanel) {
+                            updateToolPanelStatus(toolCallPanel, 'complete', toolCallsCount);
+                        }
+
+                        // 清空临时提示并显示最终响应
+                        if (data.response || currentText) {
+                            finalResponse = data.response || currentText;
+                            responseDiv.innerHTML = formatMessage(finalResponse);
+                        }
+                        conversationHistory.push({ role: 'assistant', content: finalResponse });
+                        scrollToBottom();
+                        break;
+
+                    case 'error':
+                        responseDiv.innerHTML = `<span style="color: #ef4444;">❌ 错误: ${escapeHtml(data.error || '未知错误')}</span>`;
+                        if (toolCallPanel) {
+                            updateToolPanelStatus(toolCallPanel, 'error', toolCallsCount);
+                        }
+                        scrollToBottom();
+                        break;
+                }
+            });
+
+            // 重置状态
+            isTyping = false;
+            sendButton.disabled = false;
+            userInput.focus();
+        }
+
+        // Load MCP servers
+        async function loadMCPServers() {
+            try {
+                const response = await fetch(`${API_BASE}/api/mcp/servers`);
+                const data = await response.json();
+
+                mcpServers.innerHTML = data.servers.map(server => `
+                    <div class="mcp-server-tag ${server.enabled ? 'enabled' : 'disabled'}">
+                        <span class="mcp-status-dot ${server.enabled ? 'online' : 'offline'}"></span>
+                        <span class="font-medium">${server.name}</span>
+                        ${server.auth_type === 'jwt' ? '<span>🔒</span>' : ''}
+                    </div>
+                `).join('');
+            } catch (error) {
+                console.error('Failed to load MCP servers:', error);
+            }
+        }
+
+        // Check health
+        async function checkHealth() {
+            try {
+                const response = await fetch(`${API_BASE}/api/health`);
+                if (response.ok) {
+                    statusIndicator.innerHTML = `
+                        <span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
+                        <span class="text-sm text-gray-600">已连接</span>
+                    `;
+                } else {
+                    throw new Error('Health check failed');
+                }
+            } catch (error) {
+                statusIndicator.innerHTML = `
+                    <span class="w-2 h-2 bg-red-500 rounded-full"></span>
+                    <span class="text-sm text-gray-600">连接失败</span>
+                `;
+            }
+        }
+
+        // Event listeners
+        chatForm.addEventListener('submit', (e) => {
+            e.preventDefault();
+            const message = userInput.value.trim();
+            if (message) {
+                sendMessageStream(message);
+                userInput.value = '';
+            }
+        });
+
+        // Initialize
+        loadMCPServers();
+        checkHealth();
+        userInput.focus();
+
+        // Make toggle functions global
+        window.toggleToolPanel = toggleToolPanel;
+        window.toggleCollapsible = toggleCollapsible;
+    </script>
+</body>
+</html>