2
0

index.html 63 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559
  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>MCP 助手</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.collapsed {
  167. max-height: 0;
  168. }
  169. .tool-panel-content.expanded {
  170. max-height: 800px;
  171. padding: 12px;
  172. overflow-y: auto;
  173. }
  174. .tool-panel-content::-webkit-scrollbar {
  175. width: 6px;
  176. }
  177. .tool-panel-content::-webkit-scrollbar-track {
  178. background: rgba(255, 255, 255, 0.05);
  179. }
  180. .tool-panel-content::-webkit-scrollbar-thumb {
  181. background: rgba(255, 255, 255, 0.2);
  182. border-radius: 3px;
  183. }
  184. /* 工具调用项样式 */
  185. .tool-call-item {
  186. background: rgba(255, 255, 255, 0.03);
  187. border-radius: 6px;
  188. padding: 10px 12px;
  189. margin-bottom: 8px;
  190. border-left: 3px solid #555;
  191. transition: all 0.2s;
  192. }
  193. .tool-call-item:last-child {
  194. margin-bottom: 0;
  195. }
  196. .tool-call-item.pending {
  197. border-left-color: #f59e0b;
  198. animation: pulse-border 1.5s infinite;
  199. }
  200. .tool-call-item.success {
  201. border-left-color: #10b981;
  202. }
  203. .tool-call-item.error {
  204. border-left-color: #ef4444;
  205. }
  206. @keyframes pulse-border {
  207. 0%, 100% { border-left-color: #f59e0b; }
  208. 50% { border-left-color: #fbbf24; }
  209. }
  210. .tool-call-header {
  211. display: flex;
  212. align-items: center;
  213. justify-content: space-between;
  214. margin-bottom: 6px;
  215. }
  216. .tool-name {
  217. color: #e0e0e0;
  218. font-size: 13px;
  219. font-weight: 500;
  220. display: flex;
  221. align-items: center;
  222. gap: 6px;
  223. }
  224. .tool-status {
  225. font-size: 11px;
  226. padding: 2px 8px;
  227. border-radius: 10px;
  228. font-weight: 500;
  229. }
  230. .tool-status.pending {
  231. background: rgba(245, 158, 11, 0.2);
  232. color: #fbbf24;
  233. }
  234. .tool-status.success {
  235. background: rgba(16, 185, 129, 0.2);
  236. color: #34d399;
  237. }
  238. .tool-status.error {
  239. background: rgba(239, 68, 68, 0.2);
  240. color: #f87171;
  241. }
  242. .tool-params {
  243. background: rgba(0, 0, 0, 0.3);
  244. border-radius: 4px;
  245. padding: 8px;
  246. margin-top: 6px;
  247. font-size: 11px;
  248. color: #9ca3af;
  249. overflow-x: auto;
  250. }
  251. .tool-params::-webkit-scrollbar {
  252. height: 4px;
  253. }
  254. .tool-params::-webkit-scrollbar-thumb {
  255. background: rgba(255, 255, 255, 0.2);
  256. border-radius: 2px;
  257. }
  258. .tool-result {
  259. background: rgba(16, 185, 129, 0.1);
  260. border-radius: 4px;
  261. padding: 8px;
  262. margin-top: 6px;
  263. font-size: 11px;
  264. color: #6ee7b7;
  265. }
  266. .tool-result.error {
  267. background: rgba(239, 68, 68, 0.1);
  268. color: #fca5a5;
  269. }
  270. .tool-result-content {
  271. max-height: 100px;
  272. overflow-y: auto;
  273. word-break: break-all;
  274. }
  275. .tool-result-content::-webkit-scrollbar {
  276. width: 4px;
  277. }
  278. .tool-result-content::-webkit-scrollbar-thumb {
  279. background: rgba(255, 255, 255, 0.2);
  280. border-radius: 2px;
  281. }
  282. .expand-icon {
  283. transition: transform 0.3s;
  284. }
  285. .expand-icon.expanded {
  286. transform: rotate(180deg);
  287. }
  288. /* 加载动画 */
  289. .spinner {
  290. width: 14px;
  291. height: 14px;
  292. border: 2px solid rgba(251, 191, 36, 0.3);
  293. border-top-color: #fbbf24;
  294. border-radius: 50%;
  295. animation: spin 0.8s linear infinite;
  296. }
  297. @keyframes spin {
  298. to { transform: rotate(360deg); }
  299. }
  300. /* 状态指示器 */
  301. .process-indicator {
  302. display: flex;
  303. align-items: center;
  304. gap: 6px;
  305. padding: 6px 10px;
  306. background: rgba(245, 158, 11, 0.1);
  307. border-radius: 6px;
  308. font-size: 12px;
  309. color: #fbbf24;
  310. }
  311. .process-indicator.success {
  312. background: rgba(16, 185, 129, 0.1);
  313. color: #34d399;
  314. }
  315. .process-indicator .spinner {
  316. width: 12px;
  317. height: 12px;
  318. }
  319. pre {
  320. background: #1e1e1e;
  321. color: #d4d4d4;
  322. padding: 1rem;
  323. border-radius: 0.5rem;
  324. overflow-x: auto;
  325. }
  326. code {
  327. font-family: 'Courier New', monospace;
  328. }
  329. </style>
  330. </head>
  331. <body class="bg-gray-100 min-h-screen">
  332. <div class="container mx-auto px-4 py-6 max-w-4xl">
  333. <!-- Header -->
  334. <header class="bg-white rounded-lg shadow-md p-4 mb-4">
  335. <div class="flex items-center justify-between">
  336. <div class="flex items-center space-x-3">
  337. <div class="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-lg flex items-center justify-center">
  338. <span class="text-white font-bold">AI</span>
  339. </div>
  340. <div>
  341. <h1 class="text-xl font-bold text-gray-800">AI MCP Web UI</h1>
  342. <p class="text-sm text-gray-500">通用 MCP 服务器 Web 界面</p>
  343. </div>
  344. </div>
  345. <div class="flex items-center space-x-4">
  346. <!-- 用户菜单 (登录后显示) -->
  347. <div id="user-menu" class="hidden relative">
  348. <button id="user-menu-btn" class="flex items-center space-x-2 px-3 py-1.5 bg-purple-50 rounded-lg hover:bg-purple-100 transition-colors">
  349. <div class="w-8 h-8 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-full flex items-center justify-center">
  350. <span id="user-avatar" class="text-white text-sm font-bold">U</span>
  351. </div>
  352. <span id="user-name" class="text-sm font-medium text-gray-700">用户</span>
  353. <svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  354. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
  355. </svg>
  356. </button>
  357. <!-- 下拉菜单 -->
  358. <div id="user-dropdown" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
  359. <div class="px-4 py-2 border-b border-gray-100">
  360. <p class="text-xs text-gray-500">已登录</p>
  361. <p id="dropdown-username" class="text-sm font-medium text-gray-800">用户名</p>
  362. </div>
  363. <button id="logout-btn" class="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 transition-colors flex items-center space-x-2">
  364. <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  365. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
  366. </svg>
  367. <span>退出登录</span>
  368. </button>
  369. </div>
  370. </div>
  371. <!-- 登录按钮 (未登录时显示) -->
  372. <button id="header-login-btn" class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
  373. 登录
  374. </button>
  375. <!-- 状态指示器 -->
  376. <div id="status-indicator" class="flex items-center space-x-2">
  377. <span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
  378. <span class="text-sm text-gray-600">已连接</span>
  379. </div>
  380. </div>
  381. </div>
  382. </header>
  383. <!-- Chat Container -->
  384. <div class="bg-white rounded-lg shadow-md">
  385. <!-- Messages -->
  386. <div id="chat-messages" class="chat-container overflow-y-auto p-4 space-y-4">
  387. <!-- Welcome Message -->
  388. <div class="flex justify-start">
  389. <div class="message-assistant message-bubble rounded-lg p-4 max-w-[80%] shadow-sm">
  390. <p class="text-gray-800">你好!我是 AI MCP Web UI 助手。我可以帮助你:</p>
  391. <ul class="mt-2 text-gray-700 list-disc list-inside">
  392. <li>与 Claude AI 进行对话</li>
  393. <li>调用 MCP 服务器工具</li>
  394. <li>自动化各种任务</li>
  395. </ul>
  396. <p class="mt-2 text-gray-600 text-sm">请问有什么可以帮助你的?</p>
  397. </div>
  398. </div>
  399. </div>
  400. <!-- Input Area -->
  401. <div class="border-t border-gray-200 p-4 input-area">
  402. <form id="chat-form" class="flex space-x-3">
  403. <input
  404. type="text"
  405. id="user-input"
  406. placeholder="输入你的消息..."
  407. 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"
  408. style="font-size: 16px;"
  409. autocomplete="off"
  410. >
  411. <button
  412. type="submit"
  413. id="send-button"
  414. 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"
  415. style="font-size: 16px;"
  416. >
  417. 发送
  418. </button>
  419. </form>
  420. </div>
  421. </div>
  422. <!-- MCP Servers Status -->
  423. <div class="mt-4 bg-white rounded-lg shadow-md p-4">
  424. <h2 class="text-sm font-semibold text-gray-800 mb-2">MCP 服务器</h2>
  425. <div id="mcp-servers" class="mcp-servers-scroll">
  426. <!-- MCP server tags will be inserted here -->
  427. </div>
  428. </div>
  429. </div>
  430. <!-- 登录/注册对话框 -->
  431. <div id="login-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
  432. <div class="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
  433. <div class="flex items-center justify-between mb-4">
  434. <h3 class="text-xl font-bold text-gray-800" id="login-title">登录 Novel Platform</h3>
  435. <button id="login-close-x" class="text-gray-500 hover:text-gray-700 text-2xl">&times;</button>
  436. </div>
  437. <!-- Tab 切换 -->
  438. <div class="flex border-b border-gray-200 mb-4">
  439. <button id="tab-login" class="flex-1 py-2 text-center font-medium text-purple-600 border-b-2 border-purple-600 transition-colors">
  440. 登录
  441. </button>
  442. <button id="tab-register" class="flex-1 py-2 text-center font-medium text-gray-500 hover:text-gray-700 transition-colors">
  443. 注册
  444. </button>
  445. </div>
  446. <p id="login-description" class="text-sm text-gray-600 mb-4">请输入您的账号密码以访问需要认证的 MCP 工具</p>
  447. <!-- 登录表单 -->
  448. <form id="login-form">
  449. <div class="mb-3">
  450. <label for="login-username" class="block text-sm font-medium text-gray-700 mb-1">用户名/邮箱</label>
  451. <input type="text" id="login-username" placeholder="请输入用户名或邮箱" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent">
  452. </div>
  453. <div class="mb-3">
  454. <label for="login-password" class="block text-sm font-medium text-gray-700 mb-1">密码</label>
  455. <input type="password" id="login-password" placeholder="请输入密码" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent">
  456. </div>
  457. <p id="login-error" class="text-red-500 text-sm mb-3 hidden"></p>
  458. <div class="flex justify-end gap-2">
  459. <button type="button" id="login-cancel" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">取消</button>
  460. <button type="submit" id="login-submit" class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">登录</button>
  461. </div>
  462. </form>
  463. <!-- 注册表单 (默认隐藏) -->
  464. <form id="register-form" class="hidden">
  465. <div class="mb-3">
  466. <label for="register-email" class="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
  467. <input type="email" id="register-email" placeholder="请输入邮箱" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent">
  468. </div>
  469. <div class="mb-3">
  470. <label for="register-username" class="block text-sm font-medium text-gray-700 mb-1">用户名</label>
  471. <input type="text" id="register-username" placeholder="请输入用户名" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent">
  472. </div>
  473. <div class="mb-3">
  474. <label for="register-password" class="block text-sm font-medium text-gray-700 mb-1">密码</label>
  475. <input type="password" id="register-password" placeholder="请输入密码" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent">
  476. </div>
  477. <div class="mb-3">
  478. <label for="register-confirm-password" class="block text-sm font-medium text-gray-700 mb-1">确认密码</label>
  479. <input type="password" id="register-confirm-password" placeholder="请再次输入密码" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent">
  480. </div>
  481. <p id="register-error" class="text-red-500 text-sm mb-3 hidden"></p>
  482. <p id="register-success" class="text-green-500 text-sm mb-3 hidden"></p>
  483. <div class="flex justify-end gap-2">
  484. <button type="button" id="register-cancel" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">取消</button>
  485. <button type="submit" id="register-submit" class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">注册</button>
  486. </div>
  487. </form>
  488. </div>
  489. </div>
  490. <script>
  491. // State
  492. let conversationHistory = [];
  493. let isTyping = false;
  494. // DOM Elements
  495. const chatMessages = document.getElementById('chat-messages');
  496. const chatForm = document.getElementById('chat-form');
  497. const userInput = document.getElementById('user-input');
  498. const sendButton = document.getElementById('send-button');
  499. const mcpServers = document.getElementById('mcp-servers');
  500. const statusIndicator = document.getElementById('status-indicator');
  501. // API Base URL
  502. const API_BASE = window.location.origin;
  503. // ========== MCP 服务器配置 ==========
  504. const MCP_SERVERS = {
  505. "novel-translator": {
  506. "name": "Novel Translator MCP",
  507. "auth_type": "none"
  508. },
  509. "novel-platform-user": {
  510. "name": "Novel Platform User MCP",
  511. "url": "https://d8d-ai-vscode-8080-223-238-template-6-group.dev.d8d.fun/mcp/",
  512. "auth_type": "jwt",
  513. "login_api": "/api/v1/auth/login",
  514. "base_url": "https://d8d-ai-vscode-8080-223-238-template-6-group.dev.d8d.fun"
  515. },
  516. "novel-platform-admin": {
  517. "name": "Novel Platform Admin MCP",
  518. "url": "https://d8d-ai-vscode-8080-223-238-template-6-group.dev.d8d.fun/admin-mcp/",
  519. "auth_type": "jwt",
  520. "login_api": "/api/v1/auth/login",
  521. "base_url": "https://d8d-ai-vscode-8080-223-238-template-6-group.dev.d8d.fun"
  522. }
  523. };
  524. // ========== Token 管理器 ==========
  525. const TokenManager = {
  526. // 保存 token
  527. saveToken(mcpType, token, username = null) {
  528. localStorage.setItem(`mcp_token_${mcpType}`, token);
  529. localStorage.setItem(`mcp_token_${mcpType}_time`, Date.now());
  530. if (username) {
  531. localStorage.setItem(`mcp_username_${mcpType}`, username);
  532. }
  533. },
  534. // 获取 token
  535. getToken(mcpType) {
  536. return localStorage.getItem(`mcp_token_${mcpType}`);
  537. },
  538. // 获取用户名
  539. getUsername(mcpType) {
  540. return localStorage.getItem(`mcp_username_${mcpType}`);
  541. },
  542. // 清除 token
  543. clearToken(mcpType) {
  544. localStorage.removeItem(`mcp_token_${mcpType}`);
  545. localStorage.removeItem(`mcp_token_${mcpType}_time`);
  546. localStorage.removeItem(`mcp_username_${mcpType}`);
  547. },
  548. // 检查是否已登录
  549. isLoggedIn(mcpType) {
  550. return !!this.getToken(mcpType);
  551. },
  552. // 获取 token 存储时长(毫秒)
  553. getTokenAge(mcpType) {
  554. const time = localStorage.getItem(`mcp_token_${mcpType}_time`);
  555. return time ? (Date.now() - parseInt(time)) : null;
  556. }
  557. };
  558. // ========== 登录/注册相关 ==========
  559. let currentLoginMcpType = null;
  560. let currentAuthTab = 'login'; // 'login' or 'register'
  561. let currentUser = null; // 当前登录用户
  562. const loginModal = document.getElementById('login-modal');
  563. const loginForm = document.getElementById('login-form');
  564. const registerForm = document.getElementById('register-form');
  565. const loginUsername = document.getElementById('login-username');
  566. const loginPassword = document.getElementById('login-password');
  567. const loginError = document.getElementById('login-error');
  568. const loginTitle = document.getElementById('login-title');
  569. const loginSubmit = document.getElementById('login-submit');
  570. const loginDescription = document.getElementById('login-description');
  571. // Tab 切换
  572. const tabLogin = document.getElementById('tab-login');
  573. const tabRegister = document.getElementById('tab-register');
  574. function switchTab(tab) {
  575. currentAuthTab = tab;
  576. if (tab === 'login') {
  577. tabLogin.classList.add('text-purple-600', 'border-b-2', 'border-purple-600');
  578. tabLogin.classList.remove('text-gray-500');
  579. tabRegister.classList.remove('text-purple-600', 'border-b-2', 'border-purple-600');
  580. tabRegister.classList.add('text-gray-500');
  581. loginForm.classList.remove('hidden');
  582. registerForm.classList.add('hidden');
  583. loginDescription.textContent = '请输入您的账号密码以访问需要认证的 MCP 工具';
  584. } else {
  585. tabRegister.classList.add('text-purple-600', 'border-b-2', 'border-purple-600');
  586. tabRegister.classList.remove('text-gray-500');
  587. tabLogin.classList.remove('text-purple-600', 'border-b-2', 'border-purple-600');
  588. tabLogin.classList.add('text-gray-500');
  589. registerForm.classList.remove('hidden');
  590. loginForm.classList.add('hidden');
  591. loginDescription.textContent = '创建新账号以访问需要认证的 MCP 工具';
  592. }
  593. }
  594. tabLogin.addEventListener('click', () => switchTab('login'));
  595. tabRegister.addEventListener('click', () => switchTab('register'));
  596. // 显示登录对话框
  597. function showLoginModal(mcpType) {
  598. currentLoginMcpType = mcpType;
  599. const config = MCP_SERVERS[mcpType];
  600. if (!config) return;
  601. loginTitle.textContent = config.name;
  602. // 重置为登录 Tab
  603. switchTab('login');
  604. loginUsername.value = '';
  605. loginPassword.value = '';
  606. loginError.classList.add('hidden');
  607. loginSubmit.disabled = false;
  608. loginModal.classList.remove('hidden');
  609. loginUsername.focus();
  610. // 清空注册表单
  611. document.getElementById('register-email').value = '';
  612. document.getElementById('register-username').value = '';
  613. document.getElementById('register-password').value = '';
  614. document.getElementById('register-confirm-password').value = '';
  615. document.getElementById('register-error').classList.add('hidden');
  616. document.getElementById('register-success').classList.add('hidden');
  617. }
  618. // 隐藏登录对话框
  619. function hideLoginModal() {
  620. loginModal.classList.add('hidden');
  621. currentLoginMcpType = null;
  622. }
  623. // 执行登录 - 使用本地后端代理避免 CORS 问题
  624. async function performLogin(mcpType, username, password) {
  625. const config = MCP_SERVERS[mcpType];
  626. if (!config || config.auth_type !== 'jwt') {
  627. return { success: false, error: '该 MCP 不需要登录' };
  628. }
  629. // 使用本地后端代理端点,避免直接调用外部 API 导致 CORS 错误
  630. const is_admin = mcpType === 'novel-platform-admin';
  631. const proxyUrl = is_admin ? '/api/auth/admin-login' : '/api/auth/login';
  632. try {
  633. const response = await fetch(proxyUrl, {
  634. method: 'POST',
  635. headers: { 'Content-Type': 'application/json' },
  636. body: JSON.stringify({
  637. email: username,
  638. password: password
  639. })
  640. });
  641. const data = await response.json();
  642. if (response.ok && data.success) {
  643. const token = data.token;
  644. const loggedUsername = data.username || username;
  645. TokenManager.saveToken(mcpType, token, loggedUsername);
  646. currentUser = loggedUsername;
  647. updateUserMenu();
  648. return { success: true, token, username: loggedUsername };
  649. } else {
  650. return { success: false, error: data.detail || data.error || '登录失败' };
  651. }
  652. } catch (e) {
  653. return { success: false, error: e.message };
  654. }
  655. }
  656. // 执行注册
  657. async function performRegister(email, username, password) {
  658. try {
  659. const response = await fetch('/api/auth/register', {
  660. method: 'POST',
  661. headers: { 'Content-Type': 'application/json' },
  662. body: JSON.stringify({
  663. email: email,
  664. username: username,
  665. password: password
  666. })
  667. });
  668. const data = await response.json();
  669. if (response.ok) {
  670. return { success: true, message: data.message || '注册成功' };
  671. } else {
  672. return { success: false, error: data.detail || data.error || '注册失败' };
  673. }
  674. } catch (e) {
  675. return { success: false, error: e.message };
  676. }
  677. }
  678. // 登录表单提交处理
  679. loginForm.addEventListener('submit', async (e) => {
  680. e.preventDefault();
  681. if (!currentLoginMcpType) return;
  682. const username = loginUsername.value.trim();
  683. const password = loginPassword.value;
  684. if (!username || !password) {
  685. loginError.textContent = '请输入用户名和密码';
  686. loginError.classList.remove('hidden');
  687. return;
  688. }
  689. loginSubmit.disabled = true;
  690. loginError.classList.add('hidden');
  691. const result = await performLogin(currentLoginMcpType, username, password);
  692. if (result.success) {
  693. hideLoginModal();
  694. loadMCPServers(); // 刷新 MCP 服务器列表
  695. } else {
  696. loginError.textContent = result.error;
  697. loginError.classList.remove('hidden');
  698. loginSubmit.disabled = false;
  699. }
  700. });
  701. // 注册表单提交处理
  702. registerForm.addEventListener('submit', async (e) => {
  703. e.preventDefault();
  704. const email = document.getElementById('register-email').value.trim();
  705. const username = document.getElementById('register-username').value.trim();
  706. const password = document.getElementById('register-password').value;
  707. const confirmPassword = document.getElementById('register-confirm-password').value;
  708. const registerError = document.getElementById('register-error');
  709. const registerSuccess = document.getElementById('register-success');
  710. const registerSubmit = document.getElementById('register-submit');
  711. // 验证输入
  712. if (!email || !username || !password || !confirmPassword) {
  713. registerError.textContent = '请填写所有字段';
  714. registerError.classList.remove('hidden');
  715. registerSuccess.classList.add('hidden');
  716. return;
  717. }
  718. // 验证邮箱格式
  719. const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  720. if (!emailRegex.test(email)) {
  721. registerError.textContent = '请输入有效的邮箱地址';
  722. registerError.classList.remove('hidden');
  723. registerSuccess.classList.add('hidden');
  724. return;
  725. }
  726. // 验证密码匹配
  727. if (password !== confirmPassword) {
  728. registerError.textContent = '两次输入的密码不一致';
  729. registerError.classList.remove('hidden');
  730. registerSuccess.classList.add('hidden');
  731. return;
  732. }
  733. // 验证密码长度
  734. if (password.length < 6) {
  735. registerError.textContent = '密码长度至少为 6 位';
  736. registerError.classList.remove('hidden');
  737. registerSuccess.classList.add('hidden');
  738. return;
  739. }
  740. registerSubmit.disabled = true;
  741. registerError.classList.add('hidden');
  742. registerSuccess.classList.add('hidden');
  743. const result = await performRegister(email, username, password);
  744. if (result.success) {
  745. registerSuccess.textContent = result.message + ',请切换到登录页面登录';
  746. registerSuccess.classList.remove('hidden');
  747. registerSubmit.disabled = false;
  748. } else {
  749. registerError.textContent = result.error;
  750. registerError.classList.remove('hidden');
  751. registerSubmit.disabled = false;
  752. }
  753. });
  754. // 登录对话框事件绑定
  755. document.getElementById('login-cancel').addEventListener('click', hideLoginModal);
  756. document.getElementById('register-cancel').addEventListener('click', hideLoginModal);
  757. document.getElementById('login-close-x').addEventListener('click', hideLoginModal);
  758. // 点击背景关闭
  759. loginModal.addEventListener('click', (e) => {
  760. if (e.target === loginModal) {
  761. hideLoginModal();
  762. }
  763. });
  764. // ========== 用户菜单相关 ==========
  765. const userMenu = document.getElementById('user-menu');
  766. const userMenuBtn = document.getElementById('user-menu-btn');
  767. const userDropdown = document.getElementById('user-dropdown');
  768. const headerLoginBtn = document.getElementById('header-login-btn');
  769. const logoutBtn = document.getElementById('logout-btn');
  770. // 更新用户菜单显示状态
  771. function updateUserMenu() {
  772. // 检查是否有任何 MCP 服务器已登录
  773. let isLoggedIn = false;
  774. let loggedInUsername = null;
  775. for (const [mcpType, config] of Object.entries(MCP_SERVERS)) {
  776. if (config.auth_type === 'jwt' && TokenManager.isLoggedIn(mcpType)) {
  777. isLoggedIn = true;
  778. loggedInUsername = TokenManager.getUsername(mcpType);
  779. break;
  780. }
  781. }
  782. if (isLoggedIn) {
  783. userMenu.classList.remove('hidden');
  784. headerLoginBtn.classList.add('hidden');
  785. // 更新用户信息显示
  786. if (loggedInUsername) {
  787. document.getElementById('user-name').textContent = loggedInUsername;
  788. document.getElementById('dropdown-username').textContent = loggedInUsername;
  789. document.getElementById('user-avatar').textContent = loggedInUsername.charAt(0).toUpperCase();
  790. currentUser = loggedInUsername;
  791. }
  792. } else {
  793. userMenu.classList.add('hidden');
  794. headerLoginBtn.classList.remove('hidden');
  795. currentUser = null;
  796. }
  797. }
  798. // 用户菜单按钮点击 - 切换下拉菜单
  799. userMenuBtn.addEventListener('click', (e) => {
  800. e.stopPropagation();
  801. userDropdown.classList.toggle('hidden');
  802. });
  803. // 点击其他地方关闭下拉菜单
  804. document.addEventListener('click', () => {
  805. if (!userDropdown.classList.contains('hidden')) {
  806. userDropdown.classList.add('hidden');
  807. }
  808. });
  809. // 头部登录按钮点击
  810. headerLoginBtn.addEventListener('click', () => {
  811. // 显示登录对话框,默认登录到 novel-platform-user
  812. showLoginModal('novel-platform-user');
  813. });
  814. // 退出登录
  815. logoutBtn.addEventListener('click', () => {
  816. // 清除所有 MCP 的 token
  817. for (const mcpType of Object.keys(MCP_SERVERS)) {
  818. TokenManager.clearToken(mcpType);
  819. }
  820. currentUser = null;
  821. updateUserMenu();
  822. loadMCPServers(); // 刷新 MCP 服务器列表
  823. userDropdown.classList.add('hidden');
  824. });
  825. // 初始化用户菜单状态
  826. updateUserMenu();
  827. // ========== Token 传递到后端 ==========
  828. // 为每个请求添加 Authorization header
  829. async function fetchWithAuth(url, options = {}) {
  830. // 检查 URL 路径,确定是哪个 MCP
  831. let mcpType = null;
  832. if (url.includes('/chat') || url.includes('/mcp')) {
  833. // 对于需要认证的 MCP,检查本地存储的 token
  834. for (const [type, config] of Object.entries(MCP_SERVERS)) {
  835. if (config.auth_type === 'jwt' && TokenManager.isLoggedIn(type)) {
  836. mcpType = type;
  837. break;
  838. }
  839. }
  840. }
  841. // 添加 token 到请求头
  842. if (mcpType) {
  843. const token = TokenManager.getToken(mcpType);
  844. if (token) {
  845. options.headers = options.headers || {};
  846. options.headers['X-MCP-Token'] = token;
  847. options.headers['X-MCP-Type'] = mcpType;
  848. }
  849. }
  850. return fetch(url, options);
  851. }
  852. // ========== Format message content (handle code blocks, etc.) ==========
  853. function formatMessage(content) {
  854. // Simple markdown-like formatting
  855. let formatted = content
  856. .replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
  857. .replace(/`([^`]+)`/g, '<code class="bg-gray-200 px-1 rounded">$1</code>')
  858. .replace(/\n/g, '<br>');
  859. return formatted;
  860. }
  861. // Add message to chat
  862. function addMessage(role, content) {
  863. const messageDiv = document.createElement('div');
  864. messageDiv.className = `flex ${role === 'user' ? 'justify-end' : 'justify-start'}`;
  865. const bubbleClass = role === 'user' ? 'message-user text-white' : 'message-assistant text-gray-800';
  866. messageDiv.innerHTML = `
  867. <div class="${bubbleClass} message-bubble rounded-lg p-4 max-w-[80%] shadow-sm">
  868. <div>${formatMessage(content)}</div>
  869. </div>
  870. `;
  871. chatMessages.appendChild(messageDiv);
  872. chatMessages.scrollTop = chatMessages.scrollHeight;
  873. }
  874. // Add typing indicator
  875. function addTypingIndicator() {
  876. const typingDiv = document.createElement('div');
  877. typingDiv.id = 'typing-indicator';
  878. typingDiv.className = 'flex justify-start';
  879. typingDiv.innerHTML = `
  880. <div class="message-assistant rounded-lg p-4 shadow-sm">
  881. <div class="typing-indicator flex space-x-1">
  882. <span class="w-2 h-2 bg-gray-500 rounded-full"></span>
  883. <span class="w-2 h-2 bg-gray-500 rounded-full"></span>
  884. <span class="w-2 h-2 bg-gray-500 rounded-full"></span>
  885. </div>
  886. </div>
  887. `;
  888. chatMessages.appendChild(typingDiv);
  889. chatMessages.scrollTop = chatMessages.scrollHeight;
  890. }
  891. // Remove typing indicator
  892. function removeTypingIndicator() {
  893. const indicator = document.getElementById('typing-indicator');
  894. if (indicator) indicator.remove();
  895. }
  896. // 创建工具调用面板
  897. function createToolCallPanel() {
  898. const panel = document.createElement('div');
  899. panel.className = 'tool-call-panel';
  900. panel.innerHTML = `
  901. <div class="tool-panel-header" onclick="toggleToolPanel(this)">
  902. <div class="tool-panel-title">
  903. <span class="expand-icon">▶</span>
  904. <span>🔧 工具调用</span>
  905. <span class="tool-count">(0)</span>
  906. </div>
  907. <div class="process-indicator">
  908. <div class="spinner"></div>
  909. <span>处理中...</span>
  910. </div>
  911. </div>
  912. <div class="tool-panel-content">
  913. <div class="tool-calls-container"></div>
  914. </div>
  915. `;
  916. return panel;
  917. }
  918. // 切换工具面板展开/收起
  919. function toggleToolPanel(header) {
  920. const content = header.nextElementSibling;
  921. const icon = header.querySelector('.expand-icon');
  922. content.classList.toggle('expanded');
  923. icon.classList.toggle('expanded');
  924. }
  925. // 切换参数/结果折叠区域
  926. function toggleCollapsible(header) {
  927. const content = header.nextElementSibling;
  928. const icon = header.querySelector('.collapsible-icon');
  929. content.classList.toggle('expanded');
  930. icon.classList.toggle('expanded');
  931. }
  932. // 添加工具调用项
  933. function addToolCallItem(container, toolName, params, toolUseId = null) {
  934. const item = document.createElement('div');
  935. item.className = 'tool-call-item pending';
  936. const uniqueId = `tool-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  937. item.id = uniqueId;
  938. // 存储 tool_use_id 和 tool_name 以便后续匹配
  939. if (toolUseId) {
  940. item.dataset.toolUseId = toolUseId;
  941. }
  942. item.dataset.toolName = toolName;
  943. console.log('[addToolCallItem] Created item:', { id: uniqueId, toolName, toolUseId });
  944. // 简化工具名称显示 - 去掉 mcp__ 前缀和下划线
  945. let displayName = toolName;
  946. if (toolName.startsWith('mcp__')) {
  947. displayName = toolName.replace('mcp__', '').replace(/_/g, ' ');
  948. }
  949. // 首字母大写
  950. displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1);
  951. const paramsStr = params ? JSON.stringify(params, null, 2) : '{}';
  952. const hasParams = params && Object.keys(params).length > 0;
  953. item.innerHTML = `
  954. <div class="tool-call-header">
  955. <div class="tool-name">
  956. <span>🔧</span>
  957. <span>${escapeHtml(displayName)}</span>
  958. </div>
  959. <div class="tool-status pending">
  960. <div class="spinner"></div>
  961. <span>执行中</span>
  962. </div>
  963. </div>
  964. ${hasParams ? `
  965. <div class="collapsible-section">
  966. <div class="collapsible-header" onclick="toggleCollapsible(this)">
  967. <span class="collapsible-icon">▶</span>
  968. <span>查看参数</span>
  969. </div>
  970. <div class="collapsible-content">
  971. <div class="tool-params"><pre style="margin:0;padding:0;background:transparent;font-size:10px;">${escapeHtml(paramsStr)}</pre></div>
  972. </div>
  973. </div>
  974. ` : ''}
  975. `;
  976. container.appendChild(item);
  977. return { id: uniqueId, toolUseId: toolUseId, toolName: toolName };
  978. }
  979. // 更新工具调用状态
  980. function updateToolCallStatus(itemId, status, result = null) {
  981. const item = document.getElementById(itemId);
  982. if (!item) {
  983. console.warn('[updateToolCallStatus] Item not found:', itemId);
  984. return;
  985. }
  986. const statusEl = item.querySelector('.tool-status');
  987. item.classList.remove('pending', 'success', 'error');
  988. if (status === 'success') {
  989. item.classList.add('success');
  990. statusEl.className = 'tool-status success';
  991. statusEl.innerHTML = '✅ 完成';
  992. // 总是显示结果区域(即使结果为空)
  993. const resultText = result ? (typeof result === 'string' ? result : JSON.stringify(result, null, 2)) : '';
  994. const displayResult = resultText || '(无返回结果)';
  995. const truncatedResult = displayResult.substring(0, 1000) + (displayResult.length > 1000 ? '...' : '');
  996. const resultDiv = document.createElement('div');
  997. resultDiv.className = 'collapsible-section';
  998. resultDiv.innerHTML = `
  999. <div class="collapsible-header" onclick="toggleCollapsible(this)">
  1000. <span class="collapsible-icon">▶</span>
  1001. <span>查看结果</span>
  1002. </div>
  1003. <div class="collapsible-content">
  1004. <div class="tool-result"><div class="tool-result-content">${escapeHtml(truncatedResult)}</div></div>
  1005. </div>
  1006. `;
  1007. item.appendChild(resultDiv);
  1008. } else if (status === 'error') {
  1009. item.classList.add('error');
  1010. statusEl.className = 'tool-status error';
  1011. statusEl.innerHTML = '❌ 失败';
  1012. const errorDiv = document.createElement('div');
  1013. errorDiv.className = 'collapsible-section';
  1014. errorDiv.innerHTML = `
  1015. <div class="collapsible-header" onclick="toggleCollapsible(this)">
  1016. <span class="collapsible-icon">▶</span>
  1017. <span>查看错误</span>
  1018. </div>
  1019. <div class="collapsible-content expanded">
  1020. <div class="tool-result error">${escapeHtml(result || '未知错误')}</div>
  1021. </div>
  1022. `;
  1023. item.appendChild(errorDiv);
  1024. }
  1025. }
  1026. // 更新工具面板状态
  1027. function updateToolPanelStatus(panel, status, totalCalls = 0) {
  1028. const indicator = panel.querySelector('.process-indicator');
  1029. const countEl = panel.querySelector('.tool-count');
  1030. const content = panel.querySelector('.tool-panel-content');
  1031. const icon = panel.querySelector('.expand-icon');
  1032. countEl.textContent = `(${totalCalls})`;
  1033. if (status === 'running') {
  1034. // 保持处理中状态,不改变
  1035. indicator.className = 'process-indicator';
  1036. indicator.innerHTML = `<div class="spinner"></div><span>处理中...</span>`;
  1037. } else if (status === 'complete') {
  1038. indicator.className = 'process-indicator success';
  1039. indicator.innerHTML = `<span>✅ 完成 - ${totalCalls} 个工具调用</span>`;
  1040. // 自动展开面板以便查看结果
  1041. if (content && !content.classList.contains('expanded')) {
  1042. content.classList.add('expanded');
  1043. if (icon) icon.classList.add('expanded');
  1044. }
  1045. } else if (status === 'error') {
  1046. indicator.className = 'process-indicator';
  1047. indicator.style.background = 'rgba(239, 68, 68, 0.1)';
  1048. indicator.style.color = '#f87171';
  1049. indicator.innerHTML = `<span>❌ 部分失败</span>`;
  1050. }
  1051. }
  1052. // HTML 转义
  1053. function escapeHtml(text) {
  1054. const div = document.createElement('div');
  1055. div.textContent = text;
  1056. return div.innerHTML;
  1057. }
  1058. function scrollToBottom() {
  1059. chatMessages.scrollTop = chatMessages.scrollHeight;
  1060. }
  1061. // ========== 流式聊天功能 ==========
  1062. async function chatStream(message, onEvent) {
  1063. const events = [];
  1064. try {
  1065. // 收集所有已登录 MCP 的 tokens
  1066. const mcpTokens = {};
  1067. for (const [type, config] of Object.entries(MCP_SERVERS)) {
  1068. if (config.auth_type === 'jwt' && TokenManager.isLoggedIn(type)) {
  1069. mcpTokens[type] = TokenManager.getToken(type);
  1070. }
  1071. }
  1072. const headers = { 'Content-Type': 'application/json' };
  1073. // 将 tokens 作为 JSON 字符串传递
  1074. if (Object.keys(mcpTokens).length > 0) {
  1075. headers['X-MCP-Tokens'] = JSON.stringify(mcpTokens);
  1076. }
  1077. const response = await fetch('/api/chat/stream', {
  1078. method: 'POST',
  1079. headers: headers,
  1080. body: JSON.stringify({ message, history: conversationHistory })
  1081. });
  1082. if (!response.ok) {
  1083. throw new Error(`HTTP error! status: ${response.status}`);
  1084. }
  1085. const reader = response.body.getReader();
  1086. const decoder = new TextDecoder();
  1087. let buffer = '';
  1088. while (true) {
  1089. const { done, value } = await reader.read();
  1090. if (done) break;
  1091. buffer += decoder.decode(value, { stream: true });
  1092. // 处理 SSE 格式: "event: xxx\ndata: {...}\n\n"
  1093. const lines = buffer.split('\n');
  1094. buffer = lines.pop() || ''; // 保留未完成的行
  1095. for (let i = 0; i < lines.length; i++) {
  1096. const line = lines[i].trim();
  1097. if (!line) continue;
  1098. if (line.startsWith('event:')) {
  1099. const eventType = line.substring(6).trim();
  1100. events.push({ type: eventType, data: null });
  1101. } else if (line.startsWith('data:')) {
  1102. const data = line.substring(5).trim();
  1103. if (events.length > 0) {
  1104. events[events.length - 1].data = data;
  1105. }
  1106. }
  1107. // 处理完整的事件
  1108. while (events.length > 0 && events[0].data) {
  1109. const event = events.shift();
  1110. try {
  1111. const data = JSON.parse(event.data);
  1112. onEvent(event.type, data);
  1113. } catch (e) {
  1114. console.error('Parse error:', e, event.data);
  1115. }
  1116. }
  1117. }
  1118. }
  1119. } catch (error) {
  1120. onEvent('error', { error: error.message });
  1121. }
  1122. }
  1123. async function sendMessageStream(message) {
  1124. if (isTyping) return;
  1125. isTyping = true;
  1126. sendButton.disabled = true;
  1127. // 添加用户消息
  1128. addMessage('user', message);
  1129. conversationHistory.push({ role: 'user', content: message });
  1130. // 创建助手消息容器
  1131. const assistantMsgDiv = document.createElement('div');
  1132. assistantMsgDiv.className = 'flex justify-start';
  1133. assistantMsgDiv.innerHTML = `
  1134. <div class="message-assistant message-bubble rounded-lg p-4 shadow-sm">
  1135. <div class="response-content">
  1136. <div class="text" id="currentResponse"></div>
  1137. </div>
  1138. <div id="toolCallContainer"></div>
  1139. </div>
  1140. `;
  1141. chatMessages.appendChild(assistantMsgDiv);
  1142. const responseDiv = assistantMsgDiv.querySelector('#currentResponse');
  1143. const toolContainer = assistantMsgDiv.querySelector('#toolCallContainer');
  1144. let currentText = '';
  1145. let toolCallsCount = 0;
  1146. let toolCallItems = []; // 改为数组存储,按索引匹配
  1147. let finalResponse = '';
  1148. let toolCallPanel = null;
  1149. let toolCallsContainer = null;
  1150. await chatStream(message, (eventType, data) => {
  1151. switch (eventType) {
  1152. case 'start':
  1153. responseDiv.innerHTML = '<span style="color: #888; font-style: italic;">🤔 AI 正在思考...</span>';
  1154. break;
  1155. case 'token':
  1156. // 移除思考提示
  1157. if (currentText === '') {
  1158. responseDiv.innerHTML = '';
  1159. }
  1160. currentText += data.text || '';
  1161. responseDiv.innerHTML = formatMessage(currentText);
  1162. scrollToBottom();
  1163. break;
  1164. case 'tool_call':
  1165. // 首次工具调用时创建面板
  1166. if (!toolCallPanel) {
  1167. toolCallPanel = createToolCallPanel();
  1168. toolContainer.appendChild(toolCallPanel);
  1169. toolCallsContainer = toolCallPanel.querySelector('.tool-calls-container');
  1170. // 清空思考提示
  1171. if (currentText === '' || currentText.includes('正在思考')) {
  1172. responseDiv.innerHTML = '<span style="color: #888; font-style: italic;">🔧 正在调用工具...</span>';
  1173. }
  1174. }
  1175. // 添加工具调用项
  1176. const toolName = data.tool || 'unknown';
  1177. const params = data.args || data.params || null;
  1178. const toolUseId = data.tool_id || null;
  1179. console.log('[tool_call] Adding tool:', { toolName, toolUseId, params });
  1180. const itemInfo = addToolCallItem(toolCallsContainer, toolName, params, toolUseId);
  1181. toolCallItems.push(itemInfo);
  1182. toolCallsCount++;
  1183. console.log('[tool_call] Current toolCallItems:', toolCallItems);
  1184. updateToolPanelStatus(toolCallPanel, 'running', toolCallsCount);
  1185. scrollToBottom();
  1186. break;
  1187. case 'tools_start':
  1188. // 批量工具开始
  1189. if (!toolCallPanel) {
  1190. toolCallPanel = createToolCallPanel();
  1191. toolContainer.appendChild(toolCallPanel);
  1192. toolCallsContainer = toolCallPanel.querySelector('.tool-calls-container');
  1193. }
  1194. if (currentText === '' || currentText.includes('正在思考')) {
  1195. responseDiv.innerHTML = '<span style="color: #888; font-style: italic;">🔧 正在调用工具...</span>';
  1196. }
  1197. scrollToBottom();
  1198. break;
  1199. case 'tool_done':
  1200. // 工具执行完成 - 按 tool_use_id 或 tool_name 匹配
  1201. const doneToolUseId = data.tool_id;
  1202. const doneToolName = data.tool;
  1203. const doneResult = data.result;
  1204. console.log('[tool_done] Looking for tool:', { tool_id: doneToolUseId, tool: doneToolName });
  1205. console.log('[tool_done] Current toolCallItems:', toolCallItems);
  1206. // 查找匹配的工具调用项
  1207. const doneItem = toolCallItems.find(item =>
  1208. item.toolUseId === doneToolUseId || item.toolName === doneToolName
  1209. );
  1210. if (doneItem) {
  1211. console.log('[tool_done] Found item, updating status:', doneItem.id);
  1212. updateToolCallStatus(doneItem.id, 'success', doneResult);
  1213. } else {
  1214. console.warn('[tool_done] Tool item not found for:', { tool_id: doneToolUseId, tool: doneToolName });
  1215. // 如果找不到匹配项,尝试通过 DOM 查找
  1216. const fallbackItem = Array.from(document.querySelectorAll('.tool-call-item')).find(el => {
  1217. return el.dataset.toolUseId === doneToolUseId || el.dataset.toolName === doneToolName;
  1218. });
  1219. if (fallbackItem) {
  1220. console.log('[tool_done] Found via DOM fallback:', fallbackItem.id);
  1221. updateToolCallStatus(fallbackItem.id, 'success', doneResult);
  1222. }
  1223. }
  1224. scrollToBottom();
  1225. break;
  1226. case 'tool_error':
  1227. // 工具执行错误 - 按 tool_use_id 或 tool_name 匹配
  1228. const errorToolUseId = data.tool_id;
  1229. const errorToolName = data.tool;
  1230. const errorItem = toolCallItems.find(item =>
  1231. item.toolUseId === errorToolUseId || item.toolName === errorToolName
  1232. );
  1233. if (errorItem) {
  1234. updateToolCallStatus(errorItem.id, 'error', data.error);
  1235. }
  1236. if (toolCallPanel) {
  1237. updateToolPanelStatus(toolCallPanel, 'error', toolCallsCount);
  1238. }
  1239. scrollToBottom();
  1240. break;
  1241. case 'complete':
  1242. // 完成
  1243. console.log('[complete] Tool calls finished, updating panel status');
  1244. if (toolCallPanel) {
  1245. updateToolPanelStatus(toolCallPanel, 'complete', toolCallsCount);
  1246. }
  1247. // 清空临时提示并显示最终响应
  1248. if (data.response || currentText) {
  1249. finalResponse = data.response || currentText;
  1250. responseDiv.innerHTML = formatMessage(finalResponse);
  1251. }
  1252. conversationHistory.push({ role: 'assistant', content: finalResponse });
  1253. scrollToBottom();
  1254. break;
  1255. case 'error':
  1256. responseDiv.innerHTML = `<span style="color: #ef4444;">❌ 错误: ${escapeHtml(data.error || '未知错误')}</span>`;
  1257. if (toolCallPanel) {
  1258. updateToolPanelStatus(toolCallPanel, 'error', toolCallsCount);
  1259. }
  1260. scrollToBottom();
  1261. break;
  1262. }
  1263. });
  1264. // 重置状态
  1265. isTyping = false;
  1266. sendButton.disabled = false;
  1267. userInput.focus();
  1268. }
  1269. // Load MCP servers
  1270. async function loadMCPServers() {
  1271. try {
  1272. const response = await fetch(`${API_BASE}/api/mcp/servers`);
  1273. const data = await response.json();
  1274. mcpServers.innerHTML = data.servers.map(server => {
  1275. const mcpType = server.id;
  1276. const isLoggedIn = TokenManager.isLoggedIn(mcpType);
  1277. const username = TokenManager.getUsername(mcpType);
  1278. let authStatus = '';
  1279. let clickHandler = '';
  1280. if (server.auth_type === 'jwt') {
  1281. if (isLoggedIn) {
  1282. authStatus = `<span class="text-green-600 font-medium" title="已登录为 ${username}">✅ ${username || '已登录'}</span>`;
  1283. clickHandler = `onclick="handleMcpClick('${mcpType}', '${server.auth_type}')"`;
  1284. } else {
  1285. authStatus = '<span class="text-amber-600" title="需要登录">🔒 需要登录</span>';
  1286. clickHandler = `onclick="handleMcpClick('${mcpType}', '${server.auth_type}')"`;
  1287. }
  1288. } else {
  1289. authStatus = '<span class="text-gray-500">🔓 公开</span>';
  1290. }
  1291. return `
  1292. <div class="mcp-server-tag ${server.enabled ? 'enabled' : 'disabled'}" ${clickHandler} style="cursor: ${server.auth_type === 'jwt' ? 'pointer' : 'default'}">
  1293. <span class="mcp-status-dot ${server.enabled ? 'online' : 'offline'}"></span>
  1294. <span class="font-medium">${server.name}</span>
  1295. ${authStatus}
  1296. </div>
  1297. `;
  1298. }).join('');
  1299. } catch (error) {
  1300. console.error('Failed to load MCP servers:', error);
  1301. }
  1302. }
  1303. // 处理 MCP 服务器点击
  1304. function handleMcpClick(mcpType, authType) {
  1305. if (authType !== 'jwt') return;
  1306. if (TokenManager.isLoggedIn(mcpType)) {
  1307. // 已登录,询问是否登出
  1308. const username = TokenManager.getUsername(mcpType);
  1309. if (confirm(`当前已登录为 ${username}\n\n是否要登出?`)) {
  1310. TokenManager.clearToken(mcpType);
  1311. loadMCPServers();
  1312. }
  1313. } else {
  1314. // 未登录,显示登录对话框
  1315. showLoginModal(mcpType);
  1316. }
  1317. }
  1318. // 将 handleMcpClick 暴露到全局作用域
  1319. window.handleMcpClick = handleMcpClick;
  1320. // Check health
  1321. async function checkHealth() {
  1322. try {
  1323. const response = await fetch(`${API_BASE}/api/health`);
  1324. if (response.ok) {
  1325. statusIndicator.innerHTML = `
  1326. <span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
  1327. <span class="text-sm text-gray-600">已连接</span>
  1328. `;
  1329. } else {
  1330. throw new Error('Health check failed');
  1331. }
  1332. } catch (error) {
  1333. statusIndicator.innerHTML = `
  1334. <span class="w-2 h-2 bg-red-500 rounded-full"></span>
  1335. <span class="text-sm text-gray-600">连接失败</span>
  1336. `;
  1337. }
  1338. }
  1339. // Event listeners
  1340. chatForm.addEventListener('submit', (e) => {
  1341. e.preventDefault();
  1342. const message = userInput.value.trim();
  1343. if (message) {
  1344. sendMessageStream(message);
  1345. userInput.value = '';
  1346. }
  1347. });
  1348. // Initialize
  1349. loadMCPServers();
  1350. checkHealth();
  1351. userInput.focus();
  1352. // Make toggle functions global
  1353. window.toggleToolPanel = toggleToolPanel;
  1354. window.toggleCollapsible = toggleCollapsible;
  1355. </script>
  1356. </body>
  1357. </html>