fanjingbo111's picture
Deploy algorithm agent app
ae0a268 verified
Raw
History Blame Contribute Delete
15.9 kB
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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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");
});