app.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. const state = {
  2. agents: [],
  3. lastTask: null,
  4. activeTaskId: null,
  5. mentionOptions: [],
  6. activeMentionIndex: 0,
  7. };
  8. const els = {
  9. agentList: document.getElementById("agentList"),
  10. chatForm: document.getElementById("chatForm"),
  11. messageInput: document.getElementById("messageInput"),
  12. mentionMenu: document.getElementById("mentionMenu"),
  13. messages: document.getElementById("messages"),
  14. statusText: document.getElementById("statusText"),
  15. refreshButton: document.getElementById("refreshButton"),
  16. taskView: document.getElementById("taskView"),
  17. eventList: document.getElementById("eventList"),
  18. };
  19. async function api(path, options = {}) {
  20. const response = await fetch(path, {
  21. headers: { "Content-Type": "application/json", ...(options.headers || {}) },
  22. ...options,
  23. });
  24. if (!response.ok) {
  25. const text = await response.text();
  26. throw new Error(text || `HTTP ${response.status}`);
  27. }
  28. return response.json();
  29. }
  30. function nowText() {
  31. return new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
  32. }
  33. function escapeHtml(value) {
  34. return String(value)
  35. .replaceAll("&", "&")
  36. .replaceAll("<", "&lt;")
  37. .replaceAll(">", "&gt;")
  38. .replaceAll('"', "&quot;")
  39. .replaceAll("'", "&#039;");
  40. }
  41. function linkify(text) {
  42. const escaped = escapeHtml(text);
  43. return escaped.replace(/(https?:\/\/[^\s]+|\/rss-digests\/[^\s]+)/g, (url) => {
  44. const href = url.startsWith("/") ? url : url;
  45. return `<a href="${href}" target="_blank" rel="noreferrer">${url}</a>`;
  46. });
  47. }
  48. function renderInlineMarkdown(text) {
  49. let html = linkify(text);
  50. html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
  51. html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
  52. html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
  53. return html;
  54. }
  55. function renderMarkdown(markdown) {
  56. const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
  57. const blocks = [];
  58. let paragraph = [];
  59. let list = null;
  60. let code = null;
  61. function flushParagraph() {
  62. if (!paragraph.length) return;
  63. blocks.push(`<p>${renderInlineMarkdown(paragraph.join(" "))}</p>`);
  64. paragraph = [];
  65. }
  66. function flushList() {
  67. if (!list) return;
  68. const tag = list.type === "ol" ? "ol" : "ul";
  69. blocks.push(`<${tag}>${list.items.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</${tag}>`);
  70. list = null;
  71. }
  72. function flushCode() {
  73. if (code === null) return;
  74. blocks.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
  75. code = null;
  76. }
  77. for (const line of lines) {
  78. if (line.trim().startsWith("```")) {
  79. if (code === null) {
  80. flushParagraph();
  81. flushList();
  82. code = [];
  83. } else {
  84. flushCode();
  85. }
  86. continue;
  87. }
  88. if (code !== null) {
  89. code.push(line);
  90. continue;
  91. }
  92. const trimmed = line.trim();
  93. if (!trimmed) {
  94. flushParagraph();
  95. flushList();
  96. continue;
  97. }
  98. const heading = trimmed.match(/^(#{1,3})\s+(.+)$/);
  99. if (heading) {
  100. flushParagraph();
  101. flushList();
  102. const level = heading[1].length;
  103. blocks.push(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`);
  104. continue;
  105. }
  106. const unordered = trimmed.match(/^[-*]\s+(.+)$/);
  107. if (unordered) {
  108. flushParagraph();
  109. if (!list || list.type !== "ul") {
  110. flushList();
  111. list = { type: "ul", items: [] };
  112. }
  113. list.items.push(unordered[1]);
  114. continue;
  115. }
  116. const ordered = trimmed.match(/^\d+\.\s+(.+)$/);
  117. if (ordered) {
  118. flushParagraph();
  119. if (!list || list.type !== "ol") {
  120. flushList();
  121. list = { type: "ol", items: [] };
  122. }
  123. list.items.push(ordered[1]);
  124. continue;
  125. }
  126. if (trimmed.startsWith("> ")) {
  127. flushParagraph();
  128. flushList();
  129. blocks.push(`<blockquote>${renderInlineMarkdown(trimmed.slice(2))}</blockquote>`);
  130. continue;
  131. }
  132. flushList();
  133. paragraph.push(trimmed);
  134. }
  135. flushCode();
  136. flushParagraph();
  137. flushList();
  138. return blocks.join("");
  139. }
  140. function appendMessage(kind, author, body) {
  141. const node = document.createElement("article");
  142. node.className = `message ${kind}`;
  143. node.innerHTML = `
  144. <div class="message-head">
  145. <strong>${escapeHtml(author)}</strong>
  146. <span>${nowText()}</span>
  147. </div>
  148. <div class="message-body">${renderMarkdown(body)}</div>
  149. `;
  150. els.messages.appendChild(node);
  151. els.messages.scrollTop = els.messages.scrollHeight;
  152. }
  153. function insertMention(agentId) {
  154. const mention = `@${agentId} `;
  155. const current = els.messageInput.value.trimStart();
  156. const withoutOldMention = current.replace(/^@[a-zA-Z0-9_\-]+\s*/, "");
  157. els.messageInput.value = mention + withoutOldMention;
  158. hideMentionMenu();
  159. els.messageInput.focus();
  160. }
  161. function mentionChoices() {
  162. return state.agents.map((agent) => ({
  163. ...agent,
  164. mention_id: agent.agent_id,
  165. }));
  166. }
  167. function mentionQuery() {
  168. const value = els.messageInput.value;
  169. const cursor = els.messageInput.selectionStart || 0;
  170. const beforeCursor = value.slice(0, cursor);
  171. const match = beforeCursor.match(/(^|\s)@([a-zA-Z0-9_\-]*)$/);
  172. return match ? match[2].toLowerCase() : null;
  173. }
  174. function hideMentionMenu() {
  175. els.mentionMenu.hidden = true;
  176. els.mentionMenu.innerHTML = "";
  177. state.mentionOptions = [];
  178. state.activeMentionIndex = 0;
  179. }
  180. function chooseMention(option) {
  181. const value = els.messageInput.value;
  182. const cursor = els.messageInput.selectionStart || 0;
  183. const beforeCursor = value.slice(0, cursor);
  184. const afterCursor = value.slice(cursor);
  185. const replacement = `@${option.agent_id} `;
  186. const replacedBefore = beforeCursor.replace(/(^|\s)@[a-zA-Z0-9_\-]*$/, (prefix) => {
  187. const leadingSpace = prefix.startsWith(" ") ? " " : "";
  188. return leadingSpace + replacement;
  189. });
  190. els.messageInput.value = replacedBefore + afterCursor.trimStart();
  191. hideMentionMenu();
  192. els.messageInput.focus();
  193. }
  194. function renderMentionMenu() {
  195. const query = mentionQuery();
  196. if (query === null) {
  197. hideMentionMenu();
  198. return;
  199. }
  200. state.mentionOptions = mentionChoices().filter((option) => {
  201. const haystack = `${option.agent_id} ${option.name}`.toLowerCase();
  202. return haystack.includes(query);
  203. });
  204. state.activeMentionIndex = Math.min(state.activeMentionIndex, Math.max(state.mentionOptions.length - 1, 0));
  205. if (!state.mentionOptions.length) {
  206. hideMentionMenu();
  207. return;
  208. }
  209. els.mentionMenu.innerHTML = "";
  210. for (const [index, option] of state.mentionOptions.entries()) {
  211. const item = document.createElement("button");
  212. item.type = "button";
  213. item.className = `mention-option${index === state.activeMentionIndex ? " active" : ""}`;
  214. item.innerHTML = `
  215. <strong>@${escapeHtml(option.agent_id)} · ${escapeHtml(option.name)}</strong>
  216. <span>${escapeHtml(option.description || "")}</span>
  217. `;
  218. item.addEventListener("mousedown", (event) => {
  219. event.preventDefault();
  220. chooseMention(option);
  221. });
  222. els.mentionMenu.appendChild(item);
  223. }
  224. els.mentionMenu.hidden = false;
  225. }
  226. function renderAgents() {
  227. els.agentList.innerHTML = "";
  228. for (const agent of state.agents) {
  229. const item = document.createElement("button");
  230. item.type = "button";
  231. item.className = "agent-item";
  232. item.innerHTML = `
  233. <div class="agent-name">${escapeHtml(agent.name)}</div>
  234. <div class="agent-meta">@${escapeHtml(agent.agent_id)} | ${escapeHtml(agent.memory_policy)}</div>
  235. <div class="agent-meta">${escapeHtml(agent.description)}</div>
  236. `;
  237. item.addEventListener("click", () => insertMention(agent.agent_id));
  238. els.agentList.appendChild(item);
  239. }
  240. }
  241. function renderTask() {
  242. if (!state.lastTask) {
  243. els.taskView.textContent = "暂无任务";
  244. return;
  245. }
  246. const task = state.lastTask;
  247. els.taskView.innerHTML = `
  248. <div class="task-card">
  249. <strong>${escapeHtml(task.title)}</strong>
  250. <div>智能体:${escapeHtml(task.agent_id)}</div>
  251. <div>状态:${escapeHtml(task.status)}</div>
  252. <div>任务ID:${escapeHtml(task.task_id)}</div>
  253. </div>
  254. `;
  255. }
  256. function sleep(ms) {
  257. return new Promise((resolve) => window.setTimeout(resolve, ms));
  258. }
  259. async function waitForTask(taskId) {
  260. while (true) {
  261. const task = await api(`/tasks/${taskId}`);
  262. state.lastTask = task;
  263. renderTask();
  264. if (task.status === "completed" || task.status === "failed") {
  265. return task;
  266. }
  267. await refreshEvents();
  268. await sleep(1500);
  269. }
  270. }
  271. async function refreshEvents() {
  272. const data = await api("/events?limit=20");
  273. els.eventList.innerHTML = "";
  274. if (!data.events.length) {
  275. els.eventList.textContent = "暂无事件";
  276. return;
  277. }
  278. for (const event of data.events.slice().reverse()) {
  279. const item = document.createElement("div");
  280. item.className = "event-item";
  281. item.innerHTML = `
  282. <div class="event-type">${escapeHtml(event.type)}</div>
  283. <div>${escapeHtml(event.agent_id || "system")}</div>
  284. <div>${escapeHtml(event.timestamp)}</div>
  285. `;
  286. els.eventList.appendChild(item);
  287. }
  288. }
  289. async function loadAgents() {
  290. const data = await api("/agents");
  291. state.agents = data.agents;
  292. renderAgents();
  293. renderMentionMenu();
  294. els.statusText.textContent = `已连接 ${data.total} 个智能体`;
  295. }
  296. function parseTarget(rawText) {
  297. const text = rawText.trim();
  298. const match = text.match(/^@([a-zA-Z0-9_\-]+)\s*(.*)$/);
  299. if (!match) {
  300. return { agentId: null, message: text };
  301. }
  302. const mention = match[1];
  303. const message = match[2].trim();
  304. return { agentId: mention, message };
  305. }
  306. async function sendMessage(rawText) {
  307. const { agentId, message } = parseTarget(rawText);
  308. if (!message) {
  309. appendMessage("system", "系统", "请输入消息内容。示例:@deep_research 调研一个主题");
  310. return;
  311. }
  312. if (!agentId) {
  313. appendMessage("system", "系统", "请先用 @ 选择一个智能体,例如:@deep_research 调研一个主题,或 @rss_digest 今日简报。");
  314. return;
  315. }
  316. const agent = state.agents.find((item) => item.agent_id === agentId);
  317. if (!agent) {
  318. appendMessage("system", "系统", `未找到智能体 @${agentId}。请点击左侧智能体插入正确的 @ 标记。`);
  319. return;
  320. }
  321. appendMessage("user", "你", `@${agentId} ${message}`);
  322. appendMessage("system", "系统", `${agent.name} 已开始后台执行,可以继续输入下一条消息。`);
  323. const task = await api("/tasks", {
  324. method: "POST",
  325. body: JSON.stringify({
  326. title: `与 ${agent.name} 对话`,
  327. input: message,
  328. agent_id: agentId,
  329. metadata: { group_id: "default", mention: agentId },
  330. }),
  331. });
  332. state.lastTask = task;
  333. renderTask();
  334. const running = await api(`/tasks/${task.task_id}/run`, { method: "POST" });
  335. state.lastTask = running;
  336. state.activeTaskId = running.task_id;
  337. renderTask();
  338. const completed = await waitForTask(task.task_id);
  339. state.lastTask = completed;
  340. state.activeTaskId = null;
  341. renderTask();
  342. if (completed.status === "failed") {
  343. appendMessage("system", "系统", `${agent.name} 执行失败:${completed.error || "未知错误"}`);
  344. } else {
  345. appendMessage("agent", agent.name, completed.output || "(无输出)");
  346. }
  347. await refreshEvents();
  348. }
  349. els.chatForm.addEventListener("submit", async (event) => {
  350. event.preventDefault();
  351. const text = els.messageInput.value.trim();
  352. if (!text) return;
  353. els.messageInput.value = "";
  354. try {
  355. await sendMessage(text);
  356. } catch (error) {
  357. appendMessage("system", "系统", `请求失败:${error.message}`);
  358. } finally {
  359. els.messageInput.focus();
  360. }
  361. });
  362. els.messageInput.addEventListener("input", renderMentionMenu);
  363. els.messageInput.addEventListener("click", renderMentionMenu);
  364. els.messageInput.addEventListener("blur", () => {
  365. window.setTimeout(hideMentionMenu, 120);
  366. });
  367. els.messageInput.addEventListener("keydown", (event) => {
  368. if (els.mentionMenu.hidden) return;
  369. if (event.key === "ArrowDown") {
  370. event.preventDefault();
  371. state.activeMentionIndex = (state.activeMentionIndex + 1) % state.mentionOptions.length;
  372. renderMentionMenu();
  373. } else if (event.key === "ArrowUp") {
  374. event.preventDefault();
  375. state.activeMentionIndex =
  376. (state.activeMentionIndex - 1 + state.mentionOptions.length) % state.mentionOptions.length;
  377. renderMentionMenu();
  378. } else if (event.key === "Enter" || event.key === "Tab") {
  379. event.preventDefault();
  380. chooseMention(state.mentionOptions[state.activeMentionIndex]);
  381. } else if (event.key === "Escape") {
  382. hideMentionMenu();
  383. }
  384. });
  385. els.refreshButton.addEventListener("click", async () => {
  386. try {
  387. await loadAgents();
  388. await refreshEvents();
  389. appendMessage("system", "系统", "已刷新智能体和事件日志。");
  390. } catch (error) {
  391. appendMessage("system", "系统", `刷新失败:${error.message}`);
  392. }
  393. });
  394. async function boot() {
  395. try {
  396. await loadAgents();
  397. await refreshEvents();
  398. appendMessage("system", "系统", "单聊模式已就绪。输入 @ 选择一个智能体后发送。");
  399. } catch (error) {
  400. els.statusText.textContent = "后端连接失败";
  401. appendMessage("system", "系统", `启动失败:${error.message}`);
  402. }
  403. }
  404. boot();