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;
}