app.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. const state = {
  2. history: [],
  3. lastTables: [],
  4. liveTrace: [],
  5. selectedToolName: ""
  6. };
  7. const els = {
  8. messages: document.querySelector("#messages"),
  9. form: document.querySelector("#chatForm"),
  10. input: document.querySelector("#messageInput"),
  11. inputCount: document.querySelector("#inputCount"),
  12. sendBtn: document.querySelector("#sendBtn"),
  13. statusDot: document.querySelector("#statusDot"),
  14. sessionStatus: document.querySelector("#sessionStatus"),
  15. mcpStatus: document.querySelector("#mcpStatus"),
  16. mcpEndpoint: document.querySelector("#mcpEndpoint"),
  17. toolCount: document.querySelector("#toolCount"),
  18. toolList: document.querySelector("#toolList"),
  19. traceList: document.querySelector("#traceList"),
  20. refreshBtn: document.querySelector("#refreshBtn")
  21. };
  22. boot();
  23. function boot() {
  24. addAssistantMessage(
  25. "你好,我是市民请集合智能助手 Web 测试版。你可以直接问业务系统里的订单、商品、会员、物流、月结等问题;我会先判断是否需要调用 MCP、数据库只读查询或 DeepSeek,再把结果整理成结论、依据和表格。"
  26. );
  27. bindEvents();
  28. refreshStatus();
  29. }
  30. function bindEvents() {
  31. els.form.addEventListener("submit", (event) => {
  32. event.preventDefault();
  33. sendMessage();
  34. });
  35. els.input.addEventListener("input", () => {
  36. updateInputCount();
  37. });
  38. els.input.addEventListener("keydown", (event) => {
  39. if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
  40. event.preventDefault();
  41. sendMessage();
  42. }
  43. });
  44. if (els.refreshBtn) {
  45. els.refreshBtn.addEventListener("click", refreshStatus);
  46. }
  47. document.querySelector("[data-scroll-tools]").addEventListener("click", () => {
  48. document.querySelector("#toolsPanel").scrollIntoView({ behavior: "smooth" });
  49. });
  50. document.querySelector("[data-scroll-trace]").addEventListener("click", () => {
  51. document.querySelector("#tracePanel").scrollIntoView({ behavior: "smooth" });
  52. });
  53. }
  54. async function refreshStatus() {
  55. setStatus("checking", "检查中", "MCP 连接检测中");
  56. try {
  57. const health = await getJson("/api/health");
  58. const tools = await getJson("/api/tools");
  59. const mcp = health.mcp || {};
  60. if (els.mcpEndpoint) {
  61. els.mcpEndpoint.textContent = mcp.path ? `${mcp.name || "mcp"} ${mcp.path}` : "-";
  62. }
  63. if (els.toolCount) {
  64. els.toolCount.textContent = `${tools.tools?.length || 0} 个`;
  65. }
  66. renderTools(tools.tools || []);
  67. if (health.ok) {
  68. setStatus("ok", "已连接", `${mcp.environmentName || "MCP"} 可用`);
  69. } else {
  70. setStatus("error", "未连接", mcp.message || "MCP 不可用");
  71. }
  72. } catch (error) {
  73. setStatus("error", "未连接", error.message || "状态检查失败");
  74. }
  75. }
  76. function setStatus(mode, title, detail) {
  77. els.statusDot.classList.toggle("ok", mode === "ok");
  78. els.statusDot.classList.toggle("error", mode === "error");
  79. els.sessionStatus.textContent = title;
  80. els.mcpStatus.textContent = detail;
  81. }
  82. async function sendMessage() {
  83. const message = els.input.value.trim();
  84. if (!message) {
  85. return;
  86. }
  87. addUserMessage(message);
  88. els.input.value = "";
  89. updateInputCount();
  90. state.liveTrace = [];
  91. setBusy(true);
  92. const progressId = addProgressMessage("正在分析这个需求", "我会先判断是否需要查业务系统、数据库或外部工具。");
  93. try {
  94. let result;
  95. try {
  96. result = await streamChat(message, state.history.slice(-12), progressId);
  97. } catch (streamError) {
  98. updateProgressMessage(progressId, "流式连接不可用", "正在切换为普通请求完成本次回答。");
  99. const response = await postJson("/api/chat", {
  100. message,
  101. history: state.history.slice(-12)
  102. });
  103. if (!response.ok) {
  104. throw new Error(response.message || "请求失败");
  105. }
  106. result = response.result;
  107. }
  108. removeMessage(progressId);
  109. addAssistantMessage(result.content, result.tables || []);
  110. renderTrace(result.trace || state.liveTrace, result.toolCalls || []);
  111. state.history.push({ role: "user", content: message });
  112. state.history.push({ role: "assistant", content: result.content });
  113. } catch (error) {
  114. removeMessage(progressId);
  115. addAssistantMessage(`暂时无法完成:${error.message || error}`);
  116. } finally {
  117. setBusy(false);
  118. }
  119. }
  120. async function streamChat(message, history, progressId) {
  121. const response = await fetch("/api/chat/stream", {
  122. method: "POST",
  123. headers: {
  124. "Content-Type": "application/json"
  125. },
  126. body: JSON.stringify({ message, history })
  127. });
  128. if (!response.ok) {
  129. const payload = await response.json().catch(() => ({}));
  130. throw new Error(payload.message || `HTTP ${response.status}`);
  131. }
  132. if (!response.body) {
  133. throw new Error("当前浏览器不支持流式响应");
  134. }
  135. const reader = response.body.getReader();
  136. const decoder = new TextDecoder("utf-8");
  137. let buffer = "";
  138. let resultPayload = null;
  139. while (true) {
  140. const { done, value } = await reader.read();
  141. if (done) {
  142. break;
  143. }
  144. buffer += decoder.decode(value, { stream: true });
  145. const lines = buffer.split(/\r?\n/);
  146. buffer = lines.pop() || "";
  147. for (const line of lines) {
  148. if (!line.trim()) {
  149. continue;
  150. }
  151. const event = JSON.parse(line);
  152. if (event.type === "status") {
  153. updateProgressMessage(progressId, event.payload?.title || "正在处理", event.payload?.detail || "");
  154. } else if (event.type === "trace") {
  155. const trace = event.payload || {};
  156. state.liveTrace.push(trace);
  157. updateProgressMessage(progressId, trace.title || "正在处理", trace.detail || "");
  158. renderTrace(state.liveTrace, []);
  159. } else if (event.type === "result") {
  160. resultPayload = event.payload;
  161. } else if (event.type === "error") {
  162. throw new Error(event.payload?.message || "流式请求失败");
  163. }
  164. }
  165. }
  166. if (!resultPayload) {
  167. throw new Error("没有收到 Agent 结果");
  168. }
  169. return resultPayload;
  170. }
  171. function setBusy(busy) {
  172. els.sendBtn.disabled = busy;
  173. els.sendBtn.querySelector(".send-text").textContent = busy ? "处理中" : "发送";
  174. }
  175. function addUserMessage(content) {
  176. appendMessage({ role: "user", content });
  177. }
  178. function addAssistantMessage(content, tables = []) {
  179. appendMessage({ role: "assistant", content, tables });
  180. }
  181. function addProgressMessage(title, detail) {
  182. const id = `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  183. const row = document.createElement("div");
  184. row.className = "message-row assistant";
  185. row.dataset.messageId = id;
  186. const avatar = document.createElement("div");
  187. avatar.className = "avatar";
  188. const img = document.createElement("img");
  189. img.src = "/assets/logo.png";
  190. img.alt = "";
  191. avatar.appendChild(img);
  192. const bubble = document.createElement("div");
  193. bubble.className = "bubble progress-bubble";
  194. const titleNode = document.createElement("strong");
  195. titleNode.className = "progress-title";
  196. titleNode.textContent = title;
  197. const detailNode = document.createElement("span");
  198. detailNode.className = "progress-detail";
  199. detailNode.textContent = detail;
  200. const dots = document.createElement("span");
  201. dots.className = "typing";
  202. dots.innerHTML = "<i></i><i></i><i></i>";
  203. bubble.append(titleNode, detailNode, dots);
  204. row.append(avatar, bubble);
  205. els.messages.appendChild(row);
  206. els.messages.scrollTop = els.messages.scrollHeight;
  207. return id;
  208. }
  209. function updateProgressMessage(id, title, detail) {
  210. const node = document.querySelector(`[data-message-id="${id}"]`);
  211. if (!node) {
  212. return;
  213. }
  214. const titleNode = node.querySelector(".progress-title");
  215. const detailNode = node.querySelector(".progress-detail");
  216. if (titleNode && title) {
  217. titleNode.textContent = title;
  218. }
  219. if (detailNode) {
  220. detailNode.textContent = detail || "";
  221. }
  222. els.messages.scrollTop = els.messages.scrollHeight;
  223. }
  224. function removeMessage(id) {
  225. const node = document.querySelector(`[data-message-id="${id}"]`);
  226. node?.remove();
  227. }
  228. function appendMessage({ id = "", role, content, tables = [] }) {
  229. const row = document.createElement("div");
  230. row.className = `message-row ${role}`;
  231. if (id) {
  232. row.dataset.messageId = id;
  233. }
  234. const avatar = document.createElement("div");
  235. avatar.className = "avatar";
  236. if (role === "assistant") {
  237. const img = document.createElement("img");
  238. img.src = "/assets/logo.png";
  239. img.alt = "";
  240. avatar.appendChild(img);
  241. } else {
  242. avatar.textContent = "我";
  243. }
  244. const bubble = document.createElement("div");
  245. bubble.className = "bubble";
  246. bubble.textContent = content;
  247. if (tables.length) {
  248. state.lastTables = tables;
  249. tables.forEach((table, index) => {
  250. bubble.appendChild(renderTable(table, index));
  251. });
  252. }
  253. row.append(avatar, bubble);
  254. els.messages.appendChild(row);
  255. els.messages.scrollTop = els.messages.scrollHeight;
  256. }
  257. function renderTable(table, index) {
  258. const card = document.createElement("div");
  259. card.className = "table-card";
  260. const head = document.createElement("div");
  261. head.className = "table-head";
  262. const title = document.createElement("strong");
  263. title.textContent = table.title || "结果表格";
  264. const download = document.createElement("button");
  265. download.type = "button";
  266. download.textContent = "下载 CSV";
  267. download.addEventListener("click", () => downloadCsv(table, index));
  268. head.append(title, download);
  269. const wrap = document.createElement("div");
  270. wrap.className = "table-wrap";
  271. const el = document.createElement("table");
  272. const thead = document.createElement("thead");
  273. const tr = document.createElement("tr");
  274. for (const column of table.columns || []) {
  275. const th = document.createElement("th");
  276. th.textContent = column;
  277. tr.appendChild(th);
  278. }
  279. thead.appendChild(tr);
  280. el.appendChild(thead);
  281. const tbody = document.createElement("tbody");
  282. for (const row of table.rows || []) {
  283. const bodyTr = document.createElement("tr");
  284. for (const column of table.columns || []) {
  285. const td = document.createElement("td");
  286. td.textContent = row[column] ?? "";
  287. bodyTr.appendChild(td);
  288. }
  289. tbody.appendChild(bodyTr);
  290. }
  291. el.appendChild(tbody);
  292. wrap.appendChild(el);
  293. card.append(head, wrap);
  294. return card;
  295. }
  296. function downloadCsv(table, index) {
  297. const rows = [
  298. table.columns,
  299. ...(table.rows || []).map((row) => table.columns.map((column) => row[column] ?? ""))
  300. ];
  301. const csv = rows.map((row) => row.map(csvCell).join(",")).join("\r\n");
  302. const blob = new Blob(["\ufeff", csv], { type: "text/csv;charset=utf-8" });
  303. const url = URL.createObjectURL(blob);
  304. const link = document.createElement("a");
  305. link.href = url;
  306. link.download = `${safeFileName(table.title || "agent-table")}-${index + 1}.csv`;
  307. link.click();
  308. URL.revokeObjectURL(url);
  309. }
  310. function csvCell(value) {
  311. const text = String(value ?? "");
  312. return `"${text.replace(/"/g, '""')}"`;
  313. }
  314. function safeFileName(value) {
  315. return String(value).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 60);
  316. }
  317. function renderTools(tools) {
  318. els.toolList.innerHTML = "";
  319. for (const tool of tools) {
  320. const item = document.createElement("button");
  321. item.type = "button";
  322. item.className = "tool-item";
  323. item.dataset.toolName = tool.name;
  324. item.title = "点击后将工具调用意图填入输入框";
  325. if (state.selectedToolName === tool.name) {
  326. item.classList.add("active");
  327. }
  328. const title = document.createElement("strong");
  329. title.textContent = tool.title || tool.name;
  330. const desc = document.createElement("span");
  331. desc.textContent = `${tool.name}${tool.description ? ` - ${tool.description}` : ""}`;
  332. const params = document.createElement("small");
  333. params.textContent = summarizeToolParams(tool);
  334. item.append(title, desc, params);
  335. item.addEventListener("click", () => selectTool(tool));
  336. els.toolList.appendChild(item);
  337. }
  338. }
  339. function selectTool(tool) {
  340. state.selectedToolName = tool.name;
  341. els.toolList.querySelectorAll(".tool-item").forEach((node) => {
  342. node.classList.toggle("active", node.dataset.toolName === tool.name);
  343. });
  344. els.input.value = buildToolPrompt(tool);
  345. updateInputCount();
  346. els.input.focus();
  347. addAssistantMessage(buildToolSelectionMessage(tool));
  348. }
  349. function summarizeToolParams(tool) {
  350. const schema = tool.inputSchema || {};
  351. const properties = schema.properties || {};
  352. const keys = Object.keys(properties);
  353. if (!keys.length) {
  354. return "无参数,点击后可直接发送";
  355. }
  356. const required = new Set(schema.required || []);
  357. const requiredKeys = keys.filter((key) => required.has(key));
  358. const optionalKeys = keys.filter((key) => !required.has(key));
  359. const parts = [];
  360. if (requiredKeys.length) {
  361. parts.push(`必填:${requiredKeys.join("、")}`);
  362. }
  363. if (optionalKeys.length) {
  364. parts.push(`可选:${optionalKeys.join("、")}`);
  365. }
  366. return parts.join(";");
  367. }
  368. function buildToolPrompt(tool) {
  369. const schema = tool.inputSchema || {};
  370. const properties = schema.properties || {};
  371. const keys = Object.keys(properties);
  372. const title = tool.title || tool.name;
  373. if (!keys.length) {
  374. return `使用「${title}」`;
  375. }
  376. const required = new Set(schema.required || []);
  377. const params = keys.map((key) => {
  378. const label = required.has(key) ? "必填" : "可选";
  379. const description = properties[key]?.description || "";
  380. return `${key}(${label}:${description})=`;
  381. });
  382. return `使用「${title}」,${params.join(";")}`;
  383. }
  384. function buildToolSelectionMessage(tool) {
  385. const schema = tool.inputSchema || {};
  386. const properties = schema.properties || {};
  387. const keys = Object.keys(properties);
  388. const title = tool.title || tool.name;
  389. const lines = [
  390. `已选择工具:${title}`,
  391. tool.description || "",
  392. `工具名:${tool.name}`
  393. ].filter(Boolean);
  394. if (keys.length) {
  395. const required = new Set(schema.required || []);
  396. const params = keys.map((key) => {
  397. const label = required.has(key) ? "必填" : "可选";
  398. const description = properties[key]?.description || properties[key]?.type || "";
  399. return `- ${key}(${label}):${description}`;
  400. });
  401. lines.push(`参数:\n${params.join("\n")}`);
  402. lines.push("我已把参数模板放到输入框,补齐后发送即可。");
  403. } else {
  404. lines.push("这个工具不需要参数,我已把可直接发送的指令放到输入框。");
  405. }
  406. return lines.join("\n\n");
  407. }
  408. function updateInputCount() {
  409. els.inputCount.textContent = `${els.input.value.trim().length} 字`;
  410. }
  411. function renderTrace(trace, toolCalls) {
  412. els.traceList.innerHTML = "";
  413. const rows = trace.length ? trace : toolCalls.map((item) => ({
  414. phase: item.ok ? "tool" : "error",
  415. title: item.name,
  416. detail: item.error || `${item.durationMs || 0}ms`
  417. }));
  418. for (const item of rows.slice(-12).reverse()) {
  419. const node = document.createElement("div");
  420. node.className = `trace-item phase-${item.phase}`;
  421. const title = document.createElement("strong");
  422. title.textContent = item.title || item.phase;
  423. const detail = document.createElement("span");
  424. detail.textContent = item.detail || item.at || "";
  425. node.append(title, detail);
  426. els.traceList.appendChild(node);
  427. }
  428. }
  429. async function getJson(url) {
  430. const response = await fetch(url);
  431. const payload = await response.json();
  432. if (!response.ok) {
  433. throw new Error(payload.message || `HTTP ${response.status}`);
  434. }
  435. return payload;
  436. }
  437. async function postJson(url, body) {
  438. const response = await fetch(url, {
  439. method: "POST",
  440. headers: {
  441. "Content-Type": "application/json"
  442. },
  443. body: JSON.stringify(body)
  444. });
  445. const payload = await response.json();
  446. if (!response.ok) {
  447. throw new Error(payload.message || `HTTP ${response.status}`);
  448. }
  449. return payload;
  450. }