app.js 12 KB

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