| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305 |
- 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: '<span class="typing">正在处理 <i></i><i></i><i></i></span>',
- 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;
- }
|