const state = { history: [], lastTables: [], liveTrace: [], selectedToolName: "" }; 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、数据库只读查询或 DeepSeek,再把结果整理成结论、依据和表格。" ); bindEvents(); refreshStatus(); } function bindEvents() { els.form.addEventListener("submit", (event) => { event.preventDefault(); sendMessage(); }); els.input.addEventListener("input", () => { updateInputCount(); }); els.input.addEventListener("keydown", (event) => { if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) { event.preventDefault(); sendMessage(); } }); if (els.refreshBtn) { 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 || {}; if (els.mcpEndpoint) { els.mcpEndpoint.textContent = mcp.path ? `${mcp.name || "mcp"} ${mcp.path}` : "-"; } if (els.toolCount) { 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 = ""; updateInputCount(); state.liveTrace = []; setBusy(true); const progressId = addProgressMessage("正在分析这个需求", "我会先判断是否需要查业务系统、数据库或外部工具。"); try { let result; try { result = await streamChat(message, state.history.slice(-12), progressId); } catch (streamError) { updateProgressMessage(progressId, "流式连接不可用", "正在切换为普通请求完成本次回答。"); const response = await postJson("/api/chat", { message, history: state.history.slice(-12) }); if (!response.ok) { throw new Error(response.message || "请求失败"); } result = response.result; } removeMessage(progressId); addAssistantMessage(result.content, result.tables || []); renderTrace(result.trace || state.liveTrace, result.toolCalls || []); state.history.push({ role: "user", content: message }); state.history.push({ role: "assistant", content: result.content }); } catch (error) { removeMessage(progressId); addAssistantMessage(`暂时无法完成:${error.message || error}`); } finally { setBusy(false); } } async function streamChat(message, history, progressId) { const response = await fetch("/api/chat/stream", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message, history }) }); if (!response.ok) { const payload = await response.json().catch(() => ({})); throw new Error(payload.message || `HTTP ${response.status}`); } if (!response.body) { throw new Error("当前浏览器不支持流式响应"); } const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; let resultPayload = null; while (true) { const { done, value } = await reader.read(); if (done) { break; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split(/\r?\n/); buffer = lines.pop() || ""; for (const line of lines) { if (!line.trim()) { continue; } const event = JSON.parse(line); if (event.type === "status") { updateProgressMessage(progressId, event.payload?.title || "正在处理", event.payload?.detail || ""); } else if (event.type === "trace") { const trace = event.payload || {}; state.liveTrace.push(trace); updateProgressMessage(progressId, trace.title || "正在处理", trace.detail || ""); renderTrace(state.liveTrace, []); } else if (event.type === "result") { resultPayload = event.payload; } else if (event.type === "error") { throw new Error(event.payload?.message || "流式请求失败"); } } } if (!resultPayload) { throw new Error("没有收到 Agent 结果"); } return resultPayload; } 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 addProgressMessage(title, detail) { const id = `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`; const row = document.createElement("div"); row.className = "message-row assistant"; row.dataset.messageId = id; const avatar = document.createElement("div"); avatar.className = "avatar"; const img = document.createElement("img"); img.src = "/assets/logo.png"; img.alt = ""; avatar.appendChild(img); const bubble = document.createElement("div"); bubble.className = "bubble progress-bubble"; const titleNode = document.createElement("strong"); titleNode.className = "progress-title"; titleNode.textContent = title; const detailNode = document.createElement("span"); detailNode.className = "progress-detail"; detailNode.textContent = detail; const dots = document.createElement("span"); dots.className = "typing"; dots.innerHTML = ""; bubble.append(titleNode, detailNode, dots); row.append(avatar, bubble); els.messages.appendChild(row); els.messages.scrollTop = els.messages.scrollHeight; return id; } function updateProgressMessage(id, title, detail) { const node = document.querySelector(`[data-message-id="${id}"]`); if (!node) { return; } const titleNode = node.querySelector(".progress-title"); const detailNode = node.querySelector(".progress-detail"); if (titleNode && title) { titleNode.textContent = title; } if (detailNode) { detailNode.textContent = detail || ""; } els.messages.scrollTop = els.messages.scrollHeight; } function removeMessage(id) { const node = document.querySelector(`[data-message-id="${id}"]`); node?.remove(); } function appendMessage({ id = "", role, content, tables = [] }) { 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"; 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("button"); item.type = "button"; item.className = "tool-item"; item.dataset.toolName = tool.name; item.title = "点击后将工具调用意图填入输入框"; if (state.selectedToolName === tool.name) { item.classList.add("active"); } const title = document.createElement("strong"); title.textContent = tool.title || tool.name; const desc = document.createElement("span"); desc.textContent = `${tool.name}${tool.description ? ` - ${tool.description}` : ""}`; const params = document.createElement("small"); params.textContent = summarizeToolParams(tool); item.append(title, desc, params); item.addEventListener("click", () => selectTool(tool)); els.toolList.appendChild(item); } } function selectTool(tool) { state.selectedToolName = tool.name; els.toolList.querySelectorAll(".tool-item").forEach((node) => { node.classList.toggle("active", node.dataset.toolName === tool.name); }); els.input.value = buildToolPrompt(tool); updateInputCount(); els.input.focus(); addAssistantMessage(buildToolSelectionMessage(tool)); } function summarizeToolParams(tool) { const schema = tool.inputSchema || {}; const properties = schema.properties || {}; const keys = Object.keys(properties); if (!keys.length) { return "无参数,点击后可直接发送"; } const required = new Set(schema.required || []); const requiredKeys = keys.filter((key) => required.has(key)); const optionalKeys = keys.filter((key) => !required.has(key)); const parts = []; if (requiredKeys.length) { parts.push(`必填:${requiredKeys.join("、")}`); } if (optionalKeys.length) { parts.push(`可选:${optionalKeys.join("、")}`); } return parts.join(";"); } function buildToolPrompt(tool) { const schema = tool.inputSchema || {}; const properties = schema.properties || {}; const keys = Object.keys(properties); const title = tool.title || tool.name; if (!keys.length) { return `使用「${title}」`; } const required = new Set(schema.required || []); const params = keys.map((key) => { const label = required.has(key) ? "必填" : "可选"; const description = properties[key]?.description || ""; return `${key}(${label}:${description})=`; }); return `使用「${title}」,${params.join(";")}`; } function buildToolSelectionMessage(tool) { const schema = tool.inputSchema || {}; const properties = schema.properties || {}; const keys = Object.keys(properties); const title = tool.title || tool.name; const lines = [ `已选择工具:${title}`, tool.description || "", `工具名:${tool.name}` ].filter(Boolean); if (keys.length) { const required = new Set(schema.required || []); const params = keys.map((key) => { const label = required.has(key) ? "必填" : "可选"; const description = properties[key]?.description || properties[key]?.type || ""; return `- ${key}(${label}):${description}`; }); lines.push(`参数:\n${params.join("\n")}`); lines.push("我已把参数模板放到输入框,补齐后发送即可。"); } else { lines.push("这个工具不需要参数,我已把可直接发送的指令放到输入框。"); } return lines.join("\n\n"); } function updateInputCount() { els.inputCount.textContent = `${els.input.value.trim().length} 字`; } 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; }