"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, ">") .replace(/"/g, """) .replace(/'/g, "'"); } 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, "

$1

"); text = text.replace(/^## (.+)$/gm, "

$1

"); text = text.replace(/^# (.+)$/gm, "

$1

"); // Bold / italic text = text.replace(/\*\*([^*\n]+)\*\*/g, "$1"); text = text.replace(/(^|\W)\*([^*\n]+)\*(\W|$)/g, "$1$2$3"); // Inline code text = text.replace(/`([^`\n]+)`/g, "$1"); // Links text = text.replace( /\[([^\]]+)\]\(([^)\s]+)\)/g, '$1' ); // Lists (simple, line-based) text = text.replace(/(?:^|\n)((?:- .+\n?)+)/g, (_, block) => { const items = block .trim() .split(/\n/) .map((l) => `
  • ${l.replace(/^- /, "")}
  • `) .join(""); return `\n\n`; }); text = text.replace(/(?:^|\n)((?:\d+\. .+\n?)+)/g, (_, block) => { const items = block .trim() .split(/\n/) .map((l) => `
  • ${l.replace(/^\d+\. /, "")}
  • `) .join(""); return `\n
      ${items}
    \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 `

    ${t.replace(/\n/g, "
    ")}

    `; }) .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 `
    ${escapeHtml(body)}
    `; }); 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 = `
    `; 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 = ` ${escapeHtml(name)} ${escapeHtml(argsPreview).slice(0, 240)} `; details.appendChild(summary); const inner = document.createElement("div"); inner.className = "tool-body"; inner.innerHTML = `
    Arguments
    ${escapeHtml(typeof args === "string" ? args : JSON.stringify(args, null, 2))}
    Result${isError ? " (error)" : ""}
    ${escapeHtml(result || "")}
    `; 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 = `
    ${escapeHtml(e.message)}
    `; } } function renderSessions() { els.sessionList.innerHTML = ""; if (!State.sessions.length) { els.sessionList.innerHTML = `
    No saved sessions yet.
    `; 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 = `
    ${escapeHtml(preview)}
    ${s.turns} turns · ${s.tool_calls} tools
    `; 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 = `${escapeHtml(display.name)}${escapeHtml(display.desc || "")}`; 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 = `
    No matches.
    `; } } // --------------------------------------------------------------------------- // 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();