Spaces:
Sleeping
Sleeping
| const historyList = document.querySelector("#history-list"); | |
| const refreshHistoryBtn = document.querySelector("#refresh-history-btn"); | |
| const apiStatus = document.querySelector("#api-status"); | |
| const artifactStatus = document.querySelector("#artifact-status"); | |
| const questionInput = document.querySelector("#question"); | |
| const runBtn = document.querySelector("#run-btn"); | |
| const timeline = document.querySelector("#timeline"); | |
| const newChatBtn = document.querySelector("#new-chat-btn"); | |
| const clearScreenBtn = document.querySelector("#clear-screen-btn"); | |
| const STORAGE_KEY = "algorithm-agent-history-v1"; | |
| let eventSource = null; | |
| let currentWorkflow = null; | |
| let currentQuestion = ""; | |
| let running = false; | |
| const defaultQuestion = `给定一个有向带权图,节点为 A, B, C, D, E。 | |
| 边为 A->B:4, A->C:2, B->C:1, B->D:5, C->D:8, C->E:10, D->E:2。 | |
| 请从 A 到 E 找到最短路径,并说明应该使用什么算法。`; | |
| function escapeHtml(value) { | |
| return String(value || "") | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll('"', """) | |
| .replaceAll("'", "'"); | |
| } | |
| function nowText() { | |
| const d = new Date(); | |
| const pad = (n) => String(n).padStart(2, "0"); | |
| return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; | |
| } | |
| function loadHistory() { | |
| try { | |
| return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); | |
| } catch { | |
| return []; | |
| } | |
| } | |
| function saveHistory(history) { | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(history.slice(0, 20))); | |
| } | |
| function renderHistory() { | |
| const history = loadHistory(); | |
| if (!history.length) { | |
| historyList.innerHTML = '<div class="mini-empty">还没有历史记录。</div>'; | |
| return; | |
| } | |
| historyList.innerHTML = history | |
| .map( | |
| (item, index) => ` | |
| <button class="history-item" data-index="${index}" type="button"> | |
| <span class="history-row-top"> | |
| <span class="history-question">${escapeHtml(item.question)}</span> | |
| <span class="history-delete" data-index="${index}" title="删除历史记录">删</span> | |
| </span> | |
| <span class="history-preview">${escapeHtml(item.answer || "")}</span> | |
| <span class="history-time">${escapeHtml(item.time || "")}${item.stepCount ? ` · ${item.stepCount} 步` : ""}</span> | |
| </button> | |
| `, | |
| ) | |
| .join(""); | |
| } | |
| async function loadStatus() { | |
| const resp = await fetch("/api/status"); | |
| const status = await resp.json(); | |
| apiStatus.textContent = status.enabled | |
| ? `${status.provider} · ${status.model} · LLM` | |
| : `${status.provider} · 未检测到 Key`; | |
| apiStatus.classList.toggle("offline", !status.enabled); | |
| } | |
| function setRunning(value) { | |
| running = value; | |
| runBtn.disabled = value; | |
| runBtn.textContent = value ? "运行中" : "发送"; | |
| newChatBtn.disabled = value; | |
| clearScreenBtn.disabled = value; | |
| historyList.classList.toggle("locked", value); | |
| } | |
| function clearTimeline(message = "从右下方输入问题开始。") { | |
| timeline.innerHTML = `<div class="empty-state">${escapeHtml(message)}</div>`; | |
| } | |
| function removeEmpty() { | |
| const empty = timeline.querySelector(".empty-state"); | |
| if (empty) empty.remove(); | |
| } | |
| function appendChatMessage({ role, title, content, time, artifact }) { | |
| removeEmpty(); | |
| const el = document.createElement("article"); | |
| el.className = `chat-message ${role}`; | |
| const badge = role === "user" ? "用户" : "回答"; | |
| el.innerHTML = ` | |
| <div class="message-meta"> | |
| <span>${escapeHtml(title || "")}</span> | |
| <span>${escapeHtml(time || "")}</span> | |
| </div> | |
| <div class="message-bubble"> | |
| <div class="message-badge">${badge}</div> | |
| <div class="markdown-body">${formatText(content || "")}</div> | |
| ${artifact ? renderArtifacts(artifact) : ""} | |
| </div> | |
| `; | |
| timeline.appendChild(el); | |
| typesetMath(el); | |
| timeline.scrollTop = timeline.scrollHeight; | |
| } | |
| function formatText(text) { | |
| return renderMarkdown(text); | |
| } | |
| function protectLatex(text) { | |
| const store = []; | |
| const protectedText = String(text || "").replace( | |
| /(\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)|\$[^$\n]+\$)/g, | |
| (match) => { | |
| const token = `@@LATEX_${store.length}@@`; | |
| store.push(match); | |
| return token; | |
| }, | |
| ); | |
| return { protectedText, store }; | |
| } | |
| function restoreLatex(html, store) { | |
| let output = html; | |
| store.forEach((item, index) => { | |
| output = output.replaceAll(`@@LATEX_${index}@@`, item); | |
| }); | |
| return output; | |
| } | |
| function renderMarkdown(text) { | |
| const { protectedText, store } = protectLatex(text); | |
| const lines = protectedText.replaceAll("\r\n", "\n").split("\n"); | |
| const html = []; | |
| let paragraph = []; | |
| let listType = null; | |
| let inCode = false; | |
| let codeLines = []; | |
| let blockquote = []; | |
| const flushParagraph = () => { | |
| if (!paragraph.length) return; | |
| html.push(`<p>${renderInline(paragraph.join(" "), store)}</p>`); | |
| paragraph = []; | |
| }; | |
| const flushList = () => { | |
| if (!listType) return; | |
| html.push(`</${listType}>`); | |
| listType = null; | |
| }; | |
| const flushQuote = () => { | |
| if (!blockquote.length) return; | |
| html.push(`<blockquote>${blockquote.map((line) => renderInline(line, store)).join("<br>")}</blockquote>`); | |
| blockquote = []; | |
| }; | |
| const closeBlocks = () => { | |
| flushParagraph(); | |
| flushList(); | |
| flushQuote(); | |
| }; | |
| for (let i = 0; i < lines.length; i += 1) { | |
| const rawLine = lines[i]; | |
| const line = rawLine.trimEnd(); | |
| if (line.trim().startsWith("```")) { | |
| if (inCode) { | |
| html.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`); | |
| codeLines = []; | |
| inCode = false; | |
| } else { | |
| closeBlocks(); | |
| inCode = true; | |
| } | |
| continue; | |
| } | |
| if (inCode) { | |
| codeLines.push(rawLine); | |
| continue; | |
| } | |
| if (!line.trim()) { | |
| closeBlocks(); | |
| continue; | |
| } | |
| if (isTableStart(lines, i)) { | |
| closeBlocks(); | |
| const table = collectTable(lines, i); | |
| html.push(renderTable(table.rows, store)); | |
| i = table.nextIndex - 1; | |
| continue; | |
| } | |
| const heading = /^(#{1,4})\s+(.+)$/.exec(line); | |
| if (heading) { | |
| closeBlocks(); | |
| const level = heading[1].length; | |
| html.push(`<h${level}>${renderInline(heading[2], store)}</h${level}>`); | |
| continue; | |
| } | |
| const quote = /^>\s?(.*)$/.exec(line); | |
| if (quote) { | |
| flushParagraph(); | |
| flushList(); | |
| blockquote.push(quote[1]); | |
| continue; | |
| } | |
| const unordered = /^[-*]\s+(.+)$/.exec(line); | |
| if (unordered) { | |
| flushParagraph(); | |
| flushQuote(); | |
| if (listType !== "ul") { | |
| flushList(); | |
| listType = "ul"; | |
| html.push("<ul>"); | |
| } | |
| html.push(`<li>${renderInline(unordered[1], store)}</li>`); | |
| continue; | |
| } | |
| const ordered = /^\d+\.\s+(.+)$/.exec(line); | |
| if (ordered) { | |
| flushParagraph(); | |
| flushQuote(); | |
| if (listType !== "ol") { | |
| flushList(); | |
| listType = "ol"; | |
| html.push("<ol>"); | |
| } | |
| html.push(`<li>${renderInline(ordered[1], store)}</li>`); | |
| continue; | |
| } | |
| flushList(); | |
| flushQuote(); | |
| paragraph.push(line.trim()); | |
| } | |
| if (inCode) { | |
| html.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`); | |
| } | |
| closeBlocks(); | |
| return html.join(""); | |
| } | |
| function renderInline(text, latexStore) { | |
| const code = []; | |
| let value = String(text || "").replace(/`([^`]+)`/g, (_, inner) => { | |
| const token = `@@CODE_${code.length}@@`; | |
| code.push(`<code>${escapeHtml(inner)}</code>`); | |
| return token; | |
| }); | |
| value = escapeHtml(value); | |
| value = value | |
| .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") | |
| .replace(/\*([^*]+)\*/g, "<em>$1</em>") | |
| .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>'); | |
| code.forEach((item, index) => { | |
| value = value.replaceAll(`@@CODE_${index}@@`, item); | |
| }); | |
| return restoreLatex(value, latexStore); | |
| } | |
| function isTableStart(lines, index) { | |
| if (index + 1 >= lines.length) return false; | |
| const header = lines[index].trim(); | |
| const divider = lines[index + 1].trim(); | |
| return header.includes("|") && /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(divider); | |
| } | |
| function collectTable(lines, start) { | |
| const rows = []; | |
| let index = start; | |
| while (index < lines.length && lines[index].trim().includes("|")) { | |
| rows.push(lines[index].trim()); | |
| index += 1; | |
| } | |
| return { rows, nextIndex: index }; | |
| } | |
| function splitTableRow(row) { | |
| return row.replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim()); | |
| } | |
| function renderTable(rows, latexStore) { | |
| if (rows.length < 2) return ""; | |
| const header = splitTableRow(rows[0]); | |
| const body = rows.slice(2).map(splitTableRow); | |
| return ` | |
| <div class="table-wrap"> | |
| <table> | |
| <thead><tr>${header.map((cell) => `<th>${renderInline(cell, latexStore)}</th>`).join("")}</tr></thead> | |
| <tbody> | |
| ${body.map((row) => `<tr>${row.map((cell) => `<td>${renderInline(cell, latexStore)}</td>`).join("")}</tr>`).join("")} | |
| </tbody> | |
| </table> | |
| </div> | |
| `; | |
| } | |
| function typesetMath(root) { | |
| if (!window.MathJax || !window.MathJax.typesetPromise) return; | |
| window.MathJax.typesetPromise([root]).catch(() => {}); | |
| } | |
| function renderArtifacts(artifact) { | |
| if (!artifact.pdfUrl && !artifact.texUrl) return ""; | |
| return ` | |
| <div class="artifact-links"> | |
| ${artifact.pdfUrl ? `<a href="${artifact.pdfUrl}" target="_blank" rel="noreferrer">下载 PDF 报告</a>` : ""} | |
| ${artifact.texUrl ? `<a href="${artifact.texUrl}" target="_blank" rel="noreferrer">下载 LaTeX 源码</a>` : ""} | |
| </div> | |
| `; | |
| } | |
| function createWorkflowBlock() { | |
| removeEmpty(); | |
| const el = document.createElement("article"); | |
| el.className = "chat-message process workflow-message"; | |
| el.innerHTML = ` | |
| <div class="message-meta"> | |
| <span>Agent Workflow</span> | |
| <span>运行中</span> | |
| </div> | |
| <details class="workflow-details" open> | |
| <summary> | |
| <span>Agent 工作流过程</span> | |
| <span class="workflow-summary-status">运行中</span> | |
| </summary> | |
| <div class="workflow-steps"></div> | |
| </details> | |
| `; | |
| timeline.appendChild(el); | |
| timeline.scrollTop = timeline.scrollHeight; | |
| return { | |
| el, | |
| steps: el.querySelector(".workflow-steps"), | |
| status: el.querySelector(".workflow-summary-status"), | |
| details: el.querySelector(".workflow-details"), | |
| metaStatus: el.querySelector(".message-meta span:last-child"), | |
| events: [], | |
| }; | |
| } | |
| function appendWorkflowStep(event) { | |
| if (!currentWorkflow) currentWorkflow = createWorkflowBlock(); | |
| currentWorkflow.events.push(event); | |
| const step = document.createElement("section"); | |
| step.className = "workflow-step"; | |
| const moduleName = event.module || event.title || "Agent Event"; | |
| step.innerHTML = ` | |
| <div class="workflow-step-title"> | |
| <span>${escapeHtml(event.index ? `${event.index}. ${moduleName}` : moduleName)}${event.title && event.module ? ` · ${escapeHtml(event.title)}` : ""}</span> | |
| <span>${escapeHtml(event.time || "")}</span> | |
| </div> | |
| <div class="workflow-pre">${formatText(event.content || "")}</div> | |
| `; | |
| currentWorkflow.steps.appendChild(step); | |
| typesetMath(step); | |
| timeline.scrollTop = timeline.scrollHeight; | |
| } | |
| function collapseWorkflow(label) { | |
| if (!currentWorkflow) return; | |
| currentWorkflow.details.open = false; | |
| currentWorkflow.status.textContent = label || "已完成,点击展开查看 workflow"; | |
| currentWorkflow.metaStatus.textContent = "已折叠"; | |
| } | |
| function renderHistoryRecord(record) { | |
| clearTimeline(""); | |
| appendChatMessage({ role: "user", title: "历史问题", content: record.question, time: record.time }); | |
| currentWorkflow = createWorkflowBlock(); | |
| for (const event of record.workflow || []) { | |
| appendWorkflowStep(event); | |
| } | |
| collapseWorkflow("历史 workflow,点击展开查看"); | |
| const workflow = currentWorkflow; | |
| currentWorkflow = null; | |
| if (workflow && !record.workflow?.length) workflow.el.remove(); | |
| appendChatMessage({ | |
| role: "assistant", | |
| title: "历史回答", | |
| content: record.answer, | |
| time: record.time, | |
| artifact: record.artifact, | |
| }); | |
| } | |
| function buildRunUrl(question) { | |
| const params = new URLSearchParams(); | |
| params.set("question", question); | |
| return `/api/run?${params.toString()}`; | |
| } | |
| function runAgent() { | |
| if (running) return; | |
| const question = questionInput.value.trim(); | |
| if (!question) { | |
| questionInput.focus(); | |
| return; | |
| } | |
| currentQuestion = question; | |
| removeEmpty(); | |
| appendChatMessage({ role: "user", title: "你", content: question, time: nowText() }); | |
| currentWorkflow = createWorkflowBlock(); | |
| questionInput.value = ""; | |
| artifactStatus.textContent = "报告生成中"; | |
| setRunning(true); | |
| eventSource = new EventSource(buildRunUrl(question)); | |
| eventSource.onmessage = (message) => { | |
| const event = JSON.parse(message.data); | |
| if (event.type === "done") { | |
| eventSource.close(); | |
| eventSource = null; | |
| setRunning(false); | |
| return; | |
| } | |
| if (event.type === "final") { | |
| appendChatMessage({ | |
| role: "assistant", | |
| title: event.title || "Agent", | |
| content: event.content || "", | |
| time: event.time || nowText(), | |
| artifact: { pdfUrl: event.pdf_url, texUrl: event.tex_url }, | |
| }); | |
| artifactStatus.textContent = "PDF 已生成"; | |
| collapseWorkflow("已完成,点击展开查看 workflow"); | |
| const history = loadHistory(); | |
| history.unshift({ | |
| question: currentQuestion, | |
| answer: event.content || "", | |
| time: nowText(), | |
| stepCount: currentWorkflow?.events?.length || 0, | |
| workflow: currentWorkflow?.events || [], | |
| artifact: { pdfUrl: event.pdf_url, texUrl: event.tex_url }, | |
| }); | |
| saveHistory(history); | |
| renderHistory(); | |
| currentWorkflow = null; | |
| return; | |
| } | |
| if (event.type === "error") { | |
| appendWorkflowStep(event); | |
| collapseWorkflow("运行失败"); | |
| artifactStatus.textContent = "报告未生成"; | |
| return; | |
| } | |
| appendWorkflowStep(event); | |
| }; | |
| eventSource.onerror = () => { | |
| if (eventSource) eventSource.close(); | |
| eventSource = null; | |
| setRunning(false); | |
| appendChatMessage({ | |
| role: "assistant", | |
| title: "连接中断", | |
| content: "浏览器与后台流式连接中断,请检查服务是否仍在运行。", | |
| time: nowText(), | |
| }); | |
| collapseWorkflow("运行中断"); | |
| }; | |
| } | |
| runBtn.addEventListener("click", runAgent); | |
| questionInput.addEventListener("keydown", (event) => { | |
| if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { | |
| runAgent(); | |
| } | |
| }); | |
| newChatBtn.addEventListener("click", () => { | |
| if (running) return; | |
| currentWorkflow = null; | |
| clearTimeline("当前对话已清空。从右下方输入问题开始。"); | |
| questionInput.value = defaultQuestion; | |
| artifactStatus.textContent = "报告未生成"; | |
| }); | |
| clearScreenBtn.addEventListener("click", () => { | |
| if (running) return; | |
| clearTimeline("屏幕已清空,历史记录仍保留。"); | |
| }); | |
| refreshHistoryBtn.addEventListener("click", renderHistory); | |
| historyList.addEventListener("click", (event) => { | |
| const deleteBtn = event.target.closest(".history-delete"); | |
| if (deleteBtn) { | |
| event.stopPropagation(); | |
| const index = Number(deleteBtn.dataset.index); | |
| const history = loadHistory(); | |
| history.splice(index, 1); | |
| saveHistory(history); | |
| renderHistory(); | |
| return; | |
| } | |
| if (running) return; | |
| const item = event.target.closest(".history-item"); | |
| if (!item) return; | |
| const record = loadHistory()[Number(item.dataset.index)]; | |
| if (record) renderHistoryRecord(record); | |
| }); | |
| questionInput.value = defaultQuestion; | |
| renderHistory(); | |
| loadStatus().catch(() => { | |
| apiStatus.textContent = "API 状态读取失败"; | |
| apiStatus.classList.add("offline"); | |
| }); | |