app.js 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. const state = {
  2. history: [],
  3. lastTables: []
  4. };
  5. const els = {
  6. messages: document.querySelector("#messages"),
  7. form: document.querySelector("#chatForm"),
  8. input: document.querySelector("#messageInput"),
  9. inputCount: document.querySelector("#inputCount"),
  10. sendBtn: document.querySelector("#sendBtn"),
  11. statusDot: document.querySelector("#statusDot"),
  12. sessionStatus: document.querySelector("#sessionStatus"),
  13. mcpStatus: document.querySelector("#mcpStatus"),
  14. mcpEndpoint: document.querySelector("#mcpEndpoint"),
  15. toolCount: document.querySelector("#toolCount"),
  16. toolList: document.querySelector("#toolList"),
  17. traceList: document.querySelector("#traceList"),
  18. refreshBtn: document.querySelector("#refreshBtn")
  19. };
  20. boot();
  21. function boot() {
  22. addAssistantMessage(
  23. "你好,我是市民请集合智能助手 Web 测试版。你可以直接问业务系统里的订单、商品、会员、月结等问题,我会通过 MCP 工具查询后再回答。"
  24. );
  25. bindEvents();
  26. refreshStatus();
  27. }
  28. function bindEvents() {
  29. els.form.addEventListener("submit", (event) => {
  30. event.preventDefault();
  31. sendMessage();
  32. });
  33. els.input.addEventListener("input", () => {
  34. els.inputCount.textContent = `${els.input.value.trim().length} 字`;
  35. });
  36. els.input.addEventListener("keydown", (event) => {
  37. if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
  38. event.preventDefault();
  39. sendMessage();
  40. }
  41. });
  42. els.refreshBtn.addEventListener("click", refreshStatus);
  43. document.querySelector("[data-scroll-tools]").addEventListener("click", () => {
  44. document.querySelector("#toolsPanel").scrollIntoView({ behavior: "smooth" });
  45. });
  46. document.querySelector("[data-scroll-trace]").addEventListener("click", () => {
  47. document.querySelector("#tracePanel").scrollIntoView({ behavior: "smooth" });
  48. });
  49. }
  50. async function refreshStatus() {
  51. setStatus("checking", "检查中", "MCP 连接检测中");
  52. try {
  53. const health = await getJson("/api/health");
  54. const tools = await getJson("/api/tools");
  55. const mcp = health.mcp || {};
  56. els.mcpEndpoint.textContent = mcp.path ? `${mcp.name || "mcp"} ${mcp.path}` : "-";
  57. els.toolCount.textContent = `${tools.tools?.length || 0} 个`;
  58. renderTools(tools.tools || []);
  59. if (health.ok) {
  60. setStatus("ok", "已连接", `${mcp.environmentName || "MCP"} 可用`);
  61. } else {
  62. setStatus("error", "未连接", mcp.message || "MCP 不可用");
  63. }
  64. } catch (error) {
  65. setStatus("error", "未连接", error.message || "状态检查失败");
  66. }
  67. }
  68. function setStatus(mode, title, detail) {
  69. els.statusDot.classList.toggle("ok", mode === "ok");
  70. els.statusDot.classList.toggle("error", mode === "error");
  71. els.sessionStatus.textContent = title;
  72. els.mcpStatus.textContent = detail;
  73. }
  74. async function sendMessage() {
  75. const message = els.input.value.trim();
  76. if (!message) {
  77. return;
  78. }
  79. addUserMessage(message);
  80. els.input.value = "";
  81. els.inputCount.textContent = "0 字";
  82. setBusy(true);
  83. const thinkingId = addThinkingMessage();
  84. try {
  85. const response = await postJson("/api/chat", {
  86. message,
  87. history: state.history.slice(-12)
  88. });
  89. removeMessage(thinkingId);
  90. if (!response.ok) {
  91. throw new Error(response.message || "请求失败");
  92. }
  93. const result = response.result;
  94. addAssistantMessage(result.content, result.tables || []);
  95. renderTrace(result.trace || [], result.toolCalls || []);
  96. state.history.push({ role: "user", content: message });
  97. state.history.push({ role: "assistant", content: result.content });
  98. } catch (error) {
  99. removeMessage(thinkingId);
  100. addAssistantMessage(`暂时无法完成:${error.message || error}`);
  101. } finally {
  102. setBusy(false);
  103. }
  104. }
  105. function setBusy(busy) {
  106. els.sendBtn.disabled = busy;
  107. els.sendBtn.querySelector(".send-text").textContent = busy ? "处理中" : "发送";
  108. }
  109. function addUserMessage(content) {
  110. appendMessage({ role: "user", content });
  111. }
  112. function addAssistantMessage(content, tables = []) {
  113. appendMessage({ role: "assistant", content, tables });
  114. }
  115. function addThinkingMessage() {
  116. const id = `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  117. appendMessage({
  118. id,
  119. role: "assistant",
  120. content: '<span class="typing">正在处理 <i></i><i></i><i></i></span>',
  121. trustedHtml: true
  122. });
  123. return id;
  124. }
  125. function removeMessage(id) {
  126. const node = document.querySelector(`[data-message-id="${id}"]`);
  127. node?.remove();
  128. }
  129. function appendMessage({ id = "", role, content, tables = [], trustedHtml = false }) {
  130. const row = document.createElement("div");
  131. row.className = `message-row ${role}`;
  132. if (id) {
  133. row.dataset.messageId = id;
  134. }
  135. const avatar = document.createElement("div");
  136. avatar.className = "avatar";
  137. if (role === "assistant") {
  138. const img = document.createElement("img");
  139. img.src = "/assets/logo.png";
  140. img.alt = "";
  141. avatar.appendChild(img);
  142. } else {
  143. avatar.textContent = "我";
  144. }
  145. const bubble = document.createElement("div");
  146. bubble.className = "bubble";
  147. if (trustedHtml) {
  148. bubble.innerHTML = content;
  149. } else {
  150. bubble.textContent = content;
  151. }
  152. if (tables.length) {
  153. state.lastTables = tables;
  154. tables.forEach((table, index) => {
  155. bubble.appendChild(renderTable(table, index));
  156. });
  157. }
  158. row.append(avatar, bubble);
  159. els.messages.appendChild(row);
  160. els.messages.scrollTop = els.messages.scrollHeight;
  161. }
  162. function renderTable(table, index) {
  163. const card = document.createElement("div");
  164. card.className = "table-card";
  165. const head = document.createElement("div");
  166. head.className = "table-head";
  167. const title = document.createElement("strong");
  168. title.textContent = table.title || "结果表格";
  169. const download = document.createElement("button");
  170. download.type = "button";
  171. download.textContent = "下载 CSV";
  172. download.addEventListener("click", () => downloadCsv(table, index));
  173. head.append(title, download);
  174. const wrap = document.createElement("div");
  175. wrap.className = "table-wrap";
  176. const el = document.createElement("table");
  177. const thead = document.createElement("thead");
  178. const tr = document.createElement("tr");
  179. for (const column of table.columns || []) {
  180. const th = document.createElement("th");
  181. th.textContent = column;
  182. tr.appendChild(th);
  183. }
  184. thead.appendChild(tr);
  185. el.appendChild(thead);
  186. const tbody = document.createElement("tbody");
  187. for (const row of table.rows || []) {
  188. const bodyTr = document.createElement("tr");
  189. for (const column of table.columns || []) {
  190. const td = document.createElement("td");
  191. td.textContent = row[column] ?? "";
  192. bodyTr.appendChild(td);
  193. }
  194. tbody.appendChild(bodyTr);
  195. }
  196. el.appendChild(tbody);
  197. wrap.appendChild(el);
  198. card.append(head, wrap);
  199. return card;
  200. }
  201. function downloadCsv(table, index) {
  202. const rows = [
  203. table.columns,
  204. ...(table.rows || []).map((row) => table.columns.map((column) => row[column] ?? ""))
  205. ];
  206. const csv = rows.map((row) => row.map(csvCell).join(",")).join("\r\n");
  207. const blob = new Blob(["\ufeff", csv], { type: "text/csv;charset=utf-8" });
  208. const url = URL.createObjectURL(blob);
  209. const link = document.createElement("a");
  210. link.href = url;
  211. link.download = `${safeFileName(table.title || "agent-table")}-${index + 1}.csv`;
  212. link.click();
  213. URL.revokeObjectURL(url);
  214. }
  215. function csvCell(value) {
  216. const text = String(value ?? "");
  217. return `"${text.replace(/"/g, '""')}"`;
  218. }
  219. function safeFileName(value) {
  220. return String(value).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 60);
  221. }
  222. function renderTools(tools) {
  223. els.toolList.innerHTML = "";
  224. for (const tool of tools) {
  225. const item = document.createElement("div");
  226. item.className = "tool-item";
  227. const title = document.createElement("strong");
  228. title.textContent = tool.title || tool.name;
  229. const desc = document.createElement("span");
  230. desc.textContent = `${tool.name}${tool.description ? ` · ${tool.description}` : ""}`;
  231. item.append(title, desc);
  232. els.toolList.appendChild(item);
  233. }
  234. }
  235. function renderTrace(trace, toolCalls) {
  236. els.traceList.innerHTML = "";
  237. const rows = trace.length ? trace : toolCalls.map((item) => ({
  238. phase: item.ok ? "tool" : "error",
  239. title: item.name,
  240. detail: item.error || `${item.durationMs || 0}ms`
  241. }));
  242. for (const item of rows.slice(-12).reverse()) {
  243. const node = document.createElement("div");
  244. node.className = `trace-item phase-${item.phase}`;
  245. const title = document.createElement("strong");
  246. title.textContent = item.title || item.phase;
  247. const detail = document.createElement("span");
  248. detail.textContent = item.detail || item.at || "";
  249. node.append(title, detail);
  250. els.traceList.appendChild(node);
  251. }
  252. }
  253. async function getJson(url) {
  254. const response = await fetch(url);
  255. const payload = await response.json();
  256. if (!response.ok) {
  257. throw new Error(payload.message || `HTTP ${response.status}`);
  258. }
  259. return payload;
  260. }
  261. async function postJson(url, body) {
  262. const response = await fetch(url, {
  263. method: "POST",
  264. headers: {
  265. "Content-Type": "application/json"
  266. },
  267. body: JSON.stringify(body)
  268. });
  269. const payload = await response.json();
  270. if (!response.ok) {
  271. throw new Error(payload.message || `HTTP ${response.status}`);
  272. }
  273. return payload;
  274. }