(function () {
const $ = (id) => document.getElementById(id);
const els = {
statusPill: $("status-pill"),
statusText: $("status-text"),
modelLine: $("model-line"),
chatLog: $("chat-log"),
form: $("chat-form"),
prompt: $("prompt"),
send: $("send-btn"),
widgets: $("widgets"),
timeline: $("timeline"),
summary: $("summary"),
};
function initSectionCollapse() {
const sections = document.querySelectorAll(".section");
for (const section of sections) {
const head = section.querySelector(".panel-head");
if (!head) continue;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "section-toggle";
btn.setAttribute("aria-label", "toggle section");
btn.setAttribute("aria-expanded", "true");
btn.textContent = "▾";
btn.addEventListener("click", () => {
const collapsed = section.classList.toggle("collapsed");
btn.setAttribute("aria-expanded", collapsed ? "false" : "true");
btn.textContent = collapsed ? "▸" : "▾";
});
head.appendChild(btn);
}
}
function setBusy(on) {
els.statusPill.classList.toggle("busy", on);
document.title = on ? "assistant · …" : "assistant";
}
function appendTimeline(kind, text) {
const li = document.createElement("li");
const t = document.createElement("div");
t.className = "t";
t.textContent = kind;
const body = document.createElement("div");
body.className = "body";
body.textContent = text;
li.appendChild(t);
li.appendChild(body);
els.timeline.appendChild(li);
li.scrollIntoView({ block: "nearest" });
}
function appendChat(role, who, text) {
const wrap = document.createElement("div");
wrap.className = "bubble " + role;
const w = document.createElement("div");
w.className = "who";
w.textContent = who;
const b = document.createElement("div");
b.textContent = text;
wrap.appendChild(w);
wrap.appendChild(b);
els.chatLog.appendChild(wrap);
wrap.scrollIntoView({ block: "nearest" });
}
function clearUIChat() {
els.chatLog.innerHTML = "";
els.timeline.innerHTML = "";
els.summary.textContent = "";
}
function appendAssistantThinking() {
const wrap = document.createElement("div");
wrap.className = "bubble assistant thinking";
const w = document.createElement("div");
w.className = "who";
w.textContent = "assistant";
const b = document.createElement("div");
b.className = "body";
b.textContent = "thinking";
wrap.appendChild(w);
wrap.appendChild(b);
els.chatLog.appendChild(wrap);
wrap.scrollIntoView({ block: "nearest" });
return { wrap, textEl: b };
}
function eventBody(ev) {
switch (ev.type) {
case "phase":
return ev.message || ev.detail || "";
case "llm_request":
return `round ${ev.round}`;
case "llm_reply":
return (
`round ${ev.round} · pending tools: ${ev.tool_calls || 0}` +
(ev.has_content && ev.preview ? `\n${ev.preview}` : "")
);
case "tool_call":
return `${ev.tool}\nargs: ${ev.args || "{}"}`;
case "tool_result":
return `${ev.tool} · ${ev.ok ? "ok" : "error"}\n${ev.preview || ""}`;
case "final":
return "done";
case "error":
return ev.message || "";
default:
return JSON.stringify(ev);
}
}
function eventTitle(ev) {
switch (ev.type) {
case "phase":
return "prepare";
case "llm_request":
return "calling model";
case "llm_reply":
return "model reply";
case "tool_call":
return "tool call";
case "tool_result":
return "tool result";
case "final":
return "done";
case "error":
return "error";
default:
return ev.type || "event";
}
}
function renderWidgets(status) {
els.widgets.innerHTML = "";
const widgets = [
{ type: "status", title: "overview" },
{ type: "automations", title: "automations" },
{ type: "tools", title: "tools" },
{ type: "memory_context", title: "memory" },
];
for (const w of widgets) {
const card = document.createElement("div");
card.className = "widget-card";
const title = document.createElement("div");
title.className = "widget-title";
title.textContent = w.title;
card.appendChild(title);
if (w.type === "status") {
const lines = document.createElement("div");
lines.className = "status-lines";
lines.textContent =
`status: ${status.status || "idle"}\ncurrent prompt: ${status.current_prompt || "-"}\n` +
`uptime: ${status.uptime_seconds || 0}s`;
card.appendChild(lines);
} else if (w.type === "tools") {
const table = document.createElement("table");
table.className = "kv-table";
table.innerHTML = "<thead><tr><th>Tool</th><th>Calls</th><th>avg</th></tr></thead>";
const body = document.createElement("tbody");
const rows = [];
const llm = status.llm || {};
rows.push({
name: "llm",
count: Number(llm.count || 0),
avg_ms: Number(llm.avg_ms || 0),
});
for (const r of status.tools?.by_name || []) {
rows.push(r);
}
for (let i = 0; i < Math.min(8, rows.length); i++) {
const r = rows[i];
const tr = document.createElement("tr");
tr.innerHTML = `<td>${r.name}</td><td>${r.count}</td><td>${Number(r.avg_ms || 0).toFixed(1)}</td>`;
body.appendChild(tr);
}
table.appendChild(body);
card.appendChild(table);
} else if (w.type === "automations") {
const list = document.createElement("div");
list.className = "status-lines";
const autos = status.automations || [];
list.textContent = autos.length
? autos
.map((a) => `- ${a.name}\n next: ${a.next_run || "unknown"}\n last: ${a.last_run || "-"}`)
.join("\n\n")
: "no automations configured.";
card.appendChild(list);
} else if (w.type === "memory_context") {
const s = status.memory_store || {};
const h = status.history || {};
const c = status.context_consumption || {};
const line = document.createElement("div");
line.className = "status-lines";
line.textContent =
`context used: ${Number(c.used_chars || 0).toLocaleString()} chars\n` +
`chat session: ${Number(h.count || 0)} msgs, ${Number(h.total_chars || 0).toLocaleString()} chars\n` +
`long memory: ${Number(s.long_count || 0)} items, ${Number(s.long_chars || 0).toLocaleString()} chars`;
card.appendChild(line);
}
els.widgets.appendChild(card);
}
}
function handleSSEEvent(ev) {
appendTimeline(eventTitle(ev), eventBody(ev));
if (ev.type === "final") {
els.summary.textContent = ev.text || "";
}
if (ev.type === "error") {
const msg = ev.message || "Unknown error";
els.summary.textContent = (els.summary.textContent ? `${els.summary.textContent}\n\n` : "") + msg;
appendChat("assistant", "assistant", `error: ${msg}`);
}
}
async function fetchStatusAndUpdate() {
const res = await fetch("/api/status", { cache: "no-store" });
if (!res.ok) return;
const st = await res.json();
const working = st.status === "working";
setBusy(working);
els.statusText.textContent = working ? "working..." : "ready";
const model = (st.model || "").trim();
els.modelLine.textContent = model ? `model: ${model}` : "model: -";
renderWidgets(st);
}
function startStatusPolling() {
fetchStatusAndUpdate().catch(() => {});
window.__statusPoll = setInterval(() => {
fetchStatusAndUpdate().catch(() => {});
}, 2000);
}
async function runPrompt(text) {
let thinking = null;
let thinkingInterval = null;
const stopThinking = () => {
if (thinkingInterval) clearInterval(thinkingInterval);
if (thinking?.wrap && thinking.wrap.parentNode) thinking.wrap.parentNode.removeChild(thinking.wrap);
thinking = null;
thinkingInterval = null;
};
setBusy(true);
els.statusText.textContent = "working...";
els.summary.textContent = "";
appendChat("user", "you", text);
thinking = appendAssistantThinking();
let dots = 0;
thinkingInterval = setInterval(() => {
dots = (dots + 1) % 4;
thinking.textEl.textContent = "thinking" + ".".repeat(dots);
}, 450);
const res = await fetch("/ask/stream", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
body: JSON.stringify({ prompt: text }),
});
if (!res.ok || !res.body) {
const err = await res.text();
stopThinking();
appendTimeline("error", err || res.statusText);
setBusy(false);
els.statusText.textContent = "ready";
return;
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
let finalText = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() || "";
for (const block of parts) {
const line = block.trim();
if (!line.startsWith("data:")) continue;
try {
const ev = JSON.parse(line.slice(5).trim());
handleSSEEvent(ev);
if (ev.type === "final") finalText = ev.text || "";
} catch {}
}
}
stopThinking();
if (finalText) appendChat("assistant", "assistant", finalText);
setBusy(false);
els.statusText.textContent = "ready";
}
els.form.addEventListener("submit", async (e) => {
e.preventDefault();
const text = els.prompt.value.trim();
if (!text) return;
if (text === "/clear") {
els.prompt.value = "";
clearUIChat();
return;
}
els.prompt.value = "";
els.send.disabled = true;
try {
await runPrompt(text);
} catch (err) {
appendTimeline("error", String(err));
} finally {
els.send.disabled = false;
els.prompt.focus();
}
});
els.prompt.addEventListener("keydown", (e) => {
if (e.key !== "Enter" || e.shiftKey) return;
e.preventDefault();
if (els.send.disabled) return;
if (els.form.requestSubmit) {
els.form.requestSubmit();
return;
}
els.form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
els.statusText.textContent = "ready";
els.modelLine.textContent = "model: -";
initSectionCollapse();
startStatusPolling();
})();