2
0

index.html.backup 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>AI MCP Web UI</title>
  7. <script src="https://cdn.tailwindcss.com"></script>
  8. <style>
  9. .chat-container {
  10. height: calc(100vh - 200px);
  11. }
  12. /* 移动端优化 */
  13. @media (max-width: 768px) {
  14. .chat-container {
  15. height: calc(100vh - 180px);
  16. }
  17. /* 头部固定 */
  18. header {
  19. position: sticky;
  20. top: 0;
  21. z-index: 50;
  22. }
  23. /* 消息气泡宽度 */
  24. .message-bubble {
  25. max-width: 85vw !important;
  26. }
  27. /* 输入区域固定 */
  28. .input-area {
  29. position: sticky;
  30. bottom: 0;
  31. z-index: 50;
  32. background: white;
  33. border-top-left-radius: 0;
  34. border-top-right-radius: 0;
  35. }
  36. }
  37. /* 参数/结果可折叠区域 */
  38. .collapsible-section {
  39. margin-top: 6px;
  40. }
  41. .collapsible-header {
  42. display: flex;
  43. align-items: center;
  44. gap: 4px;
  45. padding: 4px 8px;
  46. background: rgba(255, 255, 255, 0.05);
  47. border-radius: 4px;
  48. cursor: pointer;
  49. font-size: 11px;
  50. color: #9ca3af;
  51. user-select: none;
  52. }
  53. .collapsible-header:hover {
  54. background: rgba(255, 255, 255, 0.08);
  55. }
  56. .collapsible-content {
  57. max-height: 0;
  58. overflow: hidden;
  59. transition: max-height 0.3s ease-out;
  60. }
  61. .collapsible-content.expanded {
  62. max-height: 300px;
  63. overflow-y: auto;
  64. }
  65. .collapsible-icon {
  66. transition: transform 0.3s;
  67. font-size: 10px;
  68. }
  69. .collapsible-icon.expanded {
  70. transform: rotate(90deg);
  71. }
  72. /* MCP 服务器横向滚动 */
  73. .mcp-servers-scroll {
  74. display: flex;
  75. gap: 8px;
  76. overflow-x: auto;
  77. padding: 4px 0;
  78. scrollbar-width: thin;
  79. -webkit-overflow-scrolling: touch;
  80. }
  81. .mcp-servers-scroll::-webkit-scrollbar {
  82. height: 4px;
  83. }
  84. .mcp-servers-scroll::-webkit-scrollbar-thumb {
  85. background: rgba(0, 0, 0, 0.2);
  86. border-radius: 2px;
  87. }
  88. .mcp-server-tag {
  89. flex-shrink: 0;
  90. display: flex;
  91. align-items: center;
  92. gap: 6px;
  93. padding: 8px 12px;
  94. border-radius: 8px;
  95. font-size: 12px;
  96. white-space: nowrap;
  97. }
  98. .mcp-server-tag.enabled {
  99. background: #d1fae5;
  100. color: #065f46;
  101. }
  102. .mcp-server-tag.disabled {
  103. background: #f3f4f6;
  104. color: #6b7280;
  105. }
  106. .mcp-status-dot {
  107. width: 8px;
  108. height: 8px;
  109. border-radius: 50%;
  110. }
  111. .mcp-status-dot.online {
  112. background: #10b981;
  113. }
  114. .mcp-status-dot.offline {
  115. background: #9ca3af;
  116. }
  117. .message-user {
  118. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  119. }
  120. .message-assistant {
  121. background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
  122. }
  123. .typing-indicator span {
  124. animation: bounce 1.4s infinite ease-in-out both;
  125. }
  126. .typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
  127. .typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
  128. @keyframes bounce {
  129. 0%, 80%, 100% { transform: scale(0); }
  130. 40% { transform: scale(1); }
  131. }
  132. /* 工具调用面板样式 */
  133. .tool-call-panel {
  134. background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
  135. border-radius: 8px;
  136. margin: 12px 0;
  137. overflow: hidden;
  138. border: 1px solid #3a3a5a;
  139. }
  140. .tool-panel-header {
  141. background: rgba(255, 255, 255, 0.05);
  142. padding: 10px 14px;
  143. cursor: pointer;
  144. display: flex;
  145. align-items: center;
  146. justify-content: space-between;
  147. transition: background 0.2s;
  148. }
  149. .tool-panel-header:hover {
  150. background: rgba(255, 255, 255, 0.08);
  151. }
  152. .tool-panel-title {
  153. display: flex;
  154. align-items: center;
  155. gap: 8px;
  156. color: #e0e0e0;
  157. font-size: 13px;
  158. font-weight: 500;
  159. }
  160. .tool-panel-content {
  161. padding: 0;
  162. max-height: 0;
  163. overflow: hidden;
  164. transition: max-height 0.3s ease-out, padding 0.3s ease-out;
  165. }
  166. .tool-panel-content.expanded {
  167. max-height: 800px;
  168. padding: 12px;
  169. overflow-y: auto;
  170. }
  171. .tool-panel-content::-webkit-scrollbar {
  172. width: 6px;
  173. }
  174. .tool-panel-content::-webkit-scrollbar-track {
  175. background: rgba(255, 255, 255, 0.05);
  176. }
  177. .tool-panel-content::-webkit-scrollbar-thumb {
  178. background: rgba(255, 255, 255, 0.2);
  179. border-radius: 3px;
  180. }
  181. /* 工具调用项样式 */
  182. .tool-call-item {
  183. background: rgba(255, 255, 255, 0.03);
  184. border-radius: 6px;
  185. padding: 10px 12px;
  186. margin-bottom: 8px;
  187. border-left: 3px solid #555;
  188. transition: all 0.2s;
  189. }
  190. .tool-call-item:last-child {
  191. margin-bottom: 0;
  192. }
  193. .tool-call-item.pending {
  194. border-left-color: #f59e0b;
  195. animation: pulse-border 1.5s infinite;
  196. }
  197. .tool-call-item.success {
  198. border-left-color: #10b981;
  199. }
  200. .tool-call-item.error {
  201. border-left-color: #ef4444;
  202. }
  203. @keyframes pulse-border {
  204. 0%, 100% { border-left-color: #f59e0b; }
  205. 50% { border-left-color: #fbbf24; }
  206. }
  207. .tool-call-header {
  208. display: flex;
  209. align-items: center;
  210. justify-content: space-between;
  211. margin-bottom: 6px;
  212. }
  213. .tool-name {
  214. color: #e0e0e0;
  215. font-size: 13px;
  216. font-weight: 500;
  217. display: flex;
  218. align-items: center;
  219. gap: 6px;
  220. }
  221. .tool-status {
  222. font-size: 11px;
  223. padding: 2px 8px;
  224. border-radius: 10px;
  225. font-weight: 500;
  226. }
  227. .tool-status.pending {
  228. background: rgba(245, 158, 11, 0.2);
  229. color: #fbbf24;
  230. }
  231. .tool-status.success {
  232. background: rgba(16, 185, 129, 0.2);
  233. color: #34d399;
  234. }
  235. .tool-status.error {
  236. background: rgba(239, 68, 68, 0.2);
  237. color: #f87171;
  238. }
  239. .tool-params {
  240. background: rgba(0, 0, 0, 0.3);
  241. border-radius: 4px;
  242. padding: 8px;
  243. margin-top: 6px;
  244. font-size: 11px;
  245. color: #9ca3af;
  246. overflow-x: auto;
  247. }
  248. .tool-params::-webkit-scrollbar {
  249. height: 4px;
  250. }
  251. .tool-params::-webkit-scrollbar-thumb {
  252. background: rgba(255, 255, 255, 0.2);
  253. border-radius: 2px;
  254. }
  255. .tool-result {
  256. background: rgba(16, 185, 129, 0.1);
  257. border-radius: 4px;
  258. padding: 8px;
  259. margin-top: 6px;
  260. font-size: 11px;
  261. color: #6ee7b7;
  262. }
  263. .tool-result.error {
  264. background: rgba(239, 68, 68, 0.1);
  265. color: #fca5a5;
  266. }
  267. .tool-result-content {
  268. max-height: 100px;
  269. overflow-y: auto;
  270. word-break: break-all;
  271. }
  272. .tool-result-content::-webkit-scrollbar {
  273. width: 4px;
  274. }
  275. .tool-result-content::-webkit-scrollbar-thumb {
  276. background: rgba(255, 255, 255, 0.2);
  277. border-radius: 2px;
  278. }
  279. .expand-icon {
  280. transition: transform 0.3s;
  281. }
  282. .expand-icon.expanded {
  283. transform: rotate(180deg);
  284. }
  285. /* 加载动画 */
  286. .spinner {
  287. width: 14px;
  288. height: 14px;
  289. border: 2px solid rgba(251, 191, 36, 0.3);
  290. border-top-color: #fbbf24;
  291. border-radius: 50%;
  292. animation: spin 0.8s linear infinite;
  293. }
  294. @keyframes spin {
  295. to { transform: rotate(360deg); }
  296. }
  297. /* 状态指示器 */
  298. .process-indicator {
  299. display: flex;
  300. align-items: center;
  301. gap: 6px;
  302. padding: 6px 10px;
  303. background: rgba(245, 158, 11, 0.1);
  304. border-radius: 6px;
  305. font-size: 12px;
  306. color: #fbbf24;
  307. }
  308. .process-indicator.success {
  309. background: rgba(16, 185, 129, 0.1);
  310. color: #34d399;
  311. }
  312. .process-indicator .spinner {
  313. width: 12px;
  314. height: 12px;
  315. }
  316. pre {
  317. background: #1e1e1e;
  318. color: #d4d4d4;
  319. padding: 1rem;
  320. border-radius: 0.5rem;
  321. overflow-x: auto;
  322. }
  323. code {
  324. font-family: 'Courier New', monospace;
  325. }
  326. </style>
  327. </head>
  328. <body class="bg-gray-100 min-h-screen">
  329. <div class="container mx-auto px-4 py-6 max-w-4xl">
  330. <!-- Header -->
  331. <header class="bg-white rounded-lg shadow-md p-4 mb-4">
  332. <div class="flex items-center justify-between">
  333. <div class="flex items-center space-x-3">
  334. <div class="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-lg flex items-center justify-center">
  335. <span class="text-white font-bold">AI</span>
  336. </div>
  337. <div>
  338. <h1 class="text-xl font-bold text-gray-800">AI MCP Web UI</h1>
  339. <p class="text-sm text-gray-500">通用 MCP 服务器 Web 界面</p>
  340. </div>
  341. </div>
  342. <div id="status-indicator" class="flex items-center space-x-2">
  343. <span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
  344. <span class="text-sm text-gray-600">已连接</span>
  345. </div>
  346. </div>
  347. </header>
  348. <!-- Chat Container -->
  349. <div class="bg-white rounded-lg shadow-md">
  350. <!-- Messages -->
  351. <div id="chat-messages" class="chat-container overflow-y-auto p-4 space-y-4">
  352. <!-- Welcome Message -->
  353. <div class="flex justify-start">
  354. <div class="message-assistant message-bubble rounded-lg p-4 max-w-[80%] shadow-sm">
  355. <p class="text-gray-800">你好!我是 AI MCP Web UI 助手。我可以帮助你:</p>
  356. <ul class="mt-2 text-gray-700 list-disc list-inside">
  357. <li>与 Claude AI 进行对话</li>
  358. <li>调用 MCP 服务器工具</li>
  359. <li>自动化各种任务</li>
  360. </ul>
  361. <p class="mt-2 text-gray-600 text-sm">请问有什么可以帮助你的?</p>
  362. </div>
  363. </div>
  364. </div>
  365. <!-- Input Area -->
  366. <div class="border-t border-gray-200 p-4 input-area">
  367. <form id="chat-form" class="flex space-x-3">
  368. <input
  369. type="text"
  370. id="user-input"
  371. placeholder="输入你的消息..."
  372. 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"
  373. style="font-size: 16px;"
  374. autocomplete="off"
  375. >
  376. <button
  377. type="submit"
  378. id="send-button"
  379. 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"
  380. style="font-size: 16px;"
  381. >
  382. 发送
  383. </button>
  384. </form>
  385. </div>
  386. </div>
  387. <!-- MCP Servers Status -->
  388. <div class="mt-4 bg-white rounded-lg shadow-md p-4">
  389. <h2 class="text-sm font-semibold text-gray-800 mb-2">MCP 服务器</h2>
  390. <div id="mcp-servers" class="mcp-servers-scroll">
  391. <!-- MCP server tags will be inserted here -->
  392. </div>
  393. </div>
  394. </div>
  395. <script>
  396. // State
  397. let conversationHistory = [];
  398. let isTyping = false;
  399. // DOM Elements
  400. const chatMessages = document.getElementById('chat-messages');
  401. const chatForm = document.getElementById('chat-form');
  402. const userInput = document.getElementById('user-input');
  403. const sendButton = document.getElementById('send-button');
  404. const mcpServers = document.getElementById('mcp-servers');
  405. const statusIndicator = document.getElementById('status-indicator');
  406. // API Base URL
  407. const API_BASE = window.location.origin;
  408. // Format message content (handle code blocks, etc.)
  409. function formatMessage(content) {
  410. // Simple markdown-like formatting
  411. let formatted = content
  412. .replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
  413. .replace(/`([^`]+)`/g, '<code class="bg-gray-200 px-1 rounded">$1</code>')
  414. .replace(/\n/g, '<br>');
  415. return formatted;
  416. }
  417. // Add message to chat
  418. function addMessage(role, content) {
  419. const messageDiv = document.createElement('div');
  420. messageDiv.className = `flex ${role === 'user' ? 'justify-end' : 'justify-start'}`;
  421. const bubbleClass = role === 'user' ? 'message-user text-white' : 'message-assistant text-gray-800';
  422. messageDiv.innerHTML = `
  423. <div class="${bubbleClass} message-bubble rounded-lg p-4 max-w-[80%] shadow-sm">
  424. <div>${formatMessage(content)}</div>
  425. </div>
  426. `;
  427. chatMessages.appendChild(messageDiv);
  428. chatMessages.scrollTop = chatMessages.scrollHeight;
  429. }
  430. // Add typing indicator
  431. function addTypingIndicator() {
  432. const typingDiv = document.createElement('div');
  433. typingDiv.id = 'typing-indicator';
  434. typingDiv.className = 'flex justify-start';
  435. typingDiv.innerHTML = `
  436. <div class="message-assistant rounded-lg p-4 shadow-sm">
  437. <div class="typing-indicator flex space-x-1">
  438. <span class="w-2 h-2 bg-gray-500 rounded-full"></span>
  439. <span class="w-2 h-2 bg-gray-500 rounded-full"></span>
  440. <span class="w-2 h-2 bg-gray-500 rounded-full"></span>
  441. </div>
  442. </div>
  443. `;
  444. chatMessages.appendChild(typingDiv);
  445. chatMessages.scrollTop = chatMessages.scrollHeight;
  446. }
  447. // Remove typing indicator
  448. function removeTypingIndicator() {
  449. const indicator = document.getElementById('typing-indicator');
  450. if (indicator) indicator.remove();
  451. }
  452. // 创建工具调用面板
  453. function createToolCallPanel() {
  454. const panel = document.createElement('div');
  455. panel.className = 'tool-call-panel';
  456. panel.innerHTML = `
  457. <div class="tool-panel-header" onclick="toggleToolPanel(this)">
  458. <div class="tool-panel-title">
  459. <span class="expand-icon">▶</span>
  460. <span>🔧 工具调用</span>
  461. <span class="tool-count">(0)</span>
  462. </div>
  463. <div class="process-indicator">
  464. <div class="spinner"></div>
  465. <span>处理中...</span>
  466. </div>
  467. </div>
  468. <div class="tool-panel-content">
  469. <div class="tool-calls-container"></div>
  470. </div>
  471. `;
  472. return panel;
  473. }
  474. // 切换工具面板展开/收起
  475. function toggleToolPanel(header) {
  476. const content = header.nextElementSibling;
  477. const icon = header.querySelector('.expand-icon');
  478. content.classList.toggle('expanded');
  479. icon.classList.toggle('expanded');
  480. }
  481. // 切换参数/结果折叠区域
  482. function toggleCollapsible(header) {
  483. const content = header.nextElementSibling;
  484. const icon = header.querySelector('.collapsible-icon');
  485. content.classList.toggle('expanded');
  486. icon.classList.toggle('expanded');
  487. }
  488. // 添加工具调用项
  489. function addToolCallItem(container, toolName, params) {
  490. const item = document.createElement('div');
  491. item.className = 'tool-call-item pending';
  492. item.id = `tool-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  493. // 简化工具名称显示 - 去掉 mcp__ 前缀和下划线
  494. let displayName = toolName;
  495. if (toolName.startsWith('mcp__')) {
  496. displayName = toolName.replace('mcp__', '').replace(/_/g, ' ');
  497. }
  498. // 首字母大写
  499. displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1);
  500. const paramsStr = params ? JSON.stringify(params, null, 2) : '{}';
  501. const hasParams = params && Object.keys(params).length > 0;
  502. item.innerHTML = `
  503. <div class="tool-call-header">
  504. <div class="tool-name">
  505. <span>🔧</span>
  506. <span>${escapeHtml(displayName)}</span>
  507. </div>
  508. <div class="tool-status pending">
  509. <div class="spinner"></div>
  510. <span>执行中</span>
  511. </div>
  512. </div>
  513. ${hasParams ? `
  514. <div class="collapsible-section">
  515. <div class="collapsible-header" onclick="toggleCollapsible(this)">
  516. <span class="collapsible-icon">▶</span>
  517. <span>查看参数</span>
  518. </div>
  519. <div class="collapsible-content">
  520. <div class="tool-params"><pre style="margin:0;padding:0;background:transparent;font-size:10px;">${escapeHtml(paramsStr)}</pre></div>
  521. </div>
  522. </div>
  523. ` : ''}
  524. `;
  525. container.appendChild(item);
  526. return item.id;
  527. }
  528. // 更新工具调用状态
  529. function updateToolCallStatus(itemId, status, result = null) {
  530. const item = document.getElementById(itemId);
  531. if (!item) return;
  532. const statusEl = item.querySelector('.tool-status');
  533. item.classList.remove('pending', 'success', 'error');
  534. if (status === 'success') {
  535. item.classList.add('success');
  536. statusEl.className = 'tool-status success';
  537. statusEl.innerHTML = '✅ 成功';
  538. if (result) {
  539. const resultText = typeof result === 'string' ? result : JSON.stringify(result);
  540. const truncatedResult = resultText.substring(0, 500) + (resultText.length > 500 ? '...' : '');
  541. const resultDiv = document.createElement('div');
  542. resultDiv.className = 'collapsible-section';
  543. resultDiv.innerHTML = `
  544. <div class="collapsible-header" onclick="toggleCollapsible(this)">
  545. <span class="collapsible-icon">▶</span>
  546. <span>查看结果</span>
  547. </div>
  548. <div class="collapsible-content">
  549. <div class="tool-result"><div class="tool-result-content">${escapeHtml(truncatedResult)}</div></div>
  550. </div>
  551. `;
  552. item.appendChild(resultDiv);
  553. }
  554. } else if (status === 'error') {
  555. item.classList.add('error');
  556. statusEl.className = 'tool-status error';
  557. statusEl.innerHTML = '❌ 失败';
  558. const errorDiv = document.createElement('div');
  559. errorDiv.className = 'collapsible-section';
  560. errorDiv.innerHTML = `
  561. <div class="collapsible-header" onclick="toggleCollapsible(this)">
  562. <span class="collapsible-icon">▶</span>
  563. <span>查看错误</span>
  564. </div>
  565. <div class="collapsible-content">
  566. <div class="tool-result error">${escapeHtml(result || '未知错误')}</div>
  567. </div>
  568. `;
  569. item.appendChild(errorDiv);
  570. }
  571. }
  572. // 更新工具面板状态
  573. function updateToolPanelStatus(panel, status, totalCalls = 0) {
  574. const indicator = panel.querySelector('.process-indicator');
  575. const countEl = panel.querySelector('.tool-count');
  576. countEl.textContent = `(${totalCalls})`;
  577. if (status === 'complete') {
  578. indicator.className = 'process-indicator success';
  579. indicator.innerHTML = `<span>✅ 完成 - ${totalCalls} 个工具调用</span>`;
  580. } else if (status === 'error') {
  581. indicator.className = 'process-indicator';
  582. indicator.style.background = 'rgba(239, 68, 68, 0.1)';
  583. indicator.style.color = '#f87171';
  584. indicator.innerHTML = `<span>❌ 部分失败</span>`;
  585. }
  586. }
  587. // HTML 转义
  588. function escapeHtml(text) {
  589. const div = document.createElement('div');
  590. div.textContent = text;
  591. return div.innerHTML;
  592. }
  593. function scrollToBottom() {
  594. chatMessages.scrollTop = chatMessages.scrollHeight;
  595. }
  596. // ========== 流式聊天功能 ==========
  597. async function chatStream(message, onEvent) {
  598. const events = [];
  599. try {
  600. const response = await fetch('/api/chat/stream', {
  601. method: 'POST',
  602. headers: { 'Content-Type': 'application/json' },
  603. body: JSON.stringify({ message, history: conversationHistory })
  604. });
  605. if (!response.ok) {
  606. throw new Error(`HTTP error! status: ${response.status}`);
  607. }
  608. const reader = response.body.getReader();
  609. const decoder = new TextDecoder();
  610. let buffer = '';
  611. while (true) {
  612. const { done, value } = await reader.read();
  613. if (done) break;
  614. buffer += decoder.decode(value, { stream: true });
  615. // 处理 SSE 格式: "event: xxx\ndata: {...}\n\n"
  616. const lines = buffer.split('\n');
  617. buffer = lines.pop() || ''; // 保留未完成的行
  618. for (let i = 0; i < lines.length; i++) {
  619. const line = lines[i].trim();
  620. if (!line) continue;
  621. if (line.startsWith('event:')) {
  622. const eventType = line.substring(6).trim();
  623. events.push({ type: eventType, data: null });
  624. } else if (line.startsWith('data:')) {
  625. const data = line.substring(5).trim();
  626. if (events.length > 0) {
  627. events[events.length - 1].data = data;
  628. }
  629. }
  630. // 处理完整的事件
  631. while (events.length > 0 && events[0].data) {
  632. const event = events.shift();
  633. try {
  634. const data = JSON.parse(event.data);
  635. onEvent(event.type, data);
  636. } catch (e) {
  637. console.error('Parse error:', e, event.data);
  638. }
  639. }
  640. }
  641. }
  642. } catch (error) {
  643. onEvent('error', { error: error.message });
  644. }
  645. }
  646. async function sendMessageStream(message) {
  647. if (isTyping) return;
  648. isTyping = true;
  649. sendButton.disabled = true;
  650. // 添加用户消息
  651. addMessage('user', message);
  652. conversationHistory.push({ role: 'user', content: message });
  653. // 创建助手消息容器
  654. const assistantMsgDiv = document.createElement('div');
  655. assistantMsgDiv.className = 'flex justify-start';
  656. assistantMsgDiv.innerHTML = `
  657. <div class="message-assistant message-bubble rounded-lg p-4 shadow-sm">
  658. <div class="response-content">
  659. <div class="text" id="currentResponse"></div>
  660. </div>
  661. <div id="toolCallContainer"></div>
  662. </div>
  663. `;
  664. chatMessages.appendChild(assistantMsgDiv);
  665. const responseDiv = assistantMsgDiv.querySelector('#currentResponse');
  666. const toolContainer = assistantMsgDiv.querySelector('#toolCallContainer');
  667. let currentText = '';
  668. let toolCallsCount = 0;
  669. let toolCallItems = {};
  670. let finalResponse = '';
  671. let toolCallPanel = null;
  672. let toolCallsContainer = null;
  673. await chatStream(message, (eventType, data) => {
  674. switch (eventType) {
  675. case 'start':
  676. responseDiv.innerHTML = '<span style="color: #888; font-style: italic;">🤔 AI 正在思考...</span>';
  677. break;
  678. case 'token':
  679. // 移除思考提示
  680. if (currentText === '') {
  681. responseDiv.innerHTML = '';
  682. }
  683. currentText += data.text || '';
  684. responseDiv.innerHTML = formatMessage(currentText);
  685. scrollToBottom();
  686. break;
  687. case 'tool_call':
  688. // 首次工具调用时创建面板
  689. if (!toolCallPanel) {
  690. toolCallPanel = createToolCallPanel();
  691. toolContainer.appendChild(toolCallPanel);
  692. toolCallsContainer = toolCallPanel.querySelector('.tool-calls-container');
  693. // 清空思考提示
  694. if (currentText === '' || currentText.includes('正在思考')) {
  695. responseDiv.innerHTML = '<span style="color: #888; font-style: italic;">🔧 正在调用工具...</span>';
  696. }
  697. }
  698. // 添加工具调用项
  699. const toolName = data.tool || 'unknown';
  700. const params = data.args || data.params || null;
  701. const itemId = addToolCallItem(toolCallsContainer, toolName, params);
  702. toolCallItems[data.tool_id || toolName] = itemId;
  703. toolCallsCount++;
  704. updateToolPanelStatus(toolCallPanel, 'running', toolCallsCount);
  705. scrollToBottom();
  706. break;
  707. case 'tools_start':
  708. // 批量工具开始
  709. if (!toolCallPanel) {
  710. toolCallPanel = createToolCallPanel();
  711. toolContainer.appendChild(toolCallPanel);
  712. toolCallsContainer = toolCallPanel.querySelector('.tool-calls-container');
  713. }
  714. if (currentText === '' || currentText.includes('正在思考')) {
  715. responseDiv.innerHTML = '<span style="color: #888; font-style: italic;">🔧 正在调用工具...</span>';
  716. }
  717. scrollToBottom();
  718. break;
  719. case 'tool_done':
  720. // 工具执行完成
  721. const toolId = data.tool_id || data.tool;
  722. const itemId2 = toolCallItems[toolId];
  723. if (itemId2) {
  724. const result = data.result || '';
  725. updateToolCallStatus(itemId2, 'success', result);
  726. }
  727. scrollToBottom();
  728. break;
  729. case 'tool_error':
  730. // 工具执行错误
  731. const errorItemId = toolCallItems[data.tool_id || data.tool];
  732. if (errorItemId) {
  733. updateToolCallStatus(errorItemId, 'error', data.error);
  734. }
  735. if (toolCallPanel) {
  736. updateToolPanelStatus(toolCallPanel, 'error', toolCallsCount);
  737. }
  738. scrollToBottom();
  739. break;
  740. case 'complete':
  741. // 完成
  742. if (toolCallPanel) {
  743. updateToolPanelStatus(toolCallPanel, 'complete', toolCallsCount);
  744. }
  745. // 清空临时提示并显示最终响应
  746. if (data.response || currentText) {
  747. finalResponse = data.response || currentText;
  748. responseDiv.innerHTML = formatMessage(finalResponse);
  749. }
  750. conversationHistory.push({ role: 'assistant', content: finalResponse });
  751. scrollToBottom();
  752. break;
  753. case 'error':
  754. responseDiv.innerHTML = `<span style="color: #ef4444;">❌ 错误: ${escapeHtml(data.error || '未知错误')}</span>`;
  755. if (toolCallPanel) {
  756. updateToolPanelStatus(toolCallPanel, 'error', toolCallsCount);
  757. }
  758. scrollToBottom();
  759. break;
  760. }
  761. });
  762. // 重置状态
  763. isTyping = false;
  764. sendButton.disabled = false;
  765. userInput.focus();
  766. }
  767. // Load MCP servers
  768. async function loadMCPServers() {
  769. try {
  770. const response = await fetch(`${API_BASE}/api/mcp/servers`);
  771. const data = await response.json();
  772. mcpServers.innerHTML = data.servers.map(server => `
  773. <div class="mcp-server-tag ${server.enabled ? 'enabled' : 'disabled'}">
  774. <span class="mcp-status-dot ${server.enabled ? 'online' : 'offline'}"></span>
  775. <span class="font-medium">${server.name}</span>
  776. ${server.auth_type === 'jwt' ? '<span>🔒</span>' : ''}
  777. </div>
  778. `).join('');
  779. } catch (error) {
  780. console.error('Failed to load MCP servers:', error);
  781. }
  782. }
  783. // Check health
  784. async function checkHealth() {
  785. try {
  786. const response = await fetch(`${API_BASE}/api/health`);
  787. if (response.ok) {
  788. statusIndicator.innerHTML = `
  789. <span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
  790. <span class="text-sm text-gray-600">已连接</span>
  791. `;
  792. } else {
  793. throw new Error('Health check failed');
  794. }
  795. } catch (error) {
  796. statusIndicator.innerHTML = `
  797. <span class="w-2 h-2 bg-red-500 rounded-full"></span>
  798. <span class="text-sm text-gray-600">连接失败</span>
  799. `;
  800. }
  801. }
  802. // Event listeners
  803. chatForm.addEventListener('submit', (e) => {
  804. e.preventDefault();
  805. const message = userInput.value.trim();
  806. if (message) {
  807. sendMessageStream(message);
  808. userInput.value = '';
  809. }
  810. });
  811. // Initialize
  812. loadMCPServers();
  813. checkHealth();
  814. userInput.focus();
  815. // Make toggle functions global
  816. window.toggleToolPanel = toggleToolPanel;
  817. window.toggleCollapsible = toggleCollapsible;
  818. </script>
  819. </body>
  820. </html>