index.html 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  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>基于A2A的SmartBizTrip企业差旅助手</title>
  7. <!-- CSS 控制颜色、布局和动画;不会发起业务请求。实际调用逻辑在页面末尾的 script。 -->
  8. <style>
  9. :root {
  10. color-scheme: light;
  11. --blue: #2563eb;
  12. --blue-dark: #1d4ed8;
  13. --text: #1e293b;
  14. --muted: #64748b;
  15. --line: #e5eaf1;
  16. --canvas: #f5f7fb;
  17. }
  18. * { margin: 0; padding: 0; box-sizing: border-box; }
  19. body {
  20. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
  21. background: var(--canvas); color: var(--text);
  22. height: 100vh; height: 100dvh; display: flex; flex-direction: column;
  23. -webkit-font-smoothing: antialiased;
  24. }
  25. button, input { font: inherit; }
  26. button { cursor: pointer; transition: background .18s, border-color .18s, box-shadow .18s; }
  27. button:focus-visible, summary:focus-visible { outline: 3px solid #93b4ff; outline-offset: 3px; }
  28. .header {
  29. min-height: 78px; padding: 16px 32px; background: #fff;
  30. border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 14px;
  31. }
  32. .brand-mark {
  33. width: 42px; height: 42px; flex: 0 0 42px; display: grid; place-items: center;
  34. border-radius: 12px; background: var(--blue); color: #fff;
  35. box-shadow: 0 4px 10px #2563eb20;
  36. }
  37. .header h1 { font-size: 21px; font-weight: 700; letter-spacing: -.6px; }
  38. .header p { font-size: 12px; color: var(--muted); margin-top: 3px; }
  39. .header-label { margin-left: auto; font-size: 12px; color: var(--muted); border-left: 1px solid var(--line); padding-left: 20px; }
  40. .main { display: flex; flex: 1; min-height: 0; gap: 22px; padding: 24px 28px 16px; overflow: hidden; }
  41. .chat-area {
  42. flex: 2; min-width: 0; display: flex; flex-direction: column; background: #fff;
  43. border: 1px solid var(--line); border-radius: 16px; overflow: hidden;
  44. box-shadow: 0 3px 16px #1e293b04;
  45. }
  46. .chat-heading { padding: 20px 26px; border-bottom: 1px solid var(--line); }
  47. .chat-heading h2 { font-size: 16px; font-weight: 600; }
  48. .chat-heading p { font-size: 12px; color: var(--muted); margin-top: 6px; }
  49. .chat-messages { flex: 1; min-height: 0; overflow-y: auto; padding: 28px; }
  50. .chat-messages > .loading {
  51. max-width: 500px; margin: clamp(28px, 10vh, 100px) auto 0; padding: 28px 20px;
  52. line-height: 1.8; color: var(--muted); font-size: 14px;
  53. }
  54. .chat-messages > .loading::before {
  55. content: "开启高效差旅"; display: block; color: var(--text); font-size: 27px;
  56. font-weight: 650; letter-spacing: -.5px; margin-bottom: 12px;
  57. }
  58. .chat-messages > .loading::after {
  59. content: "天气查询 · 交通票务 · 商务用车 · 会务安排";
  60. display: block; font-size: 12px; color: var(--muted); margin-top: 24px;
  61. padding: 14px 8px; border-top: 1px solid var(--line);
  62. }
  63. .message { margin-bottom: 24px; display: flex; gap: 10px; align-items: flex-start; }
  64. .message.user { justify-content: flex-end; }
  65. .message.assistant { justify-content: flex-start; }
  66. .message.assistant::before {
  67. content: "S"; flex: 0 0 30px; height: 30px; border-radius: 9px;
  68. display: grid; place-items: center; color: var(--blue); background: #edf3ff;
  69. font-size: 14px; font-weight: 700; margin-top: 3px;
  70. }
  71. .message-bubble { max-width: 82%; padding: 13px 17px; border-radius: 12px; font-size: 14px; line-height: 1.85; white-space: pre-wrap; overflow-wrap: anywhere; }
  72. .message.user .message-bubble { background: var(--blue); color: #fff; border-bottom-right-radius: 4px; }
  73. .message.assistant .message-bubble { background: #f6f8fc; color: var(--text); border: 1px solid #edf0f5; border-top-left-radius: 4px; }
  74. .chat-input-area { padding: 20px 24px; background: #fff; border-top: 1px solid var(--line); display: flex; gap: 12px; }
  75. .chat-input-area input {
  76. flex: 1; min-width: 0; padding: 14px 16px; border-radius: 10px;
  77. border: 1px solid #dce3ed; background: #fafbfd; color: var(--text); font-size: 14px; outline: none;
  78. transition: border-color .18s, box-shadow .18s;
  79. }
  80. input::placeholder { color: #78869a; }
  81. .chat-input-area input:focus { border-color: var(--blue); background: #fff; box-shadow: 0 0 0 3px #2563eb12; }
  82. .chat-input-area button { padding: 12px 24px; border-radius: 10px; border: 1px solid var(--blue); background: var(--blue); color: #fff; font-size: 14px; font-weight: 600; }
  83. .chat-input-area button:hover { background: var(--blue-dark); }
  84. .chat-input-area button:disabled { background: #a9bee8; border-color: #a9bee8; cursor: not-allowed; }
  85. .sidebar { flex: 1; min-width: 290px; max-width: 360px; overflow-y: auto; padding: 2px 5px 10px 0; }
  86. .section-title { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 650; color: #475569; margin: 2px 0 13px; }
  87. .section-title::before { content: ""; width: 3px; height: 13px; border-radius: 3px; background: var(--blue); }
  88. .agent-card { background: #fff; border-radius: 12px; padding: 16px 18px; margin-bottom: 10px; border: 1px solid var(--line); }
  89. .agent-card h3 { font-size: 13px; font-weight: 650; color: var(--text); margin-bottom: 7px; overflow-wrap: anywhere; }
  90. .agent-card p { font-size: 12px; line-height: 1.7; color: var(--muted); margin-bottom: 7px; }
  91. .agent-card .skill { font-size: 12px; line-height: 1.6; color: #526176; padding: 2px 0; }
  92. .agent-card .status { display: inline-block; font-size: 11px; color: #17714b; background: #edf8f1; border-radius: 5px; padding: 3px 7px; margin-top: 9px; }
  93. .memory-section { margin-bottom: 10px; border: 1px solid var(--line); border-radius: 10px; background: #fff; padding: 0 14px; }
  94. .memory-section summary { cursor: pointer; font-size: 12px; font-weight: 600; color: #475569; padding: 14px 0; }
  95. .memory-section summary:hover { color: var(--blue); }
  96. .memory-item { background: #f8fafc; border-radius: 7px; padding: 10px; margin-bottom: 9px; border: 1px solid #edf1f6; overflow-wrap: anywhere; }
  97. .memory-item p { font-size: 12px; line-height: 1.75; color: #526176; margin-bottom: 4px; white-space: pre-wrap; }
  98. .memory-item .timestamp { font-size: 10px; color: var(--muted); }
  99. /* Preference rows are generated with inline styles; override their presentation only. */
  100. #profileEditor .memory-item { padding: 8px; gap: 6px !important; }
  101. #profileEditor input { min-width: 0; width: 0; padding: 7px 8px !important; border-radius: 5px !important; border-color: #dce3ed !important; background: #fff !important; color: var(--text) !important; outline: none; }
  102. #profileEditor input:focus { border-color: var(--blue) !important; box-shadow: 0 0 0 2px #2563eb12; }
  103. #profileEditor button { color: var(--blue) !important; border-color: #ccdafa !important; border-radius: 6px !important; min-height: 30px; }
  104. #profileEditor button:hover { background: #eff5ff !important; }
  105. #profileEditor .memory-item button { flex-shrink: 0; color: #64748b !important; border-color: #dce3ed !important; }
  106. .clear-btn { width: 100%; padding: 10px; margin: 3px 0 24px; border-radius: 8px; border: 1px solid #dce3ed; background: #fff; color: #526176; font-size: 12px; }
  107. .clear-btn:hover { background: #eff5ff; border-color: #b9cff8; color: var(--blue); }
  108. .loading { color: var(--muted); font-size: 13px; text-align: center; padding: 12px; }
  109. .typing-indicator { display: inline-flex; gap: 4px; align-items: center; }
  110. .typing-indicator span { width: 5px; height: 5px; background: var(--blue); border-radius: 50%; animation: blink 1.4s infinite both; }
  111. .typing-indicator span:nth-child(2) { animation-delay: .2s; }
  112. .typing-indicator span:nth-child(3) { animation-delay: .4s; }
  113. @keyframes blink { 0%, 80%, 100% { opacity: .2; } 40% { opacity: 1; } }
  114. .cursor { display: inline-block; width: 2px; height: 1em; background: var(--blue); margin-left: 2px; animation: cursor-blink .8s infinite; vertical-align: text-bottom; }
  115. @keyframes cursor-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
  116. .footer { text-align: center; padding: 0 16px 12px; font-size: 11px; color: var(--muted); }
  117. ::-webkit-scrollbar { width: 5px; height: 5px; }
  118. ::-webkit-scrollbar-track { background: transparent; }
  119. ::-webkit-scrollbar-thumb { background: #ccd5e2; border-radius: 4px; }
  120. @media (max-width: 1000px) {
  121. .main { padding: 16px; gap: 16px; }
  122. .sidebar { min-width: 260px; max-width: 300px; }
  123. .chat-messages { padding: 20px; }
  124. .chat-input-area { padding: 16px; }
  125. }
  126. @media (max-width: 680px) {
  127. body { height: auto; min-height: 100dvh; }
  128. .header { padding: 14px 16px; }
  129. .header-label { display: none; }
  130. .main { flex-direction: column; overflow: visible; padding: 12px; gap: 20px; }
  131. .chat-area { flex: none; height: 72dvh; min-height: 420px; }
  132. .chat-heading { padding: 16px; }
  133. .chat-messages { padding: 16px; }
  134. .chat-messages > .loading { margin-top: 24px; padding: 20px 0; }
  135. .chat-messages > .loading::before { font-size: 23px; }
  136. .sidebar { flex: none; width: 100%; max-width: none; min-width: 0; overflow: visible; padding: 0; }
  137. .message-bubble { max-width: 85%; }
  138. .message.assistant .message-bubble { max-width: calc(100% - 40px); }
  139. .chat-input-area { padding: 12px; gap: 8px; }
  140. .chat-input-area button { padding: 12px 18px; }
  141. .footer { padding-top: 8px; }
  142. }
  143. @media (prefers-reduced-motion: reduce) {
  144. *, *::before, *::after { transition: none !important; animation: none !important; }
  145. }
  146. </style>
  147. </head>
  148. <body>
  149. <div class="header">
  150. <div class="brand-mark" aria-hidden="true"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="14" rx="3"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 12a24 24 0 0 0 18 0M10 13h4"/></svg></div>
  151. <div><h1>SmartBizTrip</h1><p>企业差旅智能助手</p></div>
  152. <span class="header-label">企业差旅工作台</span>
  153. </div>
  154. <div class="main">
  155. <!-- 页面结构 1:消息列表和输入区;发送按钮与 Enter 键都调用 sendMessage。 -->
  156. <div class="chat-area">
  157. <div class="chat-heading"><h2>差旅助手</h2><p>从出行查询到会务安排,让差旅准备更轻松</p></div>
  158. <div class="chat-messages" id="chatMessages">
  159. <div class="loading">欢迎体验智能对话!输入问题即可开始</div>
  160. </div>
  161. <div class="chat-input-area">
  162. <input type="text" id="chatInput" aria-label="输入差旅问题" placeholder="描述您的差旅需求,按 Enter 发送…" autocomplete="off">
  163. <button id="sendBtn" onclick="sendMessage()">发送</button>
  164. </div>
  165. </div>
  166. <!-- 页面结构 2:代理卡片和记忆侧栏;数据由下方脚本调用 API 后填入。 -->
  167. <div class="sidebar">
  168. <div class="section-title">助手能力</div>
  169. <div id="agentCards"></div>
  170. <button class="clear-btn" onclick="clearMemory()">清空记忆</button>
  171. <div class="section-title">对话记忆</div>
  172. <div id="memoryPanel">
  173. <div class="loading">加载中...</div>
  174. </div>
  175. </div>
  176. </div>
  177. <div class="footer">Powered by 企业差旅中心 | 基于Agent2Agent的企业差旅助手系统 v7.5</div>
  178. <script>
  179. // 阅读顺序对应 api_server.py 顶部:本页负责主流程 1(发送)和 12(结束接收)。
  180. // 后端第 10 步生成片段时,本页同时读取并刷新气泡;第 11 步保存记忆后才结束响应。
  181. // 【页面初始化 1】脚本位于 HTML 元素之后,此时可以按 id 取得消息区、输入框和按钮。
  182. const chatMessages = document.getElementById('chatMessages');
  183. // 变量保存 DOM 元素的引用,后面修改 chatInput.value 或 sendBtn.disabled 会作用到页面上的同一个元素。
  184. // getElementById 中的名称需要与上方 HTML 的 id 保持一致,不是访问后端变量。
  185. const chatInput = document.getElementById('chatInput');
  186. const sendBtn = document.getElementById('sendBtn');
  187. // 【页面初始化 2】绑定键盘事件;这里只注册回调,按下 Enter 时才调用 sendMessage。
  188. chatInput.addEventListener('keydown', (e) => {
  189. // e 是浏览器提供的键盘事件;只有按 Enter 且没同时按 Shift,才进入发送分支。
  190. // 注册监听器时不会执行此分支,它会在后续按键事件到来时触发。
  191. if (e.key === 'Enter' && !e.shiftKey) {
  192. // 阻止本次按键的默认行为,然后复用发送按钮也在调用的 sendMessage。
  193. e.preventDefault();
  194. sendMessage();
  195. }
  196. });
  197. // 【界面辅助】创建消息气泡并返回 bubble,供 sendMessage 后续持续更新内容。
  198. function addMessage(role, content) {
  199. // 调用者:发送消息、收到响应或展示错误的代码。role 决定气泡样式,content 是初始显示文本。
  200. // 返回值是新创建的 bubble 元素;sendMessage 保存这个引用,以后把新片段更新到同一个气泡。
  201. const loading = chatMessages.querySelector('.loading');
  202. // 只有找到欢迎/加载提示时才删除;已有聊天消息时查不到该元素,不影响继续创建气泡。
  203. if (loading) loading.remove();
  204. const div = document.createElement('div');
  205. div.className = `message ${role}`;
  206. const bubble = document.createElement('div');
  207. bubble.className = 'message-bubble';
  208. bubble.innerHTML = escapeHtml(content);
  209. // 先把 bubble 放进外层 message div,再把外层 div 放进 chatMessages,元素才真正出现在页面上。
  210. div.appendChild(bubble);
  211. chatMessages.appendChild(div);
  212. chatMessages.scrollTop = chatMessages.scrollHeight;
  213. return bubble;
  214. }
  215. // 【界面辅助】发送后显示等待提示,收到响应或遇到错误时由 hideTyping 移除。
  216. function showTyping() {
  217. // 创建一条临时助手消息,固定 id 为 typingIndicator,方便 hideTyping 精确找到并移除。
  218. // 这只是“等待响应”的界面提示,与模型是否正在推理或工具是否已经运行不是同一个状态。
  219. const div = document.createElement('div');
  220. div.className = 'message assistant';
  221. div.id = 'typingIndicator';
  222. div.innerHTML = `<div class="message-bubble"><div class="typing-indicator"><span></span><span></span><span></span></div> 思考中,请等待...</div>`;
  223. chatMessages.appendChild(div);
  224. chatMessages.scrollTop = chatMessages.scrollHeight;
  225. }
  226. // 【界面辅助】移除等待提示;没找到提示元素时不做处理。
  227. function hideTyping() {
  228. // 从 DOM 查找临时提示,存在才 remove;重复调用也不会因为元素已不存在而报错。
  229. const indicator = document.getElementById('typingIndicator');
  230. if (indicator) indicator.remove();
  231. }
  232. // 【界面辅助】先按文本写入临时元素,再读取转义后的 HTML,避免把回复文本当作标签解析。
  233. function escapeHtml(text) {
  234. // 先把 text 写到一个临时 div 的 textContent,浏览器按文本处理,再读取转义后的 innerHTML。
  235. // 例如回复中的 <b> 会作为文本显示;真正使用 innerHTML 的地方仍可自行追加光标等界面标记。
  236. const div = document.createElement('div');
  237. div.textContent = text;
  238. return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
  239. }
  240. async function sendMessage() {
  241. // 入口有两处:点击发送按钮,或按 Enter。它不是页面加载时自动执行的聊天请求。
  242. // 局部 fullText 保存这次助手回复,reader/decoder/buffer 只服务于本次响应流。
  243. // await 会等待异步结果再执行下一行;期间浏览器仍能处理其他事件,并不是暂停整个页面。
  244. // 【主流程 1.1】读取输入,空文本直接结束;随后显示用户消息并禁用发送按钮。
  245. const text = chatInput.value.trim();
  246. // trim 后空串会提前结束本次函数,所以不会清空输入框、显示等待提示或发送请求。
  247. if (!text || sendBtn.disabled) return;
  248. // 把用户刚提交的文本留在局部 text 中,再清空输入框;后续 POST 仍发送原 text。
  249. // 上面的禁用状态检查同时阻止按 Enter 重复提交尚未结束的请求。
  250. chatInput.value = '';
  251. sendBtn.disabled = true;
  252. addMessage('user', text);
  253. showTyping();
  254. let fullText = '';
  255. try {
  256. // 【主流程 1.2 → 2】把消息作为 JSON POST 到 API。fetch 返回响应后再继续读取响应体。
  257. // 这里使用 fetch 的流读取能力消费 SSE 格式文本,不是创建 EventSource。
  258. const response = await fetch('/api/chat/stream', {
  259. method: 'POST',
  260. headers: { 'Content-Type': 'application/json' },
  261. // 前端对象转换为 JSON 请求体,字段 message 对应后端 ChatRequest.message。
  262. // Content-Type 指定请求体格式;它与后端响应的 text/event-stream 是两个方向的类型。
  263. body: JSON.stringify({ message: text })
  264. });
  265. if (!response.ok) throw new Error('聊天请求失败');
  266. // 收到 HTTP 响应对象后先撤下等待提示,再创建空助手气泡;此时未必已经拿到回复正文片段。
  267. hideTyping();
  268. const bubble = addMessage('assistant', '');
  269. // 【返回过程 10 → 网页】取得字节流读取器;TextDecoder 把 UTF-8 字节转换为文字。
  270. const reader = response.body.getReader();
  271. const decoder = new TextDecoder();
  272. // buffer 是未处理完的一行 SSE 文本,fullText 是已解析出的助手回复,二者不能混用。
  273. // 例如本次网络块只收到 data: {"chu,先暂存,等下个网络块补齐再解析。
  274. let buffer = '';
  275. // 【接收内部步骤 1】循环等待新的网络字节块;字节块不保证刚好是一个完整 SSE 事件。
  276. while (true) {
  277. // 【读流细节 1】解构本次读取结果:done 表示流结束,value 是本次收到的字节数组。
  278. // 一次读取可能含多个事件,也可能只有半个事件;不能直接把 value 当成 JSON 字符串。
  279. const { done, value } = await reader.read();
  280. // 【读流细节 2】break 只退出当前 while,随后继续执行去光标和 finally 等代码;不是退出整个 sendMessage。
  281. if (done) break;
  282. // 【接收内部步骤 2】保留未读完的字节和文本:decoder 处理跨块的中文字符,buffer 保留未结束的一行。
  283. // 本后端把每个事件写为一行 data: JSON,再跟一个空行,因此这里按行解析。
  284. buffer += decoder.decode(value, { stream: true });
  285. // 【读流细节 3】按换行拆出已接收文本。最后一项可能不完整,下一行将它从 lines 取走并继续缓存。
  286. // 如果刚好以换行结尾,最后一项就是空串,buffer 也会恢复为空。
  287. const lines = buffer.split('\n');
  288. // pop 会从数组移除最后一项并返回它;因此下方 for 只处理前面完整的行。
  289. // || 在取不到内容时使用空串,方便下一次用 += 接续新文本。
  290. buffer = lines.pop() || '';
  291. // 【读流细节 4】逐行解析一批网络数据,内层 for 结束后回到外层 while 继续读取下一块。
  292. for (const line of lines) {
  293. const trimmed = line.trim();
  294. // continue 只跳过当前这一行,继续检查下一行;空行和非 data 行都不会成为聊天内容。
  295. if (!trimmed.startsWith('data: ')) continue;
  296. // 从第 6 个字符开始保留,去掉前缀 data: 加一个空格;得到 JSON 文本或 [DONE] 标记。
  297. const data = trimmed.slice(6);
  298. // 【主流程 12.2】结束标记不显示在气泡中;当前实现跳过它,直到 reader.read() 的 done 为 true 才退出。
  299. if (data === '[DONE]') continue;
  300. try {
  301. // 【接收内部步骤 3】解析后端 {chunk: ...},累计成全文并重新绘制气泡;不是每次创建新消息。
  302. const parsed = JSON.parse(data);
  303. // 【读流细节 5】chunk 非空才更新显示;空片段被忽略,继续等后续内容。
  304. // 这里收到的是 API 封装后的对象,不再是 A2A Task 或数据库结果。
  305. if (parsed.chunk) {
  306. // 把新片段追加到已有正文末尾;例如“北京”加“明天晴”变为“北京明天晴”,不插入额外换行。
  307. fullText += parsed.chunk;
  308. // 每次用累计全文重绘同一个 bubble,再追加闪烁光标;显示效果像逐步打字。
  309. bubble.innerHTML = escapeHtml(fullText) + '<span class="cursor"></span>';
  310. chatMessages.scrollTop = chatMessages.scrollHeight;
  311. }
  312. } catch (e) { /* 当前 data 行不是合法 JSON 时跳过它,后续行仍继续解析。 */ }
  313. }
  314. }
  315. // 【主流程 12.3】响应流结束,去掉光标;若没有任何片段则显示空回复提示。
  316. bubble.innerHTML = escapeHtml(fullText);
  317. // 流已结束但没有解析出任何 chunk 时显示空回复提示;不把 [DONE] 本身算成正文。
  318. if (!fullText) {
  319. bubble.innerHTML = '<span style="color:#888;">(未收到回复)</span>';
  320. }
  321. } catch (err) {
  322. hideTyping();
  323. // 外层 catch 处理 HTTP 失败状态、网络异常和读流异常。
  324. addMessage('assistant', '请求失败,请检查网络连接。');
  325. } finally {
  326. // 【主流程 12.4】无论成功还是异常,都恢复按钮和输入焦点。
  327. sendBtn.disabled = false;
  328. chatInput.focus();
  329. }
  330. // 【主流程 12.5】本轮接收结束后刷新记忆侧栏,显示后端保存的对话。
  331. loadMemory();
  332. }
  333. // 【页面初始化 3a】请求代理卡片后渲染侧栏;与 loadMemory 各自异步完成。
  334. async function loadAgents() {
  335. // 请求 GET /api/agents,response.json() 解析响应体,data.data 才是卡片列表。
  336. // map 把每张卡片转换为 HTML 字符串,join('') 合成一段再替换 agentCards 内容。
  337. try {
  338. const response = await fetch('/api/agents');
  339. const data = await response.json();
  340. const container = document.getElementById('agentCards');
  341. container.innerHTML = data.data.map(agent => `
  342. <div class="agent-card">
  343. <h3>${escapeHtml(agent.name)}</h3>
  344. <p>${escapeHtml(agent.description)}</p>
  345. ${agent.skills.map(s => `<div class="skill">• ${escapeHtml(s)}</div>`).join('')}
  346. <div class="status">● ${escapeHtml(agent.status)}</div>
  347. </div>
  348. `).join('');
  349. } catch (err) {
  350. console.error('Failed to load agents:', err);
  351. }
  352. }
  353. // 【页面初始化 3b / 主流程 12.5】请求记忆摘要并重建侧栏,包含历史、偏好、当前任务和查询记录。
  354. async function loadMemory() {
  355. // 请求 GET /api/memory,m 是后端返回的记忆摘要,不是直接读取数据库。
  356. // 模板字符串一次重建短期对话、偏好、当前任务和查询历史四块内容;空列表/空字典显示“无”。
  357. // 这会替换整个 memoryPanel,因此表单行也由返回数据重新创建。
  358. try {
  359. const response = await fetch('/api/memory');
  360. const data = await response.json();
  361. // 解包项目约定的外层响应 {status, data};后续 m.user_profile 等字段与后端 get_memory_state 一一对应。
  362. const m = data.data;
  363. const container = document.getElementById('memoryPanel');
  364. container.innerHTML = `
  365. <details class="memory-section" open>
  366. <summary>短期对话</summary>
  367. ${m.short_term_messages.map(msg => `
  368. <div class="memory-item">
  369. <p><strong>${escapeHtml(msg.role)}:</strong> ${escapeHtml(msg.content)}</p>
  370. <div class="timestamp">${escapeHtml(msg.timestamp)}</div>
  371. </div>
  372. `).join('') || '<div class="memory-item"><p>无</p></div>'}
  373. </details>
  374. <details class="memory-section" open>
  375. <summary>用户偏好</summary>
  376. <div id="profileEditor">
  377. ${Object.keys(m.user_profile).length > 0
  378. ? Object.entries(m.user_profile).map(([k, v]) => `
  379. <div class="memory-item" style="display:flex;gap:8px;align-items:center;">
  380. <input type="text" class="profile-key" value="${escapeHtml(k)}" placeholder="键名" style="flex:1;padding:4px 8px;border-radius:4px;border:1px solid #0f3460;background:#1a1a2e;color:#e0e0e0;font-size:12px;">
  381. <input type="text" class="profile-value" value="${escapeHtml(v)}" placeholder="值" style="flex:1;padding:4px 8px;border-radius:4px;border:1px solid #0f3460;background:#1a1a2e;color:#e0e0e0;font-size:12px;">
  382. <button onclick="removeProfileItem(this)" style="padding:2px 8px;border-radius:4px;border:1px solid #e94560;background:transparent;color:#e94560;font-size:12px;cursor:pointer;">&times;</button>
  383. </div>
  384. `).join('')
  385. : ''}
  386. <div style="display:flex;gap:8px;margin:8px 0;">
  387. <button onclick="addProfileRow()" style="flex:1;padding:6px;border-radius:4px;border:1px dashed #0f3460;background:transparent;color:#888;font-size:12px;cursor:pointer;">+ 添加偏好</button>
  388. <button onclick="saveProfile()" style="flex:1;padding:6px;border-radius:4px;border:1px solid #e94560;background:transparent;color:#e94560;font-size:12px;cursor:pointer;">保存全部</button>
  389. </div>
  390. </div>
  391. </details>
  392. <details class="memory-section">
  393. <summary>当前任务</summary>
  394. ${Object.keys(m.current_task).length > 0
  395. ? Object.entries(m.current_task).map(([k, v]) => `
  396. <div class="memory-item"><p><strong>${escapeHtml(k)}:</strong> ${escapeHtml(v)}</p></div>
  397. `).join('')
  398. : '<div class="memory-item"><p>无</p></div>'}
  399. </details>
  400. <details class="memory-section">
  401. <summary>查询历史</summary>
  402. ${m.entity_history.map(e => `
  403. <div class="memory-item">
  404. <p><strong>${escapeHtml(e.type)}:</strong> ${escapeHtml(e.query)}</p>
  405. <div class="timestamp">${escapeHtml(e.timestamp)}</div>
  406. </div>
  407. `).join('') || '<div class="memory-item"><p>无</p></div>'}
  408. </details>
  409. `;
  410. } catch (err) {
  411. console.error('Failed to load memory:', err);
  412. }
  413. }
  414. // 【清空分支 1 → 2 → 3】用户确认 → 请求清空后端记忆 → 更新聊天区并重新加载侧栏。
  415. async function clearMemory() {
  416. // 只有浏览器 confirm 返回 true 才发送清理请求;取消会 return,不改变当前内容。
  417. // await fetch 后先替换聊天区提示,再调用 loadMemory 刷新侧栏,这两个显示区域分别更新。
  418. if (!confirm('确定要清空所有记忆吗?')) return;
  419. try {
  420. await fetch('/api/memory/clear', { method: 'POST' });
  421. chatMessages.innerHTML = '<div class="loading">记忆已清空,欢迎体验智能对话!</div>';
  422. loadMemory();
  423. } catch (err) {
  424. console.error('Failed to clear memory:', err);
  425. }
  426. }
  427. // ========== 用户偏好编辑 ==========
  428. // 【偏好编辑 1a】仅在网页增加一行,暂不写后端;点击“保存全部”才提交。
  429. function addProfileRow() {
  430. // 仅向网页中增加一组键/值输入框;输入框还不是后端偏好。
  431. // insertBefore 把新行放在最后的操作按钮区域之前,随后 focus 让用户直接输入键名。
  432. const editor = document.getElementById('profileEditor');
  433. const row = document.createElement('div');
  434. row.className = 'memory-item';
  435. row.style.cssText = 'display:flex;gap:8px;align-items:center;';
  436. row.innerHTML = `
  437. <input type="text" class="profile-key" placeholder="键名" style="flex:1;padding:4px 8px;border-radius:4px;border:1px solid #0f3460;background:#1a1a2e;color:#e0e0e0;font-size:12px;">
  438. <input type="text" class="profile-value" placeholder="值" style="flex:1;padding:4px 8px;border-radius:4px;border:1px solid #0f3460;background:#1a1a2e;color:#e0e0e0;font-size:12px;">
  439. <button onclick="removeProfileItem(this)" style="padding:2px 8px;border-radius:4px;border:1px solid #e94560;background:transparent;color:#e94560;font-size:12px;cursor:pointer;">&times;</button>
  440. `;
  441. editor.insertBefore(row, editor.lastElementChild);
  442. row.querySelector('.profile-key').focus();
  443. }
  444. // 【偏好编辑 1b】仅移除网页这一行,后续完整保存时才同步删除后端对应偏好。
  445. function removeProfileItem(btn) {
  446. // btn 来自 onclick 中的 this,指被点击的删除按钮;parentElement 是它所在的偏好整行。
  447. // 删除 DOM 行后,saveProfile 再收集表单时就不会包含它。
  448. btn.parentElement.remove();
  449. }
  450. // 【偏好编辑 2】收集非空键和值组成 profile;相同键后出现的值覆盖先出现的值。
  451. async function saveProfile() {
  452. // 先收集界面上的键和值,再整体提交到后端;删除行、添加行都在这一步一起保存。
  453. // keys 与 values 按 DOM 顺序对应,使用相同下标 i 取出同一行的键和值。
  454. const editor = document.getElementById('profileEditor');
  455. const keys = editor.querySelectorAll('.profile-key');
  456. const values = editor.querySelectorAll('.profile-value');
  457. const profile = {};
  458. for (let i = 0; i < keys.length; i++) {
  459. const k = keys[i].value.trim();
  460. const v = values[i].value.trim();
  461. // 键和值都非空才加入提交对象;空行被跳过,重名键由后出现的值覆盖。
  462. if (k && v) profile[k] = v;
  463. }
  464. try {
  465. // 【偏好编辑 3】replace: true 表示整体替换,确保网页已删除的偏好不会留在后端。
  466. const response = await fetch('/api/memory/profile', {
  467. method: 'POST',
  468. headers: { 'Content-Type': 'application/json' },
  469. body: JSON.stringify({ profile, replace: true })
  470. });
  471. // 这里明确检查 HTTP 状态。非成功状态主动 throw,进入下方 catch 显示保存失败提示。
  472. if (!response.ok) throw new Error('保存偏好失败,请重试');
  473. // 刷新侧边栏显示
  474. loadMemory();
  475. } catch (err) {
  476. console.error('Failed to save profile:', err);
  477. alert('保存偏好失败,请重试。');
  478. }
  479. }
  480. // 【页面初始化 3】发起两个独立请求;函数没有相互 await,因此完成顺序不固定。
  481. // 脚本加载到末尾才真正发起初始化请求。前面 function 声明定义行为,但不会自动执行函数体。
  482. // 这里两次调用都未 await,分别发起后继续运行;各自结果到达时再更新对应侧栏。
  483. loadAgents();
  484. loadMemory();
  485. </script>
  486. </body>
  487. </html>