| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497 |
- 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 = "<i></i><i></i><i></i>";
- 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;
- }
|