Files
zk-data-agent/src/gui/static/app.js
T
2026-04-29 19:32:06 +08:00

617 lines
19 KiB
JavaScript

"use strict";
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const State = {
serverState: null,
sessions: [],
slashCommands: [],
skills: [],
activeSessionId: null, // the session we will resume on next send
isBusy: false,
paletteMode: null, // "slash" | "skills" | null
};
// ---------------------------------------------------------------------------
// DOM refs
// ---------------------------------------------------------------------------
const $ = (sel) => document.querySelector(sel);
const els = {
chat: $("#chat"),
welcome: $("#welcome"),
input: $("#prompt-input"),
sendBtn: $("#send-btn"),
sessionList: $("#session-list"),
newSessionBtn: $("#new-session-btn"),
settingsForm: $("#settings-form"),
statusDot: $("#status-dot"),
statusText: $("#status-text"),
cwdMeta: $("#cwd-meta"),
usageMeta: $("#usage-meta"),
slashBtn: $("#slash-btn"),
skillsBtn: $("#skills-btn"),
clearBtn: $("#clear-btn"),
palette: $("#palette"),
paletteSearch: $("#palette-search"),
paletteList: $("#palette-list"),
paletteClose: $("#palette-close"),
};
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
async function apiGet(path) {
const r = await fetch(path);
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return r.json();
}
async function apiPost(path, body) {
const r = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body == null ? "{}" : JSON.stringify(body),
});
const data = await r.json().catch(() => ({}));
if (!r.ok && !data.error) {
throw new Error(data.detail || `${r.status} ${r.statusText}`);
}
return data;
}
// ---------------------------------------------------------------------------
// Markdown rendering (small, dependency-free)
// ---------------------------------------------------------------------------
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function renderMarkdown(md) {
if (md == null) return "";
// Pull fenced code blocks out first so their contents are not parsed.
const codeBlocks = [];
let text = String(md).replace(/```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g, (_, lang, body) => {
const idx = codeBlocks.length;
codeBlocks.push({ lang, body });
return `\u0000CODE${idx}\u0000`;
});
text = escapeHtml(text);
// Headings
text = text.replace(/^### (.+)$/gm, "<h3>$1</h3>");
text = text.replace(/^## (.+)$/gm, "<h2>$1</h2>");
text = text.replace(/^# (.+)$/gm, "<h1>$1</h1>");
// Bold / italic
text = text.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
text = text.replace(/(^|\W)\*([^*\n]+)\*(\W|$)/g, "$1<em>$2</em>$3");
// Inline code
text = text.replace(/`([^`\n]+)`/g, "<code>$1</code>");
// Links
text = text.replace(
/\[([^\]]+)\]\(([^)\s]+)\)/g,
'<a href="$2" target="_blank" rel="noopener">$1</a>'
);
// Lists (simple, line-based)
text = text.replace(/(?:^|\n)((?:- .+\n?)+)/g, (_, block) => {
const items = block
.trim()
.split(/\n/)
.map((l) => `<li>${l.replace(/^- /, "")}</li>`)
.join("");
return `\n<ul>${items}</ul>\n`;
});
text = text.replace(/(?:^|\n)((?:\d+\. .+\n?)+)/g, (_, block) => {
const items = block
.trim()
.split(/\n/)
.map((l) => `<li>${l.replace(/^\d+\. /, "")}</li>`)
.join("");
return `\n<ol>${items}</ol>\n`;
});
// Paragraphs (split on blank lines)
text = text
.split(/\n{2,}/)
.map((chunk) => {
const t = chunk.trim();
if (!t) return "";
if (/^<(h\d|ul|ol|pre|blockquote)/.test(t)) return t;
return `<p>${t.replace(/\n/g, "<br/>")}</p>`;
})
.join("\n");
// Restore code blocks
text = text.replace(/\u0000CODE(\d+)\u0000/g, (_, idx) => {
const { lang, body } = codeBlocks[Number(idx)];
const langClass = lang ? ` class="lang-${escapeHtml(lang)}"` : "";
return `<pre><code${langClass}>${escapeHtml(body)}</code></pre>`;
});
return text;
}
// ---------------------------------------------------------------------------
// UI helpers
// ---------------------------------------------------------------------------
function setStatus(state, text) {
els.statusDot.classList.remove("busy", "error");
if (state === "busy") els.statusDot.classList.add("busy");
if (state === "error") els.statusDot.classList.add("error");
els.statusText.textContent = text;
}
function setBusy(busy) {
State.isBusy = busy;
els.sendBtn.disabled = busy;
els.input.disabled = busy;
if (busy) setStatus("busy", "Working…");
else setStatus("ready", "Ready");
}
function clearChat() {
els.chat.innerHTML = "";
if (els.welcome) {
els.chat.appendChild(els.welcome);
}
}
function hideWelcome() {
if (els.welcome && els.welcome.parentElement) {
els.welcome.remove();
}
}
function avatarFor(role) {
if (role === "user") return "U";
if (role === "assistant") return "AI";
if (role === "tool") return "⚙";
if (role === "error") return "!";
return "•";
}
function appendMessage({ role, content, html }) {
hideWelcome();
const wrap = document.createElement("div");
wrap.className = `message ${role}`;
const avatar = document.createElement("div");
avatar.className = "avatar";
avatar.textContent = avatarFor(role);
const body = document.createElement("div");
body.className = "body";
if (html != null) {
body.innerHTML = html;
} else if (role === "user") {
body.textContent = content;
} else {
body.innerHTML = renderMarkdown(content);
}
wrap.appendChild(avatar);
wrap.appendChild(body);
els.chat.appendChild(wrap);
els.chat.scrollTop = els.chat.scrollHeight;
return wrap;
}
function appendToolCall({ name, args, result, isError }) {
hideWelcome();
const wrap = document.createElement("div");
wrap.className = "message tool";
wrap.innerHTML = `<div class="avatar">⚙</div>`;
const body = document.createElement("div");
body.className = "body";
const details = document.createElement("details");
details.className = "tool-call";
const summary = document.createElement("summary");
const argsPreview = typeof args === "string" ? args : JSON.stringify(args);
summary.innerHTML = `
<span class="tool-name">${escapeHtml(name)}</span>
<span class="tool-args">${escapeHtml(argsPreview).slice(0, 240)}</span>
`;
details.appendChild(summary);
const inner = document.createElement("div");
inner.className = "tool-body";
inner.innerHTML = `
<div class="label">Arguments</div>
<pre>${escapeHtml(typeof args === "string" ? args : JSON.stringify(args, null, 2))}</pre>
<div class="label">Result${isError ? " (error)" : ""}</div>
<pre>${escapeHtml(result || "")}</pre>
`;
details.appendChild(inner);
body.appendChild(details);
wrap.appendChild(body);
els.chat.appendChild(wrap);
els.chat.scrollTop = els.chat.scrollHeight;
}
function parseAssistantToolCalls(entry) {
if (!Array.isArray(entry?.tool_calls)) return [];
return entry.tool_calls.map((tc) => {
const fn = tc?.function || {};
let args = fn.arguments ?? tc?.arguments ?? "";
try {
if (typeof args === "string") args = JSON.parse(args);
} catch {}
return {
id: tc?.id || "",
name: fn.name || tc?.name || "tool",
args,
};
});
}
function renderTranscriptEntries(entries) {
const pendingToolCalls = new Map();
for (const entry of entries || []) {
if (!entry || entry.role === "system") continue;
if (entry.role === "assistant") {
if (entry.content && entry.content.trim()) {
appendMessage({ role: "assistant", content: entry.content });
}
for (const call of parseAssistantToolCalls(entry)) {
if (call.id) pendingToolCalls.set(call.id, call);
}
continue;
}
if (entry.role === "tool") {
const call = pendingToolCalls.get(entry.tool_call_id) || {};
let parsedResult = null;
try {
parsedResult = JSON.parse(entry.content || "{}");
} catch {}
appendToolCall({
name: call.name || entry.name || parsedResult?.tool || "tool",
args: call.args ?? "",
result: entry.content || "",
isError: parsedResult?.ok === false || entry.metadata?.error_kind,
});
if (entry.tool_call_id) pendingToolCalls.delete(entry.tool_call_id);
continue;
}
renderTranscriptEntry(entry);
}
for (const call of pendingToolCalls.values()) {
appendToolCall({
name: call.name,
args: call.args,
result: "(pending — no tool result message found)",
});
}
}
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------
function applyServerState(state) {
State.serverState = state;
els.settingsForm.model.value = state.model || "";
els.settingsForm.base_url.value = state.base_url || "";
els.settingsForm.cwd.value = state.cwd || "";
els.settingsForm.allow_shell.checked = !!state.allow_shell;
els.settingsForm.allow_write.checked = !!state.allow_write;
els.cwdMeta.textContent = `cwd: ${state.cwd || "?"}`;
}
async function loadServerState() {
try {
const state = await apiGet("/api/state");
applyServerState(state);
} catch (e) {
setStatus("error", `state: ${e.message}`);
}
}
async function saveSettings(ev) {
ev.preventDefault();
const fd = new FormData(els.settingsForm);
const payload = {
model: fd.get("model") || "",
base_url: fd.get("base_url") || "",
cwd: fd.get("cwd") || "",
allow_shell: els.settingsForm.allow_shell.checked,
allow_write: els.settingsForm.allow_write.checked,
};
try {
setStatus("busy", "Saving settings…");
const state = await apiPost("/api/state", payload);
applyServerState(state);
setStatus("ready", "Settings saved");
} catch (e) {
setStatus("error", `save: ${e.message}`);
}
}
// ---------------------------------------------------------------------------
// Sessions
// ---------------------------------------------------------------------------
async function loadSessions() {
try {
const sessions = await apiGet("/api/sessions");
State.sessions = sessions;
renderSessions();
} catch (e) {
els.sessionList.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>`;
}
}
function renderSessions() {
els.sessionList.innerHTML = "";
if (!State.sessions.length) {
els.sessionList.innerHTML = `<div class="empty-state">No saved sessions yet.</div>`;
return;
}
for (const s of State.sessions) {
const item = document.createElement("button");
item.type = "button";
item.className = "session-item";
if (s.session_id === State.activeSessionId) item.classList.add("active");
const preview = s.preview || "(no preview)";
item.innerHTML = `
<div class="session-preview">${escapeHtml(preview)}</div>
<div class="session-meta">${s.turns} turns · ${s.tool_calls} tools</div>
`;
item.addEventListener("click", () => openSession(s.session_id));
els.sessionList.appendChild(item);
}
}
async function openSession(sessionId) {
try {
setStatus("busy", "Loading session…");
const session = await apiGet(`/api/sessions/${encodeURIComponent(sessionId)}`);
State.activeSessionId = sessionId;
clearChat();
renderTranscriptEntries(session.messages);
renderSessions();
setStatus("ready", `Session ${sessionId.slice(0, 8)}`);
} catch (e) {
setStatus("error", `session: ${e.message}`);
}
}
function newSession() {
State.activeSessionId = null;
clearChat();
renderSessions();
setStatus("ready", "New chat");
els.input.focus();
}
// ---------------------------------------------------------------------------
// Transcript rendering
// ---------------------------------------------------------------------------
function renderTranscriptEntry(entry) {
if (!entry || !entry.role) return;
if (entry.role === "system") return;
if (entry.role === "user") {
appendMessage({ role: "user", content: entry.content || "" });
return;
}
if (entry.role === "assistant") {
if (entry.content && entry.content.trim()) {
appendMessage({ role: "assistant", content: entry.content });
}
if (Array.isArray(entry.tool_calls)) {
for (const tc of entry.tool_calls) {
const name = tc?.function?.name || tc?.name || "tool";
let args = tc?.function?.arguments ?? tc?.arguments ?? "";
try {
if (typeof args === "string") args = JSON.parse(args);
} catch {}
appendToolCall({ name, args, result: "(pending — see following tool message)" });
}
}
return;
}
if (entry.role === "tool") {
const name = entry.name || "tool";
const meta = entry.metadata || {};
appendToolCall({
name,
args: meta.action || "",
result: entry.content || "",
isError: meta.ok === false,
});
return;
}
}
// ---------------------------------------------------------------------------
// Slash commands / skills palette
// ---------------------------------------------------------------------------
async function loadSlashCommands() {
try {
State.slashCommands = await apiGet("/api/slash-commands");
} catch (e) {
State.slashCommands = [];
}
}
async function loadSkills() {
try {
State.skills = await apiGet("/api/skills");
} catch (e) {
State.skills = [];
}
}
function openPalette(mode) {
State.paletteMode = mode;
els.palette.classList.remove("hidden");
els.paletteSearch.value = "";
renderPalette("");
els.paletteSearch.focus();
}
function closePalette() {
els.palette.classList.add("hidden");
State.paletteMode = null;
}
function renderPalette(filter) {
const items = State.paletteMode === "skills" ? State.skills : State.slashCommands;
const f = (filter || "").toLowerCase().trim();
els.paletteList.innerHTML = "";
for (const item of items) {
const display =
State.paletteMode === "skills"
? { name: item.name, desc: item.description }
: { name: `/${item.primary}`, desc: item.description };
if (f && !display.name.toLowerCase().includes(f) && !display.desc.toLowerCase().includes(f))
continue;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "palette-item";
btn.innerHTML = `<span class="palette-name">${escapeHtml(display.name)}</span><span class="palette-desc">${escapeHtml(display.desc || "")}</span>`;
btn.addEventListener("click", () => {
if (State.paletteMode === "skills") {
els.input.value = `Use the ${item.name} skill`;
} else {
els.input.value = `/${item.primary} `;
}
closePalette();
autoSizeInput();
els.input.focus();
});
els.paletteList.appendChild(btn);
}
if (!els.paletteList.children.length) {
els.paletteList.innerHTML = `<div class="empty-state" style="padding:10px;">No matches.</div>`;
}
}
// ---------------------------------------------------------------------------
// Composer
// ---------------------------------------------------------------------------
function autoSizeInput() {
els.input.style.height = "auto";
els.input.style.height = Math.min(els.input.scrollHeight, 200) + "px";
}
async function send() {
if (State.isBusy) return;
const prompt = els.input.value.trim();
if (!prompt) return;
appendMessage({ role: "user", content: prompt });
els.input.value = "";
autoSizeInput();
setBusy(true);
try {
const payload = {
prompt,
resume_session_id: State.activeSessionId || null,
};
const data = await apiPost("/api/chat", payload);
if (data.error) {
appendMessage({ role: "error", content: `${data.error_type}: ${data.error}` });
setStatus("error", "Error");
} else {
// Render tool calls + final response from the transcript so the user
// sees what the agent actually did, not just the final reply.
renderRunResult(data);
State.activeSessionId = data.session_id || State.activeSessionId;
els.usageMeta.textContent =
`turns: ${data.turns} · tools: ${data.tool_calls}` +
(data.usage?.total_tokens
? ` · tokens: ${data.usage.total_tokens}`
: "");
await loadSessions();
}
} catch (e) {
appendMessage({ role: "error", content: e.message });
setStatus("error", "Error");
} finally {
setBusy(false);
els.input.focus();
}
}
function renderRunResult(data) {
// We already rendered the user prompt. The transcript starts with system +
// user + assistant turns; we want to show: any tool messages that happened
// since the last user message, then the final assistant response.
const transcript = data.transcript || [];
let lastUserIndex = -1;
for (let i = transcript.length - 1; i >= 0; i--) {
if (transcript[i].role === "user") {
lastUserIndex = i;
break;
}
}
const tail = transcript.slice(lastUserIndex + 1);
renderTranscriptEntries(tail);
// transcript 通常已经包含最终 assistant 消息;只有缺失时才用 final_output 兜底。
const finalOutput = (data.final_output || "").trim();
const lastAssistant = [...tail]
.reverse()
.find((entry) => entry?.role === "assistant" && (entry.content || "").trim());
const lastAssistantContent = (lastAssistant?.content || "").trim();
if (finalOutput && finalOutput !== lastAssistantContent) {
appendMessage({ role: "assistant", content: data.final_output });
}
}
// ---------------------------------------------------------------------------
// Wire up
// ---------------------------------------------------------------------------
function bind() {
els.input.addEventListener("input", autoSizeInput);
els.input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
});
els.sendBtn.addEventListener("click", send);
els.newSessionBtn.addEventListener("click", newSession);
els.settingsForm.addEventListener("submit", saveSettings);
els.slashBtn.addEventListener("click", () => openPalette("slash"));
els.skillsBtn.addEventListener("click", () => openPalette("skills"));
els.clearBtn.addEventListener("click", async () => {
try {
setStatus("busy", "Clearing…");
await apiPost("/api/clear", {});
newSession();
setStatus("ready", "Cleared");
} catch (e) {
setStatus("error", e.message);
}
});
els.paletteClose.addEventListener("click", closePalette);
els.palette.addEventListener("click", (e) => {
if (e.target === els.palette) closePalette();
});
els.paletteSearch.addEventListener("input", (e) => renderPalette(e.target.value));
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !els.palette.classList.contains("hidden")) {
closePalette();
}
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
openPalette("slash");
}
});
}
async function init() {
bind();
setStatus("ready", "Ready");
await Promise.all([
loadServerState(),
loadSessions(),
loadSlashCommands(),
loadSkills(),
]);
els.input.focus();
}
init();