filename:
assistant/server/static/app.js
branch:
main
back to repo
(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"),
cancel: $("cancel-btn"),
widgets: $("widgets"),
activityLog: $("activity-log"),
summary: $("summary"),
calGrid: $("cal-grid"),
calMonthLabel: $("cal-month-label"),
calPrev: $("cal-prev"),
calNext: $("cal-next"),
calToday: $("cal-today"),
calSelectedLabel: $("cal-selected-label"),
calDayItems: $("cal-day-items"),
calQuickForm: $("cal-quick-form"),
calQuickTitle: $("cal-quick-title"),
calQuickWhen: $("cal-quick-when"),
calPendingList: $("cal-pending-list"),
calPendingWrap: $("cal-pending-wrap"),
scratchpadPanel: $("scratchpad-panel"),
scratchpadEditor: $("scratchpad-editor"),
scratchpadRendered: $("scratchpad-rendered"),
scratchpadRenderToggle: $("scratchpad-render-toggle"),
scratchpadHint: $("scratchpad-hint"),
};
const SCRATCHPAD_RENDER_KEY = "assistant-scratchpad-render-v2";
let scratchpadRenderPreview = (() => {
try {
const v = sessionStorage.getItem(SCRATCHPAD_RENDER_KEY);
if (v === null) return true;
return v === "1";
} catch {
return true;
}
})();
let scratchpadLoadedOnce = false;
let scratchpadSaveTimer = null;
let scratchpadPollTimer = null;
const UI_STORAGE_KEY = "assistant-ui-v1";
const chatHistory = [];
const activityHistory = [];
let summaryMarkdown = "";
let uiRestoring = false;
let persistTimer = null;
const pad2 = (n) => String(n).padStart(2, "0");
const calState = {
viewYear: new Date().getFullYear(),
viewMonth: new Date().getMonth(),
selectedKey: "",
items: [],
pending: [],
lastRevision: -1,
loading: false,
};
function isoDateLocal(d) {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
function monthRangeUTC(y, m0) {
const from = `${y}-${pad2(m0 + 1)}-01`;
const next = new Date(Date.UTC(y, m0 + 1, 1));
const to = `${next.getUTCFullYear()}-${pad2(next.getUTCMonth() + 1)}-01`;
return { from, to };
}
function addDaysToKey(dayKey, days) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(dayKey || ""))) return "";
const parts = String(dayKey).split("-").map((x) => parseInt(x, 10));
if (parts.length !== 3 || parts.some((n) => Number.isNaN(n))) return "";
const d = new Date(parts[0], parts[1] - 1, parts[2]);
d.setDate(d.getDate() + days);
return isoDateLocal(d);
}
function visibleGridRange(y, m) {
const first = new Date(y, m, 1);
const startWeekday = first.getDay();
const start = new Date(y, m, 1 - startWeekday);
const endExclusive = new Date(start);
endExclusive.setDate(start.getDate() + 42);
return {
from: isoDateLocal(start),
to: isoDateLocal(endExclusive),
};
}
function windowForCalendarFetch() {
const monthWin = visibleGridRange(calState.viewYear, calState.viewMonth);
const selectedStart = /^\d{4}-\d{2}-\d{2}$/.test(String(calState.selectedKey || ""))
? calState.selectedKey
: isoDateLocal(new Date());
const selectedEnd = addDaysToKey(selectedStart, 14) || selectedStart;
const from = monthWin.from < selectedStart ? monthWin.from : selectedStart;
const to = monthWin.to > selectedEnd ? monthWin.to : selectedEnd;
return { from, to };
}
function primaryDayKey(it) {
const s = it.due_at || "";
if (!s) return "";
const m = String(s).match(/^(\d{4}-\d{2}-\d{2})/);
if (m) return m[1];
const d = new Date(s);
if (isNaN(d.getTime())) return "";
return isoDateLocal(d);
}
function itemOnDay(it, dayKey) {
const k = primaryDayKey(it);
return k === dayKey;
}
const CAL_WHEN_UNDATED = "undated";
/** How many days ahead (including today) appear in the when dropdown. */
const WHEN_ROLLING_DAYS = 45;
function isUndated(it) {
const d = String(it.due_at || "").trim();
return !d;
}
function countForDay(dayKey) {
return calState.items.filter((it) => itemOnDay(it, dayKey)).length;
}
async function loadCalendarFromServer() {
if (calState.loading) return;
calState.loading = true;
try {
const { from, to } = windowForCalendarFetch();
const [itemsRes, pendRes] = await Promise.all([
fetch(`/api/calendar/items?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, { cache: "no-store" }),
fetch("/api/calendar/pending", { cache: "no-store" }),
]);
if (itemsRes.ok) {
const data = await itemsRes.json();
calState.items = Array.isArray(data.items) ? data.items : [];
if (data.revision != null) calState.lastRevision = Number(data.revision);
}
if (pendRes.ok) {
const data = await pendRes.json();
calState.pending = Array.isArray(data.pending) ? data.pending : [];
if (data.revision != null) calState.lastRevision = Number(data.revision);
}
renderCalendarPanel();
} catch {
/* ignore */
} finally {
calState.loading = false;
}
}
function renderCalendarPanel() {
if (!els.calGrid) return;
const y = calState.viewYear;
const m = calState.viewMonth;
els.calMonthLabel.textContent = new Date(y, m, 1).toLocaleString(undefined, {
month: "long",
year: "numeric",
});
const first = new Date(y, m, 1);
const startWeekday = first.getDay();
const daysInMonth = new Date(y, m + 1, 0).getDate();
const prevMonthDays = new Date(y, m, 0).getDate();
if (!calState.selectedKey) {
calState.selectedKey = isoDateLocal(new Date());
}
const cells = [];
for (let i = 0; i < startWeekday; i++) {
const day = prevMonthDays - startWeekday + i + 1;
const pm = m === 0 ? 11 : m - 1;
const py = m === 0 ? y - 1 : y;
const key = `${py}-${pad2(pm + 1)}-${pad2(day)}`;
cells.push({ key, label: day, muted: true });
}
for (let d = 1; d <= daysInMonth; d++) {
const key = `${y}-${pad2(m + 1)}-${pad2(d)}`;
cells.push({ key, label: d, muted: false });
}
const trailing = 42 - cells.length;
for (let i = 1; i <= trailing; i++) {
const nm = m === 11 ? 0 : m + 1;
const ny = m === 11 ? y + 1 : y;
const key = `${ny}-${pad2(nm + 1)}-${pad2(i)}`;
cells.push({ key, label: i, muted: true });
}
els.calGrid.innerHTML = "";
for (const c of cells) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "cal-cell" + (c.muted ? " muted-day" : "");
if (c.key === calState.selectedKey) btn.classList.add("selected");
const n = document.createElement("span");
n.className = "cal-day-num";
n.textContent = String(c.label);
btn.appendChild(n);
const cnt = countForDay(c.key);
if (cnt > 0) {
const dots = document.createElement("span");
dots.className = "cal-dots";
dots.textContent = cnt > 3 ? "•••+" : "•".repeat(Math.min(cnt, 3));
btn.appendChild(dots);
}
btn.addEventListener("click", () => {
calState.selectedKey = c.key;
renderCalendarPanel();
});
els.calGrid.appendChild(btn);
}
const sel = calState.selectedKey;
if (els.calSelectedLabel) {
const today = new Date();
const todayText = today.toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
els.calSelectedLabel.textContent = `today is ${todayText}.`;
}
renderDayDetail(sel);
renderPending();
syncQuickFormDefaults();
}
/** YYYY-MM-DD for today + next (WHEN_ROLLING_DAYS-1) days; includes extraKey if outside that window. */
function rollingWhenDateKeys(extraKey) {
const keys = [];
const set = new Set();
const start = new Date();
start.setHours(0, 0, 0, 0);
for (let i = 0; i < WHEN_ROLLING_DAYS; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
const k = isoDateLocal(d);
set.add(k);
keys.push(k);
}
const ek = String(extraKey || "").trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(ek) && !set.has(ek)) {
keys.push(ek);
keys.sort();
}
return keys;
}
function formatDatePickLabel(iso) {
const parts = String(iso).split("-").map((x) => parseInt(x, 10));
if (parts.length !== 3 || parts.some((n) => Number.isNaN(n))) return iso;
const dt = new Date(parts[0], parts[1] - 1, parts[2]);
return dt.toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
}
function rebuildCalWhenSelect() {
const sel = els.calQuickWhen;
if (!sel) return;
const prev = sel.value;
sel.innerHTML = "";
const oU = document.createElement("option");
oU.value = CAL_WHEN_UNDATED;
oU.textContent = "No date";
sel.appendChild(oU);
const dates = rollingWhenDateKeys(calState.selectedKey);
for (const k of dates) {
const o = document.createElement("option");
o.value = k;
o.textContent = formatDatePickLabel(k);
sel.appendChild(o);
}
if (prev === CAL_WHEN_UNDATED) {
sel.value = CAL_WHEN_UNDATED;
} else {
const preferred =
dates.includes(calState.selectedKey) ? calState.selectedKey : dates.includes(prev) ? prev : dates[0];
if (preferred) sel.value = preferred;
}
}
function syncQuickFormDefaults() {
rebuildCalWhenSelect();
}
function renderDayDetail(dayKey) {
function weekdayNameFromKey(key) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(key || ""))) return "";
const y = parseInt(key.slice(0, 4), 10);
const m = parseInt(key.slice(5, 7), 10) - 1;
const d = parseInt(key.slice(8, 10), 10);
const dt = new Date(y, m, d);
if (isNaN(dt.getTime())) return "";
return dt.toLocaleDateString(undefined, { weekday: "long" });
}
if (!els.calDayItems) return;
els.calDayItems.innerHTML = "";
if (!dayKey) return;
const undated = calState.items.filter((it) => isUndated(it));
function appendSection(title, list, emptyText) {
const head = document.createElement("li");
head.className = "cal-section-head";
head.textContent = title;
els.calDayItems.appendChild(head);
if (list.length === 0) {
const li = document.createElement("li");
li.className = "muted small";
li.textContent = emptyText;
els.calDayItems.appendChild(li);
return;
}
for (const it of list) {
appendCalendarItemRow(it);
}
}
function appendCalendarItemRow(it) {
const li = document.createElement("li");
const dueKey = primaryDayKey(it);
const todayKey = isoDateLocal(new Date());
const isSelectedDay = !!dueKey && dueKey === dayKey;
const isToday = !!dueKey && dueKey === todayKey;
if (isSelectedDay) li.classList.add("cal-item-selected-day");
else if (isToday) li.classList.add("cal-item-today");
const t = document.createElement("div");
t.className = "cal-item-title";
const pill = document.createElement("span");
pill.className = "cal-status-pill " + String(it.status || "open");
pill.textContent = String(it.status || "open");
const txt = document.createElement("span");
txt.textContent = it.title || "(untitled)";
t.appendChild(pill);
t.appendChild(txt);
li.appendChild(t);
const meta = document.createElement("div");
meta.className = "cal-item-meta";
const bits = [];
if (isUndated(it)) bits.push("no date");
if (it.due_at) {
const wk = weekdayNameFromKey(primaryDayKey(it));
bits.push(wk ? `due ${it.due_at}, ${wk}` : `due ${it.due_at}`);
}
meta.textContent = bits.join(" · ");
li.appendChild(meta);
const actions = document.createElement("div");
actions.className = "cal-item-actions";
if (it.status === "open") {
const b1 = document.createElement("button");
b1.type = "button";
b1.textContent = "done";
b1.addEventListener("click", () => completeItem(it.id));
actions.appendChild(b1);
}
const b2 = document.createElement("button");
b2.type = "button";
b2.textContent = "delete";
b2.addEventListener("click", () => deleteItem(it.id));
actions.appendChild(b2);
li.appendChild(actions);
els.calDayItems.appendChild(li);
}
appendSection("undated", undated, "no undated items");
const selectedLabel = /^\d{4}-\d{2}-\d{2}$/.test(dayKey)
? new Date(
parseInt(dayKey.slice(0, 4), 10),
parseInt(dayKey.slice(5, 7), 10) - 1,
parseInt(dayKey.slice(8, 10), 10)
).toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
})
: dayKey;
const rangeEnd = addDaysToKey(dayKey, 14);
const upcoming = calState.items
.filter((it) => {
if (isUndated(it)) return false;
const k = primaryDayKey(it);
if (!k) return false;
return k >= dayKey && (!rangeEnd || k < rangeEnd);
})
.sort((a, b) => {
const ka = primaryDayKey(a);
const kb = primaryDayKey(b);
if (ka !== kb) return ka < kb ? -1 : 1;
return Number(a.id || 0) - Number(b.id || 0);
});
appendSection(`selected . ${selectedLabel}`, upcoming, "no items in next two weeks");
}
async function completeItem(id) {
const it = calState.items.find((x) => x.id === id);
if (!it) return;
let due_at = String(it.due_at || "").trim();
if (isUndated(it)) {
const dk = String(calState.selectedKey || "").trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(dk)) {
due_at = dk;
} else {
due_at = isoDateLocal(new Date());
}
}
const body = {
title: it.title,
notes: it.notes || "",
status: "done",
due_at,
};
const res = await fetch(`/api/calendar/items/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) await loadCalendarFromServer();
}
async function deleteItem(id) {
if (!confirm("Delete this item?")) return;
const res = await fetch(`/api/calendar/items/${id}`, { method: "DELETE" });
if (res.ok) await loadCalendarFromServer();
}
/** API returns `payload` as a nested object; older paths may send a JSON string. */
function parsePendingPayload(raw) {
if (raw == null) return null;
if (typeof raw === "object") return raw;
try {
return JSON.parse(String(raw));
} catch {
return null;
}
}
function pendingDueFromItem(item) {
if (!item || typeof item !== "object") return "";
const d = String(item.due_at || item.dueAt || "").trim();
if (!d) return "";
const m = d.match(/^(\d{4}-\d{2}-\d{2})/);
return m ? m[1] : "";
}
function storeItemByID(id) {
return calState.items.find((x) => x.id === id) || null;
}
/** Load one item by id (not limited to the visible month). */
async function fetchCalendarItemById(id) {
const res = await fetch(`/api/calendar/items/${id}`, { cache: "no-store" });
if (!res.ok) return null;
const data = await res.json();
if (data.revision != null) calState.lastRevision = Number(data.revision);
return data.item || null;
}
/** One line for the calendar day (localized, no duplicate ISO). */
function formatPendingDateDisplay(iso) {
if (!iso) return "no date";
return formatDatePickLabel(iso);
}
function renderPending() {
if (!els.calPendingList) return;
els.calPendingList.innerHTML = "";
const list = calState.pending || [];
if (els.calPendingWrap) {
els.calPendingWrap.style.display = list.length === 0 ? "none" : "block";
}
if (list.length === 0) {
return;
}
for (const p of list) {
const li = document.createElement("li");
const payload = parsePendingPayload(p.payload);
const action = payload && payload.action ? String(payload.action) : "?";
const row = document.createElement("div");
row.className = "cal-pending-row";
const body = document.createElement("div");
body.className = "cal-pending-body";
const line1 = document.createElement("div");
line1.className = "cal-pending-line";
const badge = document.createElement("span");
badge.className = "cal-pending-badge";
badge.textContent = `#${p.id}`;
const actEl = document.createElement("span");
actEl.className = "cal-pending-action";
actEl.textContent = action;
const nameEl = document.createElement("span");
nameEl.className = "cal-pending-title-text";
const dateEl = document.createElement("div");
dateEl.className = "cal-pending-action";
const item = (payload && payload.item) || {};
const targetNumId = payload && payload.id ? Number(payload.id) : 0;
if (action === "create") {
nameEl.textContent = String(item.title || "").trim() || "(no title)";
dateEl.textContent = formatPendingDateDisplay(pendingDueFromItem(item));
} else if (action === "update") {
nameEl.textContent = String(item.title || "").trim() || "(no title)";
let iso = pendingDueFromItem(item);
const fromStore = targetNumId > 0 ? storeItemByID(targetNumId) : null;
if (fromStore) {
if (!String(item.title || "").trim()) nameEl.textContent = String(fromStore.title || "").trim() || "(no title)";
if (!iso) iso = pendingDueFromItem(fromStore);
}
dateEl.textContent = formatPendingDateDisplay(iso);
if (targetNumId > 0 && !fromStore) {
fetchCalendarItemById(targetNumId).then((it) => {
if (!nameEl.isConnected) return;
if (it) {
if (!String(item.title || "").trim()) nameEl.textContent = String(it.title || "").trim() || "(no title)";
const iso2 = pendingDueFromItem(item) || pendingDueFromItem(it);
dateEl.textContent = formatPendingDateDisplay(iso2);
}
});
}
} else {
const t = targetNumId > 0 ? storeItemByID(targetNumId) : null;
if (t) {
nameEl.textContent = String(t.title || "").trim() || `item #${targetNumId}`;
dateEl.textContent = formatPendingDateDisplay(pendingDueFromItem(t));
} else if (targetNumId > 0) {
nameEl.textContent = String(p.summary || action || `item #${targetNumId}`).trim();
dateEl.textContent = "…";
fetchCalendarItemById(targetNumId)
.then((it) => {
if (!dateEl.isConnected) return;
if (it) {
nameEl.textContent = String(it.title || "").trim() || `item #${targetNumId}`;
dateEl.textContent = formatPendingDateDisplay(pendingDueFromItem(it));
} else {
dateEl.textContent = "no date";
}
})
.catch(() => {
if (dateEl.isConnected) dateEl.textContent = "-";
});
} else {
nameEl.textContent = String(p.summary || action || "(pending)").trim();
dateEl.textContent = "-";
}
}
line1.appendChild(badge);
line1.appendChild(actEl);
line1.appendChild(nameEl);
body.appendChild(line1);
body.appendChild(dateEl);
const actions = document.createElement("div");
actions.className = "cal-pending-actions";
const ok = document.createElement("button");
ok.type = "button";
ok.className = "confirm";
ok.textContent = "confirm";
ok.addEventListener("click", () => confirmPending(p.id));
const no = document.createElement("button");
no.type = "button";
no.textContent = "reject";
no.addEventListener("click", () => rejectPending(p.id));
actions.appendChild(ok);
actions.appendChild(no);
row.appendChild(body);
row.appendChild(actions);
li.appendChild(row);
els.calPendingList.appendChild(li);
}
}
async function confirmPending(id) {
const res = await fetch(`/api/calendar/pending/${id}/confirm`, { method: "POST" });
if (res.ok) await loadCalendarFromServer();
}
async function rejectPending(id) {
const res = await fetch(`/api/calendar/pending/${id}/reject`, { method: "POST" });
if (res.ok) await loadCalendarFromServer();
}
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);
const shouldStartCollapsed =
section.classList.contains("dashboard-panel") ||
section.classList.contains("activity-panel") ||
section.classList.contains("calendar-panel") ||
section.classList.contains("scratchpad-panel");
if (shouldStartCollapsed) {
section.classList.add("collapsed");
btn.setAttribute("aria-expanded", "false");
btn.textContent = "▸";
}
}
}
function setBusy(on) {
els.statusPill.classList.toggle("busy", on);
document.title = on ? "assistant · …" : "assistant";
}
function escapeHTML(s) {
return String(s || "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">");
}
function renderInlineMarkdown(s) {
let out = escapeHTML(s);
out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
out = out.replace(/`([^`]+)`/g, "<code>$1</code>");
out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
out = out.replace(/\*([^*]+)\*/g, "<em>$1</em>");
return out;
}
function markdownTableAt(lines, idx) {
const hasPipe = (line) => line.includes("|");
const isSep = (line) => /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line || "");
if (idx + 1 >= lines.length) return null;
if (!hasPipe(lines[idx]) || !isSep(lines[idx + 1])) return null;
let end = idx + 2;
while (end < lines.length && hasPipe(lines[end]) && lines[end].trim() !== "") end++;
return { end };
}
function parseTableRow(line) {
const t = String(line || "").trim();
const core = t.replace(/^\|/, "").replace(/\|$/, "");
return core.split("|").map((x) => x.trim());
}
function renderMarkdown(text) {
const src = String(text || "").replace(/\r\n/g, "\n");
const lines = src.split("\n");
const html = [];
let i = 0;
let inUL = false;
const closeUL = () => {
if (inUL) {
html.push("</ul>");
inUL = false;
}
};
while (i < lines.length) {
const line = lines[i];
const t = line.trim();
if (!t) {
closeUL();
i++;
continue;
}
const tbl = markdownTableAt(lines, i);
if (tbl) {
closeUL();
const header = parseTableRow(lines[i]);
html.push('<table class="md-table"><thead><tr>');
for (const c of header) html.push(`<th>${renderInlineMarkdown(c)}</th>`);
html.push("</tr></thead><tbody>");
for (let r = i + 2; r < tbl.end; r++) {
const cols = parseTableRow(lines[r]);
html.push("<tr>");
for (const c of cols) html.push(`<td>${renderInlineMarkdown(c)}</td>`);
html.push("</tr>");
}
html.push("</tbody></table>");
i = tbl.end;
continue;
}
if (t.startsWith("### ")) {
closeUL();
html.push(`<h4>${renderInlineMarkdown(t.slice(4))}</h4>`);
i++;
continue;
}
if (t.startsWith("## ")) {
closeUL();
html.push(`<h3>${renderInlineMarkdown(t.slice(3))}</h3>`);
i++;
continue;
}
if (t.startsWith("# ")) {
closeUL();
html.push(`<h2>${renderInlineMarkdown(t.slice(2))}</h2>`);
i++;
continue;
}
if (t.startsWith("- ") || t.startsWith("* ")) {
if (!inUL) {
html.push("<ul>");
inUL = true;
}
html.push(`<li>${renderInlineMarkdown(t.slice(2))}</li>`);
i++;
continue;
}
closeUL();
html.push(`<p>${renderInlineMarkdown(t)}</p>`);
i++;
}
closeUL();
return html.join("");
}
function appendBubble(container, role, who, text, opts) {
const silent = opts && opts.silent;
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");
if (role === "assistant") {
b.className = "md-content";
b.innerHTML = renderMarkdown(text);
} else {
b.textContent = text;
}
wrap.appendChild(w);
wrap.appendChild(b);
container.appendChild(wrap);
if (!silent) {
wrap.scrollIntoView({ block: "nearest" });
}
}
function persistUIState() {
if (uiRestoring) return;
try {
localStorage.setItem(
UI_STORAGE_KEY,
JSON.stringify({
v: 1,
chat: chatHistory,
activity: activityHistory,
summaryMd: summaryMarkdown,
})
);
} catch (_) {}
}
function schedulePersistUI() {
if (uiRestoring) return;
clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
persistTimer = null;
persistUIState();
}, 50);
}
function loadPersistedUI() {
let raw;
try {
raw = localStorage.getItem(UI_STORAGE_KEY);
} catch (_) {
return;
}
if (!raw) return;
let data;
try {
data = JSON.parse(raw);
} catch (_) {
return;
}
if (!data || data.v !== 1) return;
uiRestoring = true;
try {
els.chatLog.innerHTML = "";
els.activityLog.innerHTML = "";
chatHistory.length = 0;
activityHistory.length = 0;
for (const m of data.chat || []) {
if (m && m.role && m.who != null && m.text != null) {
appendBubble(els.chatLog, m.role, m.who, m.text, { silent: true });
chatHistory.push({ role: m.role, who: m.who, text: m.text });
}
}
for (const m of data.activity || []) {
if (m && m.who != null && m.text != null) {
appendBubble(els.activityLog, "assistant", m.who, m.text, { silent: true });
activityHistory.push({ who: m.who, text: m.text });
}
}
summaryMarkdown = typeof data.summaryMd === "string" ? data.summaryMd : "";
if (els.summary) {
if (summaryMarkdown) {
els.summary.classList.add("md-content");
els.summary.innerHTML = renderMarkdown(summaryMarkdown);
} else {
els.summary.textContent = "";
els.summary.classList.remove("md-content");
}
}
} finally {
uiRestoring = false;
}
}
function appendChat(role, who, text) {
appendBubble(els.chatLog, role, who, text);
chatHistory.push({ role, who, text });
schedulePersistUI();
}
function appendActivity(who, text) {
appendBubble(els.activityLog, "assistant", who, text);
activityHistory.push({ who, text });
schedulePersistUI();
}
function clearUIChat() {
els.chatLog.innerHTML = "";
els.activityLog.innerHTML = "";
if (els.summary) {
els.summary.textContent = "";
els.summary.classList.remove("md-content");
}
chatHistory.length = 0;
activityHistory.length = 0;
summaryMarkdown = "";
persistUIState();
}
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 stopScratchpadTimers() {
clearTimeout(scratchpadSaveTimer);
scratchpadSaveTimer = null;
if (scratchpadPollTimer) {
clearInterval(scratchpadPollTimer);
scratchpadPollTimer = null;
}
}
function refreshScratchpadRenderedIfPreview() {
if (!scratchpadRenderPreview || !els.scratchpadRendered || !els.scratchpadEditor) return;
els.scratchpadRendered.innerHTML = renderMarkdown(els.scratchpadEditor.value);
}
function applyScratchpadRenderUI() {
if (!els.scratchpadEditor || !els.scratchpadRendered || !els.scratchpadRenderToggle) return;
if (scratchpadRenderPreview) {
refreshScratchpadRenderedIfPreview();
els.scratchpadEditor.setAttribute("hidden", "");
els.scratchpadRendered.removeAttribute("hidden");
els.scratchpadRenderToggle.textContent = "Edit";
els.scratchpadRenderToggle.setAttribute("aria-pressed", "true");
els.scratchpadRenderToggle.setAttribute("aria-label", "Edit Markdown source");
} else {
els.scratchpadEditor.removeAttribute("hidden");
els.scratchpadRendered.setAttribute("hidden", "");
els.scratchpadRenderToggle.textContent = "Preview";
els.scratchpadRenderToggle.setAttribute("aria-pressed", "false");
els.scratchpadRenderToggle.setAttribute("aria-label", "Show rendered Markdown");
}
}
function startScratchpadPolling() {
if (!els.scratchpadPanel || els.scratchpadPanel.hidden || scratchpadPollTimer) return;
scratchpadPollTimer = setInterval(() => {
if (!els.scratchpadPanel || els.scratchpadPanel.hidden) return;
if (document.activeElement === els.scratchpadEditor) return;
pullScratchpadFromServerIfIdle().catch(() => {});
}, 4000);
}
async function pullScratchpadFromServerIfIdle() {
if (!els.scratchpadEditor || document.activeElement === els.scratchpadEditor) return;
const res = await fetch("/api/scratchpad", { cache: "no-store" });
if (!res.ok) return;
const data = await res.json().catch(() => ({}));
const c = typeof data.content === "string" ? data.content : "";
if (els.scratchpadEditor.value !== c) {
els.scratchpadEditor.value = c;
refreshScratchpadRenderedIfPreview();
}
}
function setScratchpadFromStatus(enabled) {
if (!els.scratchpadPanel) return;
if (enabled) {
els.scratchpadPanel.hidden = false;
if (!scratchpadLoadedOnce) {
scratchpadLoadedOnce = true;
loadScratchpad().catch(() => {});
}
if (!scratchpadPollTimer) startScratchpadPolling();
} else {
els.scratchpadPanel.hidden = true;
scratchpadLoadedOnce = false;
stopScratchpadTimers();
}
}
async function loadScratchpad() {
if (!els.scratchpadEditor) return;
const res = await fetch("/api/scratchpad", { cache: "no-store" });
if (!res.ok) {
if (els.scratchpadHint) els.scratchpadHint.textContent = res.status === 404 ? "" : `load failed (${res.status})`;
return;
}
const data = await res.json().catch(() => ({}));
els.scratchpadEditor.value = typeof data.content === "string" ? data.content : "";
applyScratchpadRenderUI();
if (els.scratchpadHint) els.scratchpadHint.textContent = "";
}
async function pushScratchpadSave() {
if (!els.scratchpadEditor) return;
const content = els.scratchpadEditor.value;
const res = await fetch("/api/scratchpad", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const msg = data.error || (await res.text().catch(() => "")) || res.statusText;
if (els.scratchpadHint) els.scratchpadHint.textContent = msg || `save failed (${res.status})`;
return;
}
if (els.scratchpadHint) els.scratchpadHint.textContent = "";
}
function onScratchpadInput() {
if (!els.scratchpadEditor) return;
clearTimeout(scratchpadSaveTimer);
scratchpadSaveTimer = setTimeout(() => {
scratchpadSaveTimer = null;
pushScratchpadSave().catch(() => {});
}, 450);
}
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) {
appendActivity(eventTitle(ev), eventBody(ev));
if (ev.type === "final") {
summaryMarkdown = ev.text || "";
if (els.summary) {
els.summary.classList.add("md-content");
els.summary.innerHTML = renderMarkdown(summaryMarkdown);
}
schedulePersistUI();
}
if (ev.type === "error") {
const msg = ev.message || "Unknown error";
summaryMarkdown = summaryMarkdown ? `${summaryMarkdown}\n\n${msg}` : msg;
if (els.summary) {
els.summary.classList.add("md-content");
els.summary.innerHTML = renderMarkdown(summaryMarkdown);
}
schedulePersistUI();
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);
const sp = st.scratchpad && st.scratchpad.enabled;
setScratchpadFromStatus(!!sp);
if (els.calGrid) {
const cr = Number(st.calendar_revision);
if (Number.isFinite(cr) && cr !== calState.lastRevision) {
await loadCalendarFromServer();
}
}
}
function startStatusPolling() {
fetchStatusAndUpdate().catch(() => {});
window.__statusPoll = setInterval(() => {
fetchStatusAndUpdate().catch(() => {});
}, 2000);
}
/** Set while a chat request is in flight; cancel calls `.abort()` to drop the stream. */
let askAbortController = null;
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;
};
const ac = new AbortController();
askAbortController = ac;
if (els.cancel) els.cancel.disabled = false;
setBusy(true);
els.statusText.textContent = "working...";
summaryMarkdown = "";
if (els.summary) {
els.summary.textContent = "";
els.summary.classList.remove("md-content");
}
persistUIState();
const isClearAll = text === "/clear";
if (isClearAll) {
clearUIChat();
} else {
appendChat("user", "you", text);
}
thinking = appendAssistantThinking();
let dots = 0;
thinkingInterval = setInterval(() => {
dots = (dots + 1) % 4;
thinking.textEl.textContent = "thinking" + ".".repeat(dots);
}, 450);
try {
const res = await fetch("/ask/stream", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
body: JSON.stringify({ prompt: text }),
signal: ac.signal,
});
if (!res.ok || !res.body) {
const err = await res.text();
stopThinking();
appendActivity("error", err || res.statusText);
setBusy(false);
els.statusText.textContent = "ready";
return;
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
let finalText = "";
try {
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 {}
}
}
} finally {
try {
reader.releaseLock();
} catch (_) {}
}
stopThinking();
if (finalText) appendChat("assistant", "assistant", finalText);
setBusy(false);
els.statusText.textContent = "ready";
await fetchStatusAndUpdate();
} catch (err) {
stopThinking();
if (!ac.signal.aborted) {
appendActivity("error", String(err));
}
setBusy(false);
els.statusText.textContent = "ready";
await fetchStatusAndUpdate();
} finally {
askAbortController = null;
if (els.cancel) els.cancel.disabled = true;
}
}
if (els.cancel) {
els.cancel.addEventListener("click", () => {
if (askAbortController) askAbortController.abort();
});
}
els.form.addEventListener("submit", async (e) => {
e.preventDefault();
const text = els.prompt.value.trim();
if (!text) return;
els.prompt.value = "";
els.send.disabled = true;
try {
await runPrompt(text);
} catch (err) {
appendActivity("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 }));
});
if (els.calPrev) {
els.calPrev.addEventListener("click", () => {
calState.viewMonth--;
if (calState.viewMonth < 0) {
calState.viewMonth = 11;
calState.viewYear--;
}
loadCalendarFromServer();
});
}
if (els.calNext) {
els.calNext.addEventListener("click", () => {
calState.viewMonth++;
if (calState.viewMonth > 11) {
calState.viewMonth = 0;
calState.viewYear++;
}
loadCalendarFromServer();
});
}
if (els.calToday) {
els.calToday.addEventListener("click", () => {
const t = new Date();
calState.viewYear = t.getFullYear();
calState.viewMonth = t.getMonth();
calState.selectedKey = isoDateLocal(t);
loadCalendarFromServer();
});
}
if (els.calQuickWhen) {
els.calQuickWhen.addEventListener("change", () => {
const v = els.calQuickWhen.value;
if (v === CAL_WHEN_UNDATED) return;
if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) return;
if (v === calState.selectedKey) return;
const parts = v.split("-").map((x) => parseInt(x, 10));
calState.viewYear = parts[0];
calState.viewMonth = parts[1] - 1;
calState.selectedKey = v;
loadCalendarFromServer();
});
}
if (els.calQuickForm) {
els.calQuickForm.addEventListener("submit", async (e) => {
e.preventDefault();
const title = (els.calQuickTitle && els.calQuickTitle.value.trim()) || "";
if (!title) return;
const whenVal = (els.calQuickWhen && els.calQuickWhen.value) || "";
const body = {
title,
notes: "",
status: "open",
due_at: "",
};
if (whenVal === CAL_WHEN_UNDATED) {
body.due_at = "";
} else if (/^\d{4}-\d{2}-\d{2}$/.test(whenVal)) {
body.due_at = whenVal;
} else {
return;
}
const res = await fetch("/api/calendar/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
if (els.calQuickTitle) els.calQuickTitle.value = "";
const data = await res.json().catch(() => ({}));
if (data.revision != null) calState.lastRevision = Number(data.revision);
await loadCalendarFromServer();
}
});
}
if (els.scratchpadEditor) {
els.scratchpadEditor.addEventListener("input", onScratchpadInput);
els.scratchpadEditor.addEventListener("blur", () => {
clearTimeout(scratchpadSaveTimer);
scratchpadSaveTimer = null;
pushScratchpadSave().catch(() => {});
});
}
if (els.scratchpadRenderToggle) {
els.scratchpadRenderToggle.addEventListener("click", () => {
if (!scratchpadRenderPreview) {
clearTimeout(scratchpadSaveTimer);
scratchpadSaveTimer = null;
pushScratchpadSave().catch(() => {});
}
scratchpadRenderPreview = !scratchpadRenderPreview;
try {
sessionStorage.setItem(SCRATCHPAD_RENDER_KEY, scratchpadRenderPreview ? "1" : "0");
} catch (_) {}
applyScratchpadRenderUI();
});
applyScratchpadRenderUI();
}
if (!calState.selectedKey) calState.selectedKey = isoDateLocal(new Date());
rebuildCalWhenSelect();
loadCalendarFromServer();
els.statusText.textContent = "ready";
els.modelLine.textContent = "model: -";
initSectionCollapse();
loadPersistedUI();
startStatusPolling();
})();