| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>基于A2A的SmartBizTrip企业差旅助手</title>
- <!-- CSS 控制颜色、布局和动画;不会发起业务请求。实际调用逻辑在页面末尾的 script。 -->
- <style>
- :root {
- color-scheme: light;
- --blue: #2563eb;
- --blue-dark: #1d4ed8;
- --text: #1e293b;
- --muted: #64748b;
- --line: #e5eaf1;
- --canvas: #f5f7fb;
- }
- * { margin: 0; padding: 0; box-sizing: border-box; }
- body {
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
- background: var(--canvas); color: var(--text);
- height: 100vh; height: 100dvh; display: flex; flex-direction: column;
- -webkit-font-smoothing: antialiased;
- }
- button, input { font: inherit; }
- button { cursor: pointer; transition: background .18s, border-color .18s, box-shadow .18s; }
- button:focus-visible, summary:focus-visible { outline: 3px solid #93b4ff; outline-offset: 3px; }
- .header {
- min-height: 78px; padding: 16px 32px; background: #fff;
- border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 14px;
- }
- .brand-mark {
- width: 42px; height: 42px; flex: 0 0 42px; display: grid; place-items: center;
- border-radius: 12px; background: var(--blue); color: #fff;
- box-shadow: 0 4px 10px #2563eb20;
- }
- .header h1 { font-size: 21px; font-weight: 700; letter-spacing: -.6px; }
- .header p { font-size: 12px; color: var(--muted); margin-top: 3px; }
- .header-label { margin-left: auto; font-size: 12px; color: var(--muted); border-left: 1px solid var(--line); padding-left: 20px; }
- .main { display: flex; flex: 1; min-height: 0; gap: 22px; padding: 24px 28px 16px; overflow: hidden; }
- .chat-area {
- flex: 2; min-width: 0; display: flex; flex-direction: column; background: #fff;
- border: 1px solid var(--line); border-radius: 16px; overflow: hidden;
- box-shadow: 0 3px 16px #1e293b04;
- }
- .chat-heading { padding: 20px 26px; border-bottom: 1px solid var(--line); }
- .chat-heading h2 { font-size: 16px; font-weight: 600; }
- .chat-heading p { font-size: 12px; color: var(--muted); margin-top: 6px; }
- .chat-messages { flex: 1; min-height: 0; overflow-y: auto; padding: 28px; }
- .chat-messages > .loading {
- max-width: 500px; margin: clamp(28px, 10vh, 100px) auto 0; padding: 28px 20px;
- line-height: 1.8; color: var(--muted); font-size: 14px;
- }
- .chat-messages > .loading::before {
- content: "开启高效差旅"; display: block; color: var(--text); font-size: 27px;
- font-weight: 650; letter-spacing: -.5px; margin-bottom: 12px;
- }
- .chat-messages > .loading::after {
- content: "天气查询 · 交通票务 · 商务用车 · 会务安排";
- display: block; font-size: 12px; color: var(--muted); margin-top: 24px;
- padding: 14px 8px; border-top: 1px solid var(--line);
- }
- .message { margin-bottom: 24px; display: flex; gap: 10px; align-items: flex-start; }
- .message.user { justify-content: flex-end; }
- .message.assistant { justify-content: flex-start; }
- .message.assistant::before {
- content: "S"; flex: 0 0 30px; height: 30px; border-radius: 9px;
- display: grid; place-items: center; color: var(--blue); background: #edf3ff;
- font-size: 14px; font-weight: 700; margin-top: 3px;
- }
- .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; }
- .message.user .message-bubble { background: var(--blue); color: #fff; border-bottom-right-radius: 4px; }
- .message.assistant .message-bubble { background: #f6f8fc; color: var(--text); border: 1px solid #edf0f5; border-top-left-radius: 4px; }
- .chat-input-area { padding: 20px 24px; background: #fff; border-top: 1px solid var(--line); display: flex; gap: 12px; }
- .chat-input-area input {
- flex: 1; min-width: 0; padding: 14px 16px; border-radius: 10px;
- border: 1px solid #dce3ed; background: #fafbfd; color: var(--text); font-size: 14px; outline: none;
- transition: border-color .18s, box-shadow .18s;
- }
- input::placeholder { color: #78869a; }
- .chat-input-area input:focus { border-color: var(--blue); background: #fff; box-shadow: 0 0 0 3px #2563eb12; }
- .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; }
- .chat-input-area button:hover { background: var(--blue-dark); }
- .chat-input-area button:disabled { background: #a9bee8; border-color: #a9bee8; cursor: not-allowed; }
- .sidebar { flex: 1; min-width: 290px; max-width: 360px; overflow-y: auto; padding: 2px 5px 10px 0; }
- .section-title { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 650; color: #475569; margin: 2px 0 13px; }
- .section-title::before { content: ""; width: 3px; height: 13px; border-radius: 3px; background: var(--blue); }
- .agent-card { background: #fff; border-radius: 12px; padding: 16px 18px; margin-bottom: 10px; border: 1px solid var(--line); }
- .agent-card h3 { font-size: 13px; font-weight: 650; color: var(--text); margin-bottom: 7px; overflow-wrap: anywhere; }
- .agent-card p { font-size: 12px; line-height: 1.7; color: var(--muted); margin-bottom: 7px; }
- .agent-card .skill { font-size: 12px; line-height: 1.6; color: #526176; padding: 2px 0; }
- .agent-card .status { display: inline-block; font-size: 11px; color: #17714b; background: #edf8f1; border-radius: 5px; padding: 3px 7px; margin-top: 9px; }
- .memory-section { margin-bottom: 10px; border: 1px solid var(--line); border-radius: 10px; background: #fff; padding: 0 14px; }
- .memory-section summary { cursor: pointer; font-size: 12px; font-weight: 600; color: #475569; padding: 14px 0; }
- .memory-section summary:hover { color: var(--blue); }
- .memory-item { background: #f8fafc; border-radius: 7px; padding: 10px; margin-bottom: 9px; border: 1px solid #edf1f6; overflow-wrap: anywhere; }
- .memory-item p { font-size: 12px; line-height: 1.75; color: #526176; margin-bottom: 4px; white-space: pre-wrap; }
- .memory-item .timestamp { font-size: 10px; color: var(--muted); }
- /* Preference rows are generated with inline styles; override their presentation only. */
- #profileEditor .memory-item { padding: 8px; gap: 6px !important; }
- #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; }
- #profileEditor input:focus { border-color: var(--blue) !important; box-shadow: 0 0 0 2px #2563eb12; }
- #profileEditor button { color: var(--blue) !important; border-color: #ccdafa !important; border-radius: 6px !important; min-height: 30px; }
- #profileEditor button:hover { background: #eff5ff !important; }
- #profileEditor .memory-item button { flex-shrink: 0; color: #64748b !important; border-color: #dce3ed !important; }
- .clear-btn { width: 100%; padding: 10px; margin: 3px 0 24px; border-radius: 8px; border: 1px solid #dce3ed; background: #fff; color: #526176; font-size: 12px; }
- .clear-btn:hover { background: #eff5ff; border-color: #b9cff8; color: var(--blue); }
- .loading { color: var(--muted); font-size: 13px; text-align: center; padding: 12px; }
- .typing-indicator { display: inline-flex; gap: 4px; align-items: center; }
- .typing-indicator span { width: 5px; height: 5px; background: var(--blue); border-radius: 50%; animation: blink 1.4s infinite both; }
- .typing-indicator span:nth-child(2) { animation-delay: .2s; }
- .typing-indicator span:nth-child(3) { animation-delay: .4s; }
- @keyframes blink { 0%, 80%, 100% { opacity: .2; } 40% { opacity: 1; } }
- .cursor { display: inline-block; width: 2px; height: 1em; background: var(--blue); margin-left: 2px; animation: cursor-blink .8s infinite; vertical-align: text-bottom; }
- @keyframes cursor-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
- .footer { text-align: center; padding: 0 16px 12px; font-size: 11px; color: var(--muted); }
- ::-webkit-scrollbar { width: 5px; height: 5px; }
- ::-webkit-scrollbar-track { background: transparent; }
- ::-webkit-scrollbar-thumb { background: #ccd5e2; border-radius: 4px; }
- @media (max-width: 1000px) {
- .main { padding: 16px; gap: 16px; }
- .sidebar { min-width: 260px; max-width: 300px; }
- .chat-messages { padding: 20px; }
- .chat-input-area { padding: 16px; }
- }
- @media (max-width: 680px) {
- body { height: auto; min-height: 100dvh; }
- .header { padding: 14px 16px; }
- .header-label { display: none; }
- .main { flex-direction: column; overflow: visible; padding: 12px; gap: 20px; }
- .chat-area { flex: none; height: 72dvh; min-height: 420px; }
- .chat-heading { padding: 16px; }
- .chat-messages { padding: 16px; }
- .chat-messages > .loading { margin-top: 24px; padding: 20px 0; }
- .chat-messages > .loading::before { font-size: 23px; }
- .sidebar { flex: none; width: 100%; max-width: none; min-width: 0; overflow: visible; padding: 0; }
- .message-bubble { max-width: 85%; }
- .message.assistant .message-bubble { max-width: calc(100% - 40px); }
- .chat-input-area { padding: 12px; gap: 8px; }
- .chat-input-area button { padding: 12px 18px; }
- .footer { padding-top: 8px; }
- }
- @media (prefers-reduced-motion: reduce) {
- *, *::before, *::after { transition: none !important; animation: none !important; }
- }
- </style>
- </head>
- <body>
- <div class="header">
- <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>
- <div><h1>SmartBizTrip</h1><p>企业差旅智能助手</p></div>
- <span class="header-label">企业差旅工作台</span>
- </div>
- <div class="main">
- <!-- 页面结构 1:消息列表和输入区;发送按钮与 Enter 键都调用 sendMessage。 -->
- <div class="chat-area">
- <div class="chat-heading"><h2>差旅助手</h2><p>从出行查询到会务安排,让差旅准备更轻松</p></div>
- <div class="chat-messages" id="chatMessages">
- <div class="loading">欢迎体验智能对话!输入问题即可开始</div>
- </div>
- <div class="chat-input-area">
- <input type="text" id="chatInput" aria-label="输入差旅问题" placeholder="描述您的差旅需求,按 Enter 发送…" autocomplete="off">
- <button id="sendBtn" onclick="sendMessage()">发送</button>
- </div>
- </div>
- <!-- 页面结构 2:代理卡片和记忆侧栏;数据由下方脚本调用 API 后填入。 -->
- <div class="sidebar">
- <div class="section-title">助手能力</div>
- <div id="agentCards"></div>
- <button class="clear-btn" onclick="clearMemory()">清空记忆</button>
- <div class="section-title">对话记忆</div>
- <div id="memoryPanel">
- <div class="loading">加载中...</div>
- </div>
- </div>
- </div>
- <div class="footer">Powered by 企业差旅中心 | 基于Agent2Agent的企业差旅助手系统 v7.5</div>
- <script>
- // 阅读顺序对应 api_server.py 顶部:本页负责主流程 1(发送)和 12(结束接收)。
- // 后端第 10 步生成片段时,本页同时读取并刷新气泡;第 11 步保存记忆后才结束响应。
- // 【页面初始化 1】脚本位于 HTML 元素之后,此时可以按 id 取得消息区、输入框和按钮。
- const chatMessages = document.getElementById('chatMessages');
- // 变量保存 DOM 元素的引用,后面修改 chatInput.value 或 sendBtn.disabled 会作用到页面上的同一个元素。
- // getElementById 中的名称需要与上方 HTML 的 id 保持一致,不是访问后端变量。
- const chatInput = document.getElementById('chatInput');
- const sendBtn = document.getElementById('sendBtn');
- // 【页面初始化 2】绑定键盘事件;这里只注册回调,按下 Enter 时才调用 sendMessage。
- chatInput.addEventListener('keydown', (e) => {
- // e 是浏览器提供的键盘事件;只有按 Enter 且没同时按 Shift,才进入发送分支。
- // 注册监听器时不会执行此分支,它会在后续按键事件到来时触发。
- if (e.key === 'Enter' && !e.shiftKey) {
- // 阻止本次按键的默认行为,然后复用发送按钮也在调用的 sendMessage。
- e.preventDefault();
- sendMessage();
- }
- });
- // 【界面辅助】创建消息气泡并返回 bubble,供 sendMessage 后续持续更新内容。
- function addMessage(role, content) {
- // 调用者:发送消息、收到响应或展示错误的代码。role 决定气泡样式,content 是初始显示文本。
- // 返回值是新创建的 bubble 元素;sendMessage 保存这个引用,以后把新片段更新到同一个气泡。
- const loading = chatMessages.querySelector('.loading');
- // 只有找到欢迎/加载提示时才删除;已有聊天消息时查不到该元素,不影响继续创建气泡。
- if (loading) loading.remove();
- const div = document.createElement('div');
- div.className = `message ${role}`;
- const bubble = document.createElement('div');
- bubble.className = 'message-bubble';
- bubble.innerHTML = escapeHtml(content);
- // 先把 bubble 放进外层 message div,再把外层 div 放进 chatMessages,元素才真正出现在页面上。
- div.appendChild(bubble);
- chatMessages.appendChild(div);
- chatMessages.scrollTop = chatMessages.scrollHeight;
- return bubble;
- }
- // 【界面辅助】发送后显示等待提示,收到响应或遇到错误时由 hideTyping 移除。
- function showTyping() {
- // 创建一条临时助手消息,固定 id 为 typingIndicator,方便 hideTyping 精确找到并移除。
- // 这只是“等待响应”的界面提示,与模型是否正在推理或工具是否已经运行不是同一个状态。
- const div = document.createElement('div');
- div.className = 'message assistant';
- div.id = 'typingIndicator';
- div.innerHTML = `<div class="message-bubble"><div class="typing-indicator"><span></span><span></span><span></span></div> 思考中,请等待...</div>`;
- chatMessages.appendChild(div);
- chatMessages.scrollTop = chatMessages.scrollHeight;
- }
- // 【界面辅助】移除等待提示;没找到提示元素时不做处理。
- function hideTyping() {
- // 从 DOM 查找临时提示,存在才 remove;重复调用也不会因为元素已不存在而报错。
- const indicator = document.getElementById('typingIndicator');
- if (indicator) indicator.remove();
- }
- // 【界面辅助】先按文本写入临时元素,再读取转义后的 HTML,避免把回复文本当作标签解析。
- function escapeHtml(text) {
- // 先把 text 写到一个临时 div 的 textContent,浏览器按文本处理,再读取转义后的 innerHTML。
- // 例如回复中的 <b> 会作为文本显示;真正使用 innerHTML 的地方仍可自行追加光标等界面标记。
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
- }
- async function sendMessage() {
- // 入口有两处:点击发送按钮,或按 Enter。它不是页面加载时自动执行的聊天请求。
- // 局部 fullText 保存这次助手回复,reader/decoder/buffer 只服务于本次响应流。
- // await 会等待异步结果再执行下一行;期间浏览器仍能处理其他事件,并不是暂停整个页面。
- // 【主流程 1.1】读取输入,空文本直接结束;随后显示用户消息并禁用发送按钮。
- const text = chatInput.value.trim();
- // trim 后空串会提前结束本次函数,所以不会清空输入框、显示等待提示或发送请求。
- if (!text || sendBtn.disabled) return;
- // 把用户刚提交的文本留在局部 text 中,再清空输入框;后续 POST 仍发送原 text。
- // 上面的禁用状态检查同时阻止按 Enter 重复提交尚未结束的请求。
- chatInput.value = '';
- sendBtn.disabled = true;
- addMessage('user', text);
- showTyping();
- let fullText = '';
- try {
- // 【主流程 1.2 → 2】把消息作为 JSON POST 到 API。fetch 返回响应后再继续读取响应体。
- // 这里使用 fetch 的流读取能力消费 SSE 格式文本,不是创建 EventSource。
- const response = await fetch('/api/chat/stream', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- // 前端对象转换为 JSON 请求体,字段 message 对应后端 ChatRequest.message。
- // Content-Type 指定请求体格式;它与后端响应的 text/event-stream 是两个方向的类型。
- body: JSON.stringify({ message: text })
- });
- if (!response.ok) throw new Error('聊天请求失败');
- // 收到 HTTP 响应对象后先撤下等待提示,再创建空助手气泡;此时未必已经拿到回复正文片段。
- hideTyping();
- const bubble = addMessage('assistant', '');
- // 【返回过程 10 → 网页】取得字节流读取器;TextDecoder 把 UTF-8 字节转换为文字。
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- // buffer 是未处理完的一行 SSE 文本,fullText 是已解析出的助手回复,二者不能混用。
- // 例如本次网络块只收到 data: {"chu,先暂存,等下个网络块补齐再解析。
- let buffer = '';
- // 【接收内部步骤 1】循环等待新的网络字节块;字节块不保证刚好是一个完整 SSE 事件。
- while (true) {
- // 【读流细节 1】解构本次读取结果:done 表示流结束,value 是本次收到的字节数组。
- // 一次读取可能含多个事件,也可能只有半个事件;不能直接把 value 当成 JSON 字符串。
- const { done, value } = await reader.read();
- // 【读流细节 2】break 只退出当前 while,随后继续执行去光标和 finally 等代码;不是退出整个 sendMessage。
- if (done) break;
- // 【接收内部步骤 2】保留未读完的字节和文本:decoder 处理跨块的中文字符,buffer 保留未结束的一行。
- // 本后端把每个事件写为一行 data: JSON,再跟一个空行,因此这里按行解析。
- buffer += decoder.decode(value, { stream: true });
- // 【读流细节 3】按换行拆出已接收文本。最后一项可能不完整,下一行将它从 lines 取走并继续缓存。
- // 如果刚好以换行结尾,最后一项就是空串,buffer 也会恢复为空。
- const lines = buffer.split('\n');
- // pop 会从数组移除最后一项并返回它;因此下方 for 只处理前面完整的行。
- // || 在取不到内容时使用空串,方便下一次用 += 接续新文本。
- buffer = lines.pop() || '';
- // 【读流细节 4】逐行解析一批网络数据,内层 for 结束后回到外层 while 继续读取下一块。
- for (const line of lines) {
- const trimmed = line.trim();
- // continue 只跳过当前这一行,继续检查下一行;空行和非 data 行都不会成为聊天内容。
- if (!trimmed.startsWith('data: ')) continue;
- // 从第 6 个字符开始保留,去掉前缀 data: 加一个空格;得到 JSON 文本或 [DONE] 标记。
- const data = trimmed.slice(6);
- // 【主流程 12.2】结束标记不显示在气泡中;当前实现跳过它,直到 reader.read() 的 done 为 true 才退出。
- if (data === '[DONE]') continue;
- try {
- // 【接收内部步骤 3】解析后端 {chunk: ...},累计成全文并重新绘制气泡;不是每次创建新消息。
- const parsed = JSON.parse(data);
- // 【读流细节 5】chunk 非空才更新显示;空片段被忽略,继续等后续内容。
- // 这里收到的是 API 封装后的对象,不再是 A2A Task 或数据库结果。
- if (parsed.chunk) {
- // 把新片段追加到已有正文末尾;例如“北京”加“明天晴”变为“北京明天晴”,不插入额外换行。
- fullText += parsed.chunk;
- // 每次用累计全文重绘同一个 bubble,再追加闪烁光标;显示效果像逐步打字。
- bubble.innerHTML = escapeHtml(fullText) + '<span class="cursor"></span>';
- chatMessages.scrollTop = chatMessages.scrollHeight;
- }
- } catch (e) { /* 当前 data 行不是合法 JSON 时跳过它,后续行仍继续解析。 */ }
- }
- }
- // 【主流程 12.3】响应流结束,去掉光标;若没有任何片段则显示空回复提示。
- bubble.innerHTML = escapeHtml(fullText);
- // 流已结束但没有解析出任何 chunk 时显示空回复提示;不把 [DONE] 本身算成正文。
- if (!fullText) {
- bubble.innerHTML = '<span style="color:#888;">(未收到回复)</span>';
- }
- } catch (err) {
- hideTyping();
- // 外层 catch 处理 HTTP 失败状态、网络异常和读流异常。
- addMessage('assistant', '请求失败,请检查网络连接。');
- } finally {
- // 【主流程 12.4】无论成功还是异常,都恢复按钮和输入焦点。
- sendBtn.disabled = false;
- chatInput.focus();
- }
- // 【主流程 12.5】本轮接收结束后刷新记忆侧栏,显示后端保存的对话。
- loadMemory();
- }
- // 【页面初始化 3a】请求代理卡片后渲染侧栏;与 loadMemory 各自异步完成。
- async function loadAgents() {
- // 请求 GET /api/agents,response.json() 解析响应体,data.data 才是卡片列表。
- // map 把每张卡片转换为 HTML 字符串,join('') 合成一段再替换 agentCards 内容。
- try {
- const response = await fetch('/api/agents');
- const data = await response.json();
- const container = document.getElementById('agentCards');
- container.innerHTML = data.data.map(agent => `
- <div class="agent-card">
- <h3>${escapeHtml(agent.name)}</h3>
- <p>${escapeHtml(agent.description)}</p>
- ${agent.skills.map(s => `<div class="skill">• ${escapeHtml(s)}</div>`).join('')}
- <div class="status">● ${escapeHtml(agent.status)}</div>
- </div>
- `).join('');
- } catch (err) {
- console.error('Failed to load agents:', err);
- }
- }
- // 【页面初始化 3b / 主流程 12.5】请求记忆摘要并重建侧栏,包含历史、偏好、当前任务和查询记录。
- async function loadMemory() {
- // 请求 GET /api/memory,m 是后端返回的记忆摘要,不是直接读取数据库。
- // 模板字符串一次重建短期对话、偏好、当前任务和查询历史四块内容;空列表/空字典显示“无”。
- // 这会替换整个 memoryPanel,因此表单行也由返回数据重新创建。
- try {
- const response = await fetch('/api/memory');
- const data = await response.json();
- // 解包项目约定的外层响应 {status, data};后续 m.user_profile 等字段与后端 get_memory_state 一一对应。
- const m = data.data;
- const container = document.getElementById('memoryPanel');
- container.innerHTML = `
- <details class="memory-section" open>
- <summary>短期对话</summary>
- ${m.short_term_messages.map(msg => `
- <div class="memory-item">
- <p><strong>${escapeHtml(msg.role)}:</strong> ${escapeHtml(msg.content)}</p>
- <div class="timestamp">${escapeHtml(msg.timestamp)}</div>
- </div>
- `).join('') || '<div class="memory-item"><p>无</p></div>'}
- </details>
- <details class="memory-section" open>
- <summary>用户偏好</summary>
- <div id="profileEditor">
- ${Object.keys(m.user_profile).length > 0
- ? Object.entries(m.user_profile).map(([k, v]) => `
- <div class="memory-item" style="display:flex;gap:8px;align-items:center;">
- <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;">
- <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;">
- <button onclick="removeProfileItem(this)" style="padding:2px 8px;border-radius:4px;border:1px solid #e94560;background:transparent;color:#e94560;font-size:12px;cursor:pointer;">×</button>
- </div>
- `).join('')
- : ''}
- <div style="display:flex;gap:8px;margin:8px 0;">
- <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>
- <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>
- </div>
- </div>
- </details>
- <details class="memory-section">
- <summary>当前任务</summary>
- ${Object.keys(m.current_task).length > 0
- ? Object.entries(m.current_task).map(([k, v]) => `
- <div class="memory-item"><p><strong>${escapeHtml(k)}:</strong> ${escapeHtml(v)}</p></div>
- `).join('')
- : '<div class="memory-item"><p>无</p></div>'}
- </details>
- <details class="memory-section">
- <summary>查询历史</summary>
- ${m.entity_history.map(e => `
- <div class="memory-item">
- <p><strong>${escapeHtml(e.type)}:</strong> ${escapeHtml(e.query)}</p>
- <div class="timestamp">${escapeHtml(e.timestamp)}</div>
- </div>
- `).join('') || '<div class="memory-item"><p>无</p></div>'}
- </details>
- `;
- } catch (err) {
- console.error('Failed to load memory:', err);
- }
- }
- // 【清空分支 1 → 2 → 3】用户确认 → 请求清空后端记忆 → 更新聊天区并重新加载侧栏。
- async function clearMemory() {
- // 只有浏览器 confirm 返回 true 才发送清理请求;取消会 return,不改变当前内容。
- // await fetch 后先替换聊天区提示,再调用 loadMemory 刷新侧栏,这两个显示区域分别更新。
- if (!confirm('确定要清空所有记忆吗?')) return;
- try {
- await fetch('/api/memory/clear', { method: 'POST' });
- chatMessages.innerHTML = '<div class="loading">记忆已清空,欢迎体验智能对话!</div>';
- loadMemory();
- } catch (err) {
- console.error('Failed to clear memory:', err);
- }
- }
- // ========== 用户偏好编辑 ==========
- // 【偏好编辑 1a】仅在网页增加一行,暂不写后端;点击“保存全部”才提交。
- function addProfileRow() {
- // 仅向网页中增加一组键/值输入框;输入框还不是后端偏好。
- // insertBefore 把新行放在最后的操作按钮区域之前,随后 focus 让用户直接输入键名。
- const editor = document.getElementById('profileEditor');
- const row = document.createElement('div');
- row.className = 'memory-item';
- row.style.cssText = 'display:flex;gap:8px;align-items:center;';
- row.innerHTML = `
- <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;">
- <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;">
- <button onclick="removeProfileItem(this)" style="padding:2px 8px;border-radius:4px;border:1px solid #e94560;background:transparent;color:#e94560;font-size:12px;cursor:pointer;">×</button>
- `;
- editor.insertBefore(row, editor.lastElementChild);
- row.querySelector('.profile-key').focus();
- }
- // 【偏好编辑 1b】仅移除网页这一行,后续完整保存时才同步删除后端对应偏好。
- function removeProfileItem(btn) {
- // btn 来自 onclick 中的 this,指被点击的删除按钮;parentElement 是它所在的偏好整行。
- // 删除 DOM 行后,saveProfile 再收集表单时就不会包含它。
- btn.parentElement.remove();
- }
- // 【偏好编辑 2】收集非空键和值组成 profile;相同键后出现的值覆盖先出现的值。
- async function saveProfile() {
- // 先收集界面上的键和值,再整体提交到后端;删除行、添加行都在这一步一起保存。
- // keys 与 values 按 DOM 顺序对应,使用相同下标 i 取出同一行的键和值。
- const editor = document.getElementById('profileEditor');
- const keys = editor.querySelectorAll('.profile-key');
- const values = editor.querySelectorAll('.profile-value');
- const profile = {};
- for (let i = 0; i < keys.length; i++) {
- const k = keys[i].value.trim();
- const v = values[i].value.trim();
- // 键和值都非空才加入提交对象;空行被跳过,重名键由后出现的值覆盖。
- if (k && v) profile[k] = v;
- }
- try {
- // 【偏好编辑 3】replace: true 表示整体替换,确保网页已删除的偏好不会留在后端。
- const response = await fetch('/api/memory/profile', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ profile, replace: true })
- });
- // 这里明确检查 HTTP 状态。非成功状态主动 throw,进入下方 catch 显示保存失败提示。
- if (!response.ok) throw new Error('保存偏好失败,请重试');
- // 刷新侧边栏显示
- loadMemory();
- } catch (err) {
- console.error('Failed to save profile:', err);
- alert('保存偏好失败,请重试。');
- }
- }
- // 【页面初始化 3】发起两个独立请求;函数没有相互 await,因此完成顺序不固定。
- // 脚本加载到末尾才真正发起初始化请求。前面 function 声明定义行为,但不会自动执行函数体。
- // 这里两次调用都未 await,分别发起后继续运行;各自结果到达时再更新对应侧栏。
- loadAgents();
- loadMemory();
- </script>
- </body>
- </html>
|