const state = { history: [], lastTables: [] }; const els = { messages: document.querySelector("#messages"), form: document.querySelector("#chatForm"), input: document.querySelector("#messageInput"), inputCount: document.querySelector("#inputCount"), sendBtn: document.querySelector("#sendBtn"), statusDot: document.querySelector("#statusDot"), sessionStatus: document.querySelector("#sessionStatus"), mcpStatus: document.querySelector("#mcpStatus"), mcpEndpoint: document.querySelector("#mcpEndpoint"), toolCount: document.querySelector("#toolCount"), toolList: document.querySelector("#toolList"), traceList: document.querySelector("#traceList"), refreshBtn: document.querySelector("#refreshBtn") }; boot(); function boot() { addAssistantMessage( "你好,我是市民请集合智能助手 Web 测试版。你可以直接问业务系统里的订单、商品、会员、月结等问题,我会通过 MCP 工具查询后再回答。" ); bindEvents(); refreshStatus(); } function bindEvents() { els.form.addEventListener("submit", (event) => { event.preventDefault(); sendMessage(); }); els.input.addEventListener("input", () => { els.inputCount.textContent = `${els.input.value.trim().length} 字`; }); els.input.addEventListener("keydown", (event) => { if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) { event.preventDefault(); sendMessage(); } }); els.refreshBtn.addEventListener("click", refreshStatus); document.querySelector("[data-scroll-tools]").addEventListener("click", () => { document.querySelector("#toolsPanel").scrollIntoView({ behavior: "smooth" }); }); document.querySelector("[data-scroll-trace]").addEventListener("click", () => { document.querySelector("#tracePanel").scrollIntoView({ behavior: "smooth" }); }); } async function refreshStatus() { setStatus("checking", "检查中", "MCP 连接检测中"); try { const health = await getJson("/api/health"); const tools = await getJson("/api/tools"); const mcp = health.mcp || {}; els.mcpEndpoint.textContent = mcp.path ? `${mcp.name || "mcp"} ${mcp.path}` : "-"; els.toolCount.textContent = `${tools.tools?.length || 0} 个`; renderTools(tools.tools || []); if (health.ok) { setStatus("ok", "已连接", `${mcp.environmentName || "MCP"} 可用`); } else { setStatus("error", "未连接", mcp.message || "MCP 不可用"); } } catch (error) { setStatus("error", "未连接", error.message || "状态检查失败"); } } function setStatus(mode, title, detail) { els.statusDot.classList.toggle("ok", mode === "ok"); els.statusDot.classList.toggle("error", mode === "error"); els.sessionStatus.textContent = title; els.mcpStatus.textContent = detail; } async function sendMessage() { const message = els.input.value.trim(); if (!message) { return; } addUserMessage(message); els.input.value = ""; els.inputCount.textContent = "0 字"; setBusy(true); const thinkingId = addThinkingMessage(); try { const response = await postJson("/api/chat", { message, history: state.history.slice(-12) }); removeMessage(thinkingId); if (!response.ok) { throw new Error(response.message || "请求失败"); } const result = response.result; addAssistantMessage(result.content, result.tables || []); renderTrace(result.trace || [], result.toolCalls || []); state.history.push({ role: "user", content: message }); state.history.push({ role: "assistant", content: result.content }); } catch (error) { removeMessage(thinkingId); addAssistantMessage(`暂时无法完成:${error.message || error}`); } finally { setBusy(false); } } function setBusy(busy) { els.sendBtn.disabled = busy; els.sendBtn.querySelector(".send-text").textContent = busy ? "处理中" : "发送"; } function addUserMessage(content) { appendMessage({ role: "user", content }); } function addAssistantMessage(content, tables = []) { appendMessage({ role: "assistant", content, tables }); } function addThinkingMessage() { const id = `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`; appendMessage({ id, role: "assistant", content: '正在处理 ', trustedHtml: true }); return id; } function removeMessage(id) { const node = document.querySelector(`[data-message-id="${id}"]`); node?.remove(); } function appendMessage({ id = "", role, content, tables = [], trustedHtml = false }) { const row = document.createElement("div"); row.className = `message-row ${role}`; if (id) { row.dataset.messageId = id; } const avatar = document.createElement("div"); avatar.className = "avatar"; if (role === "assistant") { const img = document.createElement("img"); img.src = "/assets/logo.png"; img.alt = ""; avatar.appendChild(img); } else { avatar.textContent = "我"; } const bubble = document.createElement("div"); bubble.className = "bubble"; if (trustedHtml) { bubble.innerHTML = content; } else { bubble.textContent = content; } if (tables.length) { state.lastTables = tables; tables.forEach((table, index) => { bubble.appendChild(renderTable(table, index)); }); } row.append(avatar, bubble); els.messages.appendChild(row); els.messages.scrollTop = els.messages.scrollHeight; } function renderTable(table, index) { const card = document.createElement("div"); card.className = "table-card"; const head = document.createElement("div"); head.className = "table-head"; const title = document.createElement("strong"); title.textContent = table.title || "结果表格"; const download = document.createElement("button"); download.type = "button"; download.textContent = "下载 CSV"; download.addEventListener("click", () => downloadCsv(table, index)); head.append(title, download); const wrap = document.createElement("div"); wrap.className = "table-wrap"; const el = document.createElement("table"); const thead = document.createElement("thead"); const tr = document.createElement("tr"); for (const column of table.columns || []) { const th = document.createElement("th"); th.textContent = column; tr.appendChild(th); } thead.appendChild(tr); el.appendChild(thead); const tbody = document.createElement("tbody"); for (const row of table.rows || []) { const bodyTr = document.createElement("tr"); for (const column of table.columns || []) { const td = document.createElement("td"); td.textContent = row[column] ?? ""; bodyTr.appendChild(td); } tbody.appendChild(bodyTr); } el.appendChild(tbody); wrap.appendChild(el); card.append(head, wrap); return card; } function downloadCsv(table, index) { const rows = [ table.columns, ...(table.rows || []).map((row) => table.columns.map((column) => row[column] ?? "")) ]; const csv = rows.map((row) => row.map(csvCell).join(",")).join("\r\n"); const blob = new Blob(["\ufeff", csv], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${safeFileName(table.title || "agent-table")}-${index + 1}.csv`; link.click(); URL.revokeObjectURL(url); } function csvCell(value) { const text = String(value ?? ""); return `"${text.replace(/"/g, '""')}"`; } function safeFileName(value) { return String(value).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 60); } function renderTools(tools) { els.toolList.innerHTML = ""; for (const tool of tools) { const item = document.createElement("div"); item.className = "tool-item"; const title = document.createElement("strong"); title.textContent = tool.title || tool.name; const desc = document.createElement("span"); desc.textContent = `${tool.name}${tool.description ? ` · ${tool.description}` : ""}`; item.append(title, desc); els.toolList.appendChild(item); } } function renderTrace(trace, toolCalls) { els.traceList.innerHTML = ""; const rows = trace.length ? trace : toolCalls.map((item) => ({ phase: item.ok ? "tool" : "error", title: item.name, detail: item.error || `${item.durationMs || 0}ms` })); for (const item of rows.slice(-12).reverse()) { const node = document.createElement("div"); node.className = `trace-item phase-${item.phase}`; const title = document.createElement("strong"); title.textContent = item.title || item.phase; const detail = document.createElement("span"); detail.textContent = item.detail || item.at || ""; node.append(title, detail); els.traceList.appendChild(node); } } async function getJson(url) { const response = await fetch(url); const payload = await response.json(); if (!response.ok) { throw new Error(payload.message || `HTTP ${response.status}`); } return payload; } async function postJson(url, body) { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); const payload = await response.json(); if (!response.ok) { throw new Error(payload.message || `HTTP ${response.status}`); } return payload; }