@tiagojct/subsub 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/app.js ADDED
@@ -0,0 +1,1033 @@
1
+ // Sub-Sub web view. Talks only to the local Sub-Sub server (same origin).
2
+ // Every library change still goes through Sub-Sub's approval dialog; this page shows it and sends the answer.
3
+
4
+ import { marked } from "/vendor/marked.esm.js";
5
+ import { ACTIONS, STRINGS, SUGGESTIONS, TOOLS } from "/i18n.js";
6
+
7
+ // ---------------------------------------------------------------- state
8
+
9
+ let lang = "en";
10
+ let state = null; // Sub-Sub's own state: mode, profile, model, library, down
11
+ let session = null; // pi's session state
12
+ let busy = false;
13
+ let commands = [];
14
+ let providers = [];
15
+ const dialogs = []; // waiting dialog requests, oldest first
16
+ let shownDialog = null;
17
+ let currentMsg = null; // assistant message being streamed
18
+ const toolRows = new Map(); // toolCallId -> element
19
+ let working = null;
20
+
21
+ const $ = (id) => document.getElementById(id);
22
+ const log = $("log");
23
+
24
+ function t(key, vars = {}) {
25
+ const s = (STRINGS[lang] ?? STRINGS.en)[key] ?? STRINGS.en[key] ?? key;
26
+ return s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ""));
27
+ }
28
+
29
+ function el(tag, attrs = {}, ...children) {
30
+ const e = document.createElement(tag);
31
+ for (const [k, v] of Object.entries(attrs)) {
32
+ if (v === undefined || v === null || v === false) continue;
33
+ if (k === "class") e.className = v;
34
+ else if (k === "text") e.textContent = v;
35
+ else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
36
+ else e.setAttribute(k, v === true ? "" : String(v));
37
+ }
38
+ for (const c of children.flat()) if (c !== null && c !== undefined && c !== false) e.append(c);
39
+ return e;
40
+ }
41
+
42
+ async function api(path, body) {
43
+ const res = await fetch(path, body === undefined ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
44
+ const data = await res.json().catch(() => ({}));
45
+ if (!res.ok) throw new Error(data.error || `${res.status}`);
46
+ return data;
47
+ }
48
+
49
+ // ---------------------------------------------------------------- markdown (no raw HTML, safe links)
50
+
51
+ const SAFE_LINK = /^(https?:|mailto:|zotero:|obsidian:)/i;
52
+ function escapeHtml(s) {
53
+ return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
54
+ }
55
+ marked.use({
56
+ gfm: true,
57
+ breaks: false,
58
+ renderer: {
59
+ html(token) {
60
+ return escapeHtml(token.text ?? token.raw ?? "");
61
+ },
62
+ link(token) {
63
+ const text = this.parser.parseInline(token.tokens);
64
+ if (!SAFE_LINK.test(token.href ?? "")) return text;
65
+ return `<a href="${escapeHtml(token.href)}" target="_blank" rel="noopener noreferrer">${text}</a>`;
66
+ },
67
+ image(token) {
68
+ return escapeHtml(token.text || token.href || "");
69
+ },
70
+ },
71
+ });
72
+ function md(text) {
73
+ try {
74
+ return marked.parse(String(text ?? ""));
75
+ } catch {
76
+ return `<p>${escapeHtml(text)}</p>`;
77
+ }
78
+ }
79
+
80
+ // ---------------------------------------------------------------- theme & language
81
+
82
+ let currentTheme = localStorage.getItem("subsub-theme") || "auto";
83
+
84
+ function themeLabel(theme) {
85
+ if (theme === "light") return t("themeLight");
86
+ if (theme === "dark") return t("themeDark");
87
+ return t("themeAuto");
88
+ }
89
+
90
+ function updateThemeUI() {
91
+ const icon = currentTheme === "light" ? "☀️" : currentTheme === "dark" ? "🌙" : "💻";
92
+ const label = `${icon} ${themeLabel(currentTheme)}`;
93
+ const btn = $("theme-btn");
94
+ if (btn) {
95
+ btn.textContent = label;
96
+ btn.title = t("themeToggle");
97
+ }
98
+ const sideBtn = $("theme-btn-side");
99
+ if (sideBtn) sideBtn.textContent = label;
100
+ }
101
+
102
+ function setTheme(theme) {
103
+ currentTheme = theme;
104
+ localStorage.setItem("subsub-theme", theme);
105
+ if (theme === "auto") {
106
+ delete document.documentElement.dataset.theme;
107
+ } else {
108
+ document.documentElement.dataset.theme = theme;
109
+ }
110
+ updateThemeUI();
111
+ }
112
+
113
+ function toggleTheme() {
114
+ if (currentTheme === "auto") setTheme("light");
115
+ else if (currentTheme === "light") setTheme("dark");
116
+ else setTheme("auto");
117
+ }
118
+
119
+ function applyLang() {
120
+ document.documentElement.lang = lang === "pt" ? "pt-PT" : "en";
121
+ for (const e of document.querySelectorAll("[data-i18n]")) e.textContent = t(e.dataset.i18n);
122
+ updateThemeUI();
123
+ }
124
+
125
+ function mode() {
126
+ return state?.mode === "librarian" ? "librarian" : "researcher";
127
+ }
128
+
129
+ function renderHeader() {
130
+ document.body.dataset.mode = mode();
131
+ for (const b of document.querySelectorAll(".modes button")) b.setAttribute("aria-pressed", String(b.dataset.mode === mode()));
132
+ if (state?.profile) $("profile").value = $("profile-side").value = state.profile;
133
+ const m = state?.model ?? session?.model;
134
+ $("model-btn").textContent = $("model-btn-side").textContent = m ? m.id : t("noModel");
135
+ const lib = $("library");
136
+ const txt = lib.querySelector(".library-text") ?? lib;
137
+ const L = state?.library;
138
+ lib.classList.remove("down", "reachable", "checking");
139
+ if (!L || L.zotero === "checking") {
140
+ txt.textContent = t("zoteroChecking");
141
+ lib.classList.add("checking");
142
+ } else if (L.zotero === "down") {
143
+ txt.textContent = t("zoteroDown");
144
+ lib.classList.add("down");
145
+ } else {
146
+ const parts = [t("items", { n: L.items ?? "?" })];
147
+ if (L.toReview) parts.push(t("toReview", { n: L.toReview }));
148
+ txt.textContent = `Zotero: ${parts.join(", ")}`;
149
+ lib.classList.add("reachable");
150
+ }
151
+ if (state?.down?.length) {
152
+ txt.textContent += ` | ${t("serversDown", { x: state.down.join(", ") })}`;
153
+ lib.classList.add("down");
154
+ }
155
+ $("input").placeholder = mode() === "librarian" ? t("placeholderLibrarian") : t("placeholderResearcher");
156
+ renderActions();
157
+ renderBanner();
158
+ updateThemeUI();
159
+ }
160
+
161
+ function hasCommand(name) {
162
+ const base = name.split(" ")[0];
163
+ return commands.length === 0 || commands.some((c) => c.name === base);
164
+ }
165
+
166
+ function renderActions() {
167
+ const box = $("actions");
168
+ box.replaceChildren();
169
+ const list = [...ACTIONS[mode()]].filter((a) => hasCommand(a.cmd));
170
+ for (const a of list) box.append(actionButton(a));
171
+ box.append(el("hr"));
172
+ for (const a of ACTIONS.both.filter((x) => hasCommand(x.cmd))) box.append(actionButton(a));
173
+ }
174
+
175
+ function actionButton(a) {
176
+ return el("button", {
177
+ type: "button",
178
+ text: a[lang] ?? a.en,
179
+ onclick: () => {
180
+ closeSide();
181
+ if (a.send) sendMessage(`/${a.cmd}`);
182
+ else {
183
+ const input = $("input");
184
+ input.value = `/${a.cmd} `;
185
+ input.focus();
186
+ autosize();
187
+ }
188
+ },
189
+ });
190
+ }
191
+
192
+ function renderBanner() {
193
+ const b = $("banner");
194
+ b.replaceChildren();
195
+ const m = state?.model ?? session?.model;
196
+ if (!m && session) {
197
+ b.append(el("span", { text: t("connectModel") }), el("button", { type: "button", class: "primary small", text: t("connect"), onclick: openModels }));
198
+ b.hidden = false;
199
+ } else b.hidden = true;
200
+ }
201
+
202
+ let loadedSessions = [];
203
+
204
+ function renderSessionsList(filterText = "") {
205
+ const ul = $("sessions");
206
+ ul.replaceChildren();
207
+ const q = filterText.trim().toLowerCase();
208
+ const filtered = q
209
+ ? loadedSessions.filter((s) => (s.name || s.first || "").toLowerCase().includes(q))
210
+ : loadedSessions;
211
+ if (!filtered.length) {
212
+ ul.append(el("li", { class: "muted small", text: t("noConversations") }));
213
+ return;
214
+ }
215
+ const fmt = new Intl.DateTimeFormat(lang === "pt" ? "pt-PT" : "en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
216
+ for (const s of filtered) {
217
+ const current = session?.sessionFile === s.path;
218
+ ul.append(
219
+ el(
220
+ "li",
221
+ { class: current ? "current" : "" },
222
+ el(
223
+ "button",
224
+ {
225
+ type: "button",
226
+ "aria-current": current ? "true" : false,
227
+ onclick: async () => {
228
+ closeSide();
229
+ if (busy) return note(t("busy"), "warning");
230
+ try {
231
+ await api("/api/session", { path: s.path });
232
+ } catch (err) {
233
+ note(err.message, "error");
234
+ }
235
+ },
236
+ },
237
+ el("span", { class: "what", text: s.name || s.first || "…" }),
238
+ el(
239
+ "span",
240
+ { class: "when" },
241
+ el("span", { text: fmt.format(new Date(s.modified)) }),
242
+ s.messages ? el("span", { class: "badge", text: `${s.messages}` }) : null,
243
+ ),
244
+ ),
245
+ ),
246
+ );
247
+ }
248
+ }
249
+
250
+ async function loadSessions() {
251
+ try {
252
+ loadedSessions = (await api("/api/sessions")).sessions ?? [];
253
+ } catch {
254
+ return;
255
+ }
256
+ renderSessionsList($("session-search")?.value ?? "");
257
+ }
258
+
259
+ function exportConversation() {
260
+ const msgs = [];
261
+ const items = log.querySelectorAll(".msg");
262
+ if (!items.length) {
263
+ note(t("noConversations"), "warning");
264
+ return;
265
+ }
266
+ for (const m of items) {
267
+ if (m.classList.contains("user")) {
268
+ const text = m.querySelector(".bubble")?.innerText ?? "";
269
+ if (text && text !== "/subsub-refresh") msgs.push(`### User\n\n${text}\n`);
270
+ } else if (m.classList.contains("assistant")) {
271
+ const text = Array.from(m.querySelectorAll(".md")).map((d) => d.innerText).join("\n\n");
272
+ if (text) msgs.push(`### Sub-Sub (${mode()})\n\n${text}\n`);
273
+ }
274
+ }
275
+ if (!msgs.length) return;
276
+ const date = new Date().toISOString().slice(0, 10);
277
+ const content = `# Sub-Sub Research Session (${date})\n\n**Mode**: ${mode()} \n**Profile**: ${state?.profile ?? "scholar"} \n\n---\n\n${msgs.join("\n---\n\n")}`;
278
+ const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
279
+ const url = URL.createObjectURL(blob);
280
+ const a = document.createElement("a");
281
+ a.href = url;
282
+ a.download = `Sub-Sub-${mode()}-${date}.md`;
283
+ document.body.append(a);
284
+ a.click();
285
+ a.remove();
286
+ URL.revokeObjectURL(url);
287
+ }
288
+
289
+ function closeSide() {
290
+ $("side").classList.remove("open");
291
+ $("menu-btn").setAttribute("aria-expanded", "false");
292
+ }
293
+
294
+ // ---------------------------------------------------------------- conversation
295
+
296
+ function scrollDown(force = false) {
297
+ const near = log.scrollHeight - log.scrollTop - log.clientHeight < 160;
298
+ if (force || near) log.scrollTop = log.scrollHeight;
299
+ }
300
+
301
+ function note(text, kind = "info", mono = false) {
302
+ clearEmpty();
303
+ const n = el("div", { class: `note ${kind}${mono ? " mono" : ""}`, text });
304
+ log.append(n);
305
+ scrollDown(true);
306
+ return n;
307
+ }
308
+
309
+ function clearEmpty() {
310
+ log.querySelector(".empty")?.remove();
311
+ }
312
+
313
+ function renderEmpty() {
314
+ if (log.children.length) return;
315
+ const s = SUGGESTIONS[mode()][lang] ?? SUGGESTIONS[mode()].en;
316
+ log.append(
317
+ el(
318
+ "div",
319
+ { class: "empty" },
320
+ el("h1", { text: t("emptyTitle") }),
321
+ el(
322
+ "div",
323
+ { class: "chips" },
324
+ s.map((x) =>
325
+ el("button", {
326
+ type: "button",
327
+ text: x.trim(),
328
+ onclick: () => {
329
+ const input = $("input");
330
+ input.value = x;
331
+ input.focus();
332
+ autosize();
333
+ },
334
+ }),
335
+ ),
336
+ ),
337
+ ),
338
+ );
339
+ }
340
+
341
+ function textOf(content) {
342
+ if (typeof content === "string") return content;
343
+ if (!Array.isArray(content)) return "";
344
+ return content.filter((c) => c.type === "text").map((c) => c.text).join("\n");
345
+ }
346
+
347
+ /** Prompt templates arrive as their long, expanded text; show the command that was typed instead. */
348
+ const sentTemplates = [];
349
+
350
+ function userMessage(text) {
351
+ if (String(text).trim() === "/subsub-refresh") return;
352
+ clearEmpty();
353
+ let bubble;
354
+ if (text.length > 700) {
355
+ const first = text.split("\n").find((l) => l.trim()) ?? "";
356
+ bubble = el("details", { class: "bubble" }, el("summary", { text: first.length > 120 ? `${first.slice(0, 117)}...` : first }), el("div", { text }));
357
+ } else bubble = el("div", { class: "bubble", text });
358
+ log.append(el("div", { class: "msg user" }, bubble));
359
+ scrollDown(true);
360
+ }
361
+
362
+ function toolLabel(name) {
363
+ const short = String(name).replace(/^(zotero|scholar)_/, "");
364
+ return (TOOLS[lang] ?? TOOLS.en)[short] ?? (TOOLS.en[short] || name);
365
+ }
366
+
367
+ function summarizeArgs(args) {
368
+ if (!args || typeof args !== "object") return "";
369
+ const parts = [];
370
+ for (const [k, v] of Object.entries(args)) {
371
+ if (k === "dry_run" || v === "" || v === null || v === undefined) continue;
372
+ let s;
373
+ if (Array.isArray(v)) s = v.every((x) => typeof x !== "object") ? v.join(", ") : `${v.length}`;
374
+ else s = typeof v === "string" ? v : JSON.stringify(v);
375
+ if (s.length > 80) s = `${s.slice(0, 77)}...`;
376
+ parts.push(`${k}: ${s}`);
377
+ if (parts.join(" ").length > 160) break;
378
+ }
379
+ return parts.join(" ");
380
+ }
381
+
382
+ function toolRow(id, name, args) {
383
+ let row = toolRows.get(id);
384
+ if (row) return row;
385
+ const summary = el("summary", {}, el("span", { class: "label", text: toolLabel(name) }), el("span", { class: "args", text: summarizeArgs(args) }));
386
+ row = el("details", { class: "tool running" }, summary);
387
+ row.dataset.name = name;
388
+ toolRows.set(id, row);
389
+ updateTool(row, name, args);
390
+ return row;
391
+ }
392
+
393
+ /** Arguments arrive after the tool call starts; files that Sub-Sub writes get an Open button. */
394
+ function updateTool(row, name, args) {
395
+ if (!args || !Object.keys(args).length) return;
396
+ row.querySelector(".args").textContent = summarizeArgs(args);
397
+ if (args.path && (name === "write" || name === "edit") && !row.querySelector(".open")) {
398
+ row.querySelector("summary").append(
399
+ el("button", {
400
+ type: "button",
401
+ class: "open",
402
+ text: t("open"),
403
+ onclick: (e) => {
404
+ e.preventDefault();
405
+ api("/api/open", { path: args.path }).catch((err) => note(err.message, "warning"));
406
+ },
407
+ }),
408
+ );
409
+ }
410
+ }
411
+
412
+ function finishTool(id, result, isError) {
413
+ const row = toolRows.get(id);
414
+ if (!row) return;
415
+ row.classList.remove("running");
416
+ row.classList.add(isError ? "error" : "done");
417
+ const text = textOf(result?.content ?? result);
418
+ row.querySelector("pre")?.remove();
419
+ if (text) row.append(el("pre", { text: text.length > 6000 ? `${text.slice(0, 6000)}\n...` : text }));
420
+ if (isError) row.querySelector(".label").textContent = `${toolLabel(row.dataset.name)} (${t("failed")})`;
421
+ }
422
+
423
+ function attachCopyButtons(root) {
424
+ for (const pre of root.querySelectorAll("pre")) {
425
+ if (pre.parentElement?.classList.contains("pre-wrap")) continue;
426
+ const wrap = el("div", { class: "pre-wrap" });
427
+ pre.parentNode.insertBefore(wrap, pre);
428
+ wrap.append(pre);
429
+ const btn = el("button", {
430
+ type: "button",
431
+ class: "copy-btn",
432
+ text: t("copy"),
433
+ onclick: async () => {
434
+ const code = pre.querySelector("code")?.innerText ?? pre.innerText;
435
+ try {
436
+ await navigator.clipboard.writeText(code);
437
+ btn.textContent = t("copied");
438
+ btn.classList.add("copied");
439
+ setTimeout(() => {
440
+ btn.textContent = t("copy");
441
+ btn.classList.remove("copied");
442
+ }, 2000);
443
+ } catch {
444
+ btn.textContent = t("error");
445
+ }
446
+ },
447
+ });
448
+ wrap.append(btn);
449
+ }
450
+ }
451
+
452
+ /** A container for one assistant message; blocks are keyed by content index. */
453
+ function startAssistant() {
454
+ clearEmpty();
455
+ const box = el("div", { class: "msg assistant" });
456
+ log.append(box);
457
+ currentMsg = { box, blocks: new Map(), raf: 0 };
458
+ return currentMsg;
459
+ }
460
+
461
+ function block(msg, index, kind) {
462
+ let b = msg.blocks.get(index);
463
+ if (!b) {
464
+ b = { kind, text: "", node: kind === "text" ? el("div", { class: "md" }) : null };
465
+ msg.blocks.set(index, b);
466
+ if (b.node) msg.box.append(b.node);
467
+ }
468
+ return b;
469
+ }
470
+
471
+ function paint(msg) {
472
+ if (msg.raf) return;
473
+ msg.raf = requestAnimationFrame(() => {
474
+ msg.raf = 0;
475
+ for (const b of msg.blocks.values()) if (b.kind === "text" && b.dirty) {
476
+ b.node.innerHTML = md(b.text);
477
+ attachCopyButtons(b.node);
478
+ b.dirty = false;
479
+ }
480
+ scrollDown();
481
+ });
482
+ }
483
+
484
+ function renderAssistantFinal(message, msg = startAssistant()) {
485
+ msg.box.replaceChildren();
486
+ msg.blocks.clear();
487
+ let fullText = "";
488
+ for (const c of message.content ?? []) {
489
+ if (c.type === "text" && c.text?.trim()) {
490
+ fullText += (fullText ? "\n\n" : "") + c.text;
491
+ const node = el("div", { class: "md" });
492
+ node.innerHTML = md(c.text);
493
+ attachCopyButtons(node);
494
+ msg.box.append(node);
495
+ } else if (c.type === "toolCall") {
496
+ const row = toolRow(c.id, c.name, c.arguments);
497
+ updateTool(row, c.name, c.arguments);
498
+ msg.box.append(row);
499
+ }
500
+ }
501
+ if (fullText.trim()) {
502
+ const actions = el(
503
+ "div",
504
+ { class: "msg-actions" },
505
+ el("button", {
506
+ type: "button",
507
+ class: "msg-action-btn",
508
+ text: t("copyNote"),
509
+ onclick: async (e) => {
510
+ const btn = e.currentTarget;
511
+ try {
512
+ await navigator.clipboard.writeText(fullText);
513
+ btn.textContent = t("copied");
514
+ btn.classList.add("copied");
515
+ setTimeout(() => {
516
+ btn.textContent = t("copyNote");
517
+ btn.classList.remove("copied");
518
+ }, 2000);
519
+ } catch {
520
+ btn.textContent = t("error");
521
+ }
522
+ },
523
+ }),
524
+ );
525
+ msg.box.append(actions);
526
+ }
527
+ if (message.stopReason === "error" && message.errorMessage) msg.box.append(el("div", { class: "note error", text: message.errorMessage }));
528
+ if (message.stopReason === "aborted") msg.box.append(el("div", { class: "note", text: t("stopped") }));
529
+ if (!msg.box.children.length) msg.box.remove();
530
+ scrollDown();
531
+ }
532
+
533
+ function renderHistory(messages) {
534
+ log.replaceChildren();
535
+ toolRows.clear();
536
+ currentMsg = null;
537
+ for (const m of messages ?? []) {
538
+ if (m.role === "user") {
539
+ const txt = textOf(m.content).trim();
540
+ if (txt === "/subsub-refresh") continue;
541
+ userMessage(txt);
542
+ }
543
+ else if (m.role === "assistant") renderAssistantFinal(m);
544
+ else if (m.role === "toolResult") finishTool(m.toolCallId, m, m.isError);
545
+ else if (m.role === "custom" && m.display) note(textOf(m.content));
546
+ else if (m.role === "compactionSummary" || m.role === "branchSummary") note(t("earlier"));
547
+ }
548
+ for (const row of toolRows.values()) if (row.classList.contains("running")) row.classList.replace("running", "done");
549
+ renderEmpty();
550
+ scrollDown(true);
551
+ }
552
+
553
+ function setBusy(b) {
554
+ busy = b;
555
+ $("stop").hidden = !b;
556
+ if (b && !working) {
557
+ working = el("div", { class: "working", text: t("working") });
558
+ log.append(working);
559
+ scrollDown();
560
+ } else if (!b && working) {
561
+ working.remove();
562
+ working = null;
563
+ }
564
+ if (working) log.append(working); // keep it last
565
+ }
566
+
567
+ // ---------------------------------------------------------------- events from Sub-Sub
568
+
569
+ function onEvent(ev) {
570
+ switch (ev.type) {
571
+ case "hello":
572
+ lang = ev.lang === "pt" ? "pt" : "en";
573
+ applyLang();
574
+ state = ev.state ?? state;
575
+ session = ev.session ?? null;
576
+ commands = ev.commands ?? [];
577
+ providers = ev.providers ?? [];
578
+ $("version").textContent = ev.version ? `v${ev.version}` : "";
579
+ renderHeader();
580
+ renderHistory(ev.messages);
581
+ if (ev.error) note(ev.error, "error");
582
+ setBusy(!!ev.busy);
583
+ dialogs.length = 0;
584
+ for (const d of ev.dialogs ?? []) dialogs.push(d);
585
+ nextDialog();
586
+ loadSessions();
587
+ break;
588
+ case "subsub_state":
589
+ state = ev.state;
590
+ renderHeader();
591
+ break;
592
+ case "agent_start":
593
+ setBusy(true);
594
+ break;
595
+ case "agent_settled":
596
+ setBusy(false);
597
+ currentMsg = null;
598
+ refreshSession();
599
+ break;
600
+ case "message_start":
601
+ if (ev.message?.role === "user") {
602
+ const txt = (sentTemplates.shift() ?? textOf(ev.message.content)).trim();
603
+ if (txt !== "/subsub-refresh") userMessage(txt);
604
+ }
605
+ else if (ev.message?.role === "assistant") startAssistant();
606
+ else if (ev.message?.role === "custom" && ev.message.display) note(textOf(ev.message.content));
607
+ if (working) log.append(working);
608
+ break;
609
+ case "message_update": {
610
+ const a = ev.assistantMessageEvent;
611
+ const msg = currentMsg ?? startAssistant();
612
+ if (a.type === "text_delta") {
613
+ const b = block(msg, a.contentIndex, "text");
614
+ b.text += a.delta;
615
+ b.dirty = true;
616
+ paint(msg);
617
+ } else if (a.type === "text_end") {
618
+ const b = block(msg, a.contentIndex, "text");
619
+ b.text = a.content ?? b.text;
620
+ b.dirty = true;
621
+ paint(msg);
622
+ } else if (a.type === "toolcall_start") {
623
+ const b = block(msg, a.contentIndex, "tool");
624
+ b.id = a.id;
625
+ msg.box.append(toolRow(a.id, a.toolName, {}));
626
+ } else if (a.type === "toolcall_end" && a.toolCall) {
627
+ const row = toolRow(a.toolCall.id, a.toolCall.name, a.toolCall.arguments);
628
+ updateTool(row, a.toolCall.name, a.toolCall.arguments);
629
+ if (!row.isConnected) msg.box.append(row);
630
+ }
631
+ if (working) log.append(working);
632
+ break;
633
+ }
634
+ case "message_end":
635
+ if (ev.message?.role === "assistant") {
636
+ renderAssistantFinal(ev.message, currentMsg ?? startAssistant());
637
+ currentMsg = null;
638
+ }
639
+ if (working) log.append(working);
640
+ break;
641
+ case "tool_execution_start": {
642
+ const row = toolRow(ev.toolCallId, ev.toolName, ev.args);
643
+ updateTool(row, ev.toolName, ev.args);
644
+ if (!row.isConnected) (currentMsg?.box ?? log).append(row);
645
+ break;
646
+ }
647
+ case "tool_execution_end":
648
+ finishTool(ev.toolCallId, ev.result, ev.isError);
649
+ break;
650
+ case "extension_ui_request":
651
+ if (ev.method === "notify") {
652
+ if (/^Sub-Sub: (librarian|researcher) mode$/.test(ev.message)) break;
653
+ note(ev.message, ev.notifyType === "error" ? "error" : ev.notifyType === "warning" ? "warning" : "info", /\n/.test(ev.message));
654
+ } else {
655
+ dialogs.push(ev);
656
+ nextDialog();
657
+ }
658
+ break;
659
+ case "dialog_closed": {
660
+ const i = dialogs.findIndex((d) => d.id === ev.id);
661
+ if (i >= 0) dialogs.splice(i, 1);
662
+ if (shownDialog?.id === ev.id) {
663
+ shownDialog = null;
664
+ $("dialog").close();
665
+ nextDialog();
666
+ }
667
+ break;
668
+ }
669
+ case "auto_retry_start":
670
+ note(`${ev.errorMessage ?? ""} (${ev.attempt}/${ev.maxAttempts})`, "warning");
671
+ break;
672
+ case "extension_error":
673
+ note(ev.error, "error");
674
+ break;
675
+ case "agent_exit":
676
+ setBusy(false);
677
+ log.append(
678
+ el(
679
+ "div",
680
+ { class: "note error" },
681
+ el("div", { text: t("agentExit") }),
682
+ ev.stderr ? el("pre", { class: "small", text: ev.stderr }) : null,
683
+ el("button", { type: "button", class: "primary small", text: t("startAgain"), onclick: () => api("/api/restart", {}).catch((e) => note(e.message, "error")) }),
684
+ ),
685
+ );
686
+ scrollDown(true);
687
+ break;
688
+ }
689
+ }
690
+
691
+ async function refreshSession() {
692
+ // The session file exists only after the first message; then the list can mark it.
693
+ try {
694
+ session = await api("/api/state");
695
+ } catch {
696
+ /* the stream reconnects */
697
+ }
698
+ renderBanner();
699
+ loadSessions();
700
+ }
701
+
702
+ // ---------------------------------------------------------------- dialogs (approvals)
703
+
704
+ function dialogTitle(d) {
705
+ const raw = String(d.title ?? "").replace(/^Sub-Sub:\s*/, "");
706
+ let m;
707
+ if ((m = raw.match(/^apply (\S+)\?$/))) return t("apply", { x: toolLabel(m[1]) });
708
+ if ((m = raw.match(/^run (\S+)\?$/))) return t("run", { x: toolLabel(m[1]) });
709
+ if (raw === "undo?") return t("undo");
710
+ if ((m = raw.match(/^(write|edit) (.+)\?$/))) return t("write", { x: m[2] });
711
+ return raw;
712
+ }
713
+
714
+ /** Colour "+ added" and "- removed" parts, as the terminal does. */
715
+ function paintPreview(pre, text) {
716
+ pre.replaceChildren();
717
+ const seg = /(^|: |; )([+-] [^;]+)/g;
718
+ for (const line of String(text ?? "").split("\n")) {
719
+ let last = 0;
720
+ for (const m of line.matchAll(seg)) {
721
+ const start = m.index + m[1].length;
722
+ if (start > last) pre.append(line.slice(last, start));
723
+ pre.append(el("span", { class: m[2].startsWith("+") ? "add" : "rem", text: m[2] }));
724
+ last = start + m[2].length;
725
+ }
726
+ if (last < line.length) pre.append(line.slice(last));
727
+ pre.append("\n");
728
+ }
729
+ }
730
+
731
+ function nextDialog() {
732
+ if (shownDialog || !dialogs.length) return;
733
+ const d = dialogs[0];
734
+ shownDialog = d;
735
+ const dlg = $("dialog");
736
+ $("dialog-title").textContent = dialogTitle(d);
737
+ const hint = $("dialog-hint");
738
+ hint.textContent = d.method === "confirm" ? t("approveHint") : "";
739
+ hint.hidden = d.method !== "confirm";
740
+ const pre = $("dialog-message");
741
+ pre.hidden = !d.message;
742
+ if (d.message) paintPreview(pre, d.message);
743
+ const body = $("dialog-body");
744
+ body.replaceChildren();
745
+ const buttons = $("dialog-buttons");
746
+ buttons.replaceChildren();
747
+ const answer = async (reply) => {
748
+ try {
749
+ await api("/api/dialog", { id: d.id, ...reply });
750
+ } catch (err) {
751
+ note(err.message, "error");
752
+ }
753
+ };
754
+ if (d.method === "confirm") {
755
+ buttons.append(
756
+ el("span", { class: "modal-shortcut-hint", text: t("confirmShortcutHint") }),
757
+ el("button", { type: "button", class: "secondary", text: t("no"), onclick: () => answer({ confirmed: false }) }),
758
+ el("button", { type: "button", class: "primary", text: t("yes"), onclick: () => answer({ confirmed: true }) }),
759
+ );
760
+ } else if (d.method === "select") {
761
+ for (const o of d.options ?? []) buttons.append(el("button", { type: "button", class: "secondary", text: o, onclick: () => answer({ value: o }) }));
762
+ buttons.append(el("button", { type: "button", class: "ghost", text: t("cancel"), onclick: () => answer({ cancelled: true }) }));
763
+ } else {
764
+ const field = d.method === "editor" ? el("textarea", {}) : el("input", { type: "text", placeholder: d.placeholder ?? "" });
765
+ field.value = d.prefill ?? "";
766
+ body.append(field);
767
+ buttons.append(
768
+ el("button", { type: "button", class: "ghost", text: t("cancel"), onclick: () => answer({ cancelled: true }) }),
769
+ el("button", { type: "button", class: "primary", text: t("ok"), onclick: () => answer({ value: field.value }) }),
770
+ );
771
+ }
772
+ dlg.onkeydown = (e) => {
773
+ if (d.method === "confirm") {
774
+ if (e.key === "y" || e.key === "Y" || (e.key === "Enter" && (e.metaKey || e.ctrlKey))) {
775
+ e.preventDefault();
776
+ answer({ confirmed: true });
777
+ } else if (e.key === "n" || e.key === "N") {
778
+ e.preventDefault();
779
+ answer({ confirmed: false });
780
+ }
781
+ }
782
+ };
783
+ dlg.oncancel = (e) => {
784
+ e.preventDefault();
785
+ answer(d.method === "confirm" ? { confirmed: false } : { cancelled: true });
786
+ };
787
+ if (!dlg.open) dlg.showModal();
788
+ buttons.querySelector(d.method === "confirm" ? ".secondary" : "button")?.focus();
789
+ }
790
+
791
+ // ---------------------------------------------------------------- models and keys
792
+
793
+ async function openModels() {
794
+ const dlg = $("model-dialog");
795
+ const ul = $("models");
796
+ ul.replaceChildren(el("li", { class: "empty-models", text: "…" }));
797
+ const sel = $("login-provider");
798
+ sel.replaceChildren(...providers.map((p) => el("option", { value: p.id, text: p.label })));
799
+ const link = $("login-link");
800
+ const setLink = () => {
801
+ const p = providers.find((x) => x.id === sel.value);
802
+ link.href = p?.url ?? "#";
803
+ };
804
+ sel.onchange = setLink;
805
+ setLink();
806
+ if (!dlg.open) dlg.showModal();
807
+ let models = [];
808
+ try {
809
+ models = (await api("/api/models")).models ?? [];
810
+ } catch (err) {
811
+ note(err.message, "error");
812
+ }
813
+ ul.replaceChildren();
814
+ if (!models.length) ul.append(el("li", { class: "empty-models", text: t("noModels") }));
815
+ const cur = state?.model ?? session?.model;
816
+ const isCurrent = (m) => cur && cur.provider === m.provider && cur.id === m.id;
817
+ models.sort((a, b) => Number(isCurrent(b)) - Number(isCurrent(a)) || `${a.provider}/${a.id}`.localeCompare(`${b.provider}/${b.id}`));
818
+ for (const m of models) {
819
+ const isCur = cur && cur.provider === m.provider && cur.id === m.id;
820
+ ul.append(
821
+ el(
822
+ "li",
823
+ {},
824
+ el("button", {
825
+ type: "button",
826
+ class: isCur ? "current" : "",
827
+ text: `${m.provider}/${m.id}`,
828
+ onclick: async () => {
829
+ if (busy) return note(t("busy"), "warning");
830
+ try {
831
+ await api("/api/model", { provider: m.provider, id: m.id, both: $("model-both").checked });
832
+ dlg.close();
833
+ } catch (err) {
834
+ note(err.message, "error");
835
+ }
836
+ },
837
+ }),
838
+ ),
839
+ );
840
+ }
841
+ }
842
+
843
+ $("login-form")?.addEventListener("submit", async (e) => {
844
+ e.preventDefault();
845
+ const btn = $("login-save");
846
+ btn.disabled = true;
847
+ btn.textContent = t("saving");
848
+ try {
849
+ await api("/api/login", { provider: $("login-provider").value, key: $("login-key").value });
850
+ $("login-key").value = "";
851
+ note(t("saved"));
852
+ await openModels();
853
+ } catch (err) {
854
+ note(err.message, "error");
855
+ } finally {
856
+ btn.disabled = false;
857
+ btn.textContent = t("save");
858
+ }
859
+ });
860
+
861
+ // ---------------------------------------------------------------- composer
862
+
863
+ const input = $("input");
864
+
865
+ function autosize() {
866
+ input.style.height = "auto";
867
+ input.style.height = `${Math.min(input.scrollHeight + 2, window.innerHeight * 0.4)}px`;
868
+ suggest();
869
+ }
870
+
871
+ async function sendMessage(text) {
872
+ const message = text.trim();
873
+ if (!message) return;
874
+ const name = message.startsWith("/") ? message.slice(1).split(/\s/)[0] : "";
875
+ const isTemplate = name && commands.some((c) => c.name === name && c.source === "prompt");
876
+ if (isTemplate) sentTemplates.push(message);
877
+ try {
878
+ await api("/api/prompt", { message });
879
+ } catch (err) {
880
+ if (isTemplate) sentTemplates.pop();
881
+ note(err.message, "error");
882
+ }
883
+ }
884
+
885
+ let suggestIndex = 0;
886
+ function suggest() {
887
+ const box = $("suggest");
888
+ const v = input.value;
889
+ if (!/^\/\S*$/.test(v)) {
890
+ box.hidden = true;
891
+ return;
892
+ }
893
+ const q = v.slice(1).toLowerCase();
894
+ const list = commands.filter((c) => c.name !== "subsub-refresh" && c.name.toLowerCase().startsWith(q)).slice(0, 12);
895
+ if (!list.length) {
896
+ box.hidden = true;
897
+ return;
898
+ }
899
+ suggestIndex = Math.min(suggestIndex, list.length - 1);
900
+ box.replaceChildren(
901
+ ...list.map((c, i) =>
902
+ el(
903
+ "button",
904
+ {
905
+ type: "button",
906
+ role: "option",
907
+ "aria-selected": String(i === suggestIndex),
908
+ onclick: () => pick(c),
909
+ },
910
+ el("span", { class: "name", text: `/${c.name}` }),
911
+ el("span", { class: "desc", text: (c.description ?? "").replace(/^Sub-Sub:\s*/, "") }),
912
+ ),
913
+ ),
914
+ );
915
+ box.hidden = false;
916
+ box.list = list;
917
+ }
918
+ function pick(c) {
919
+ input.value = `/${c.name} `;
920
+ $("suggest").hidden = true;
921
+ input.focus();
922
+ }
923
+
924
+ input.addEventListener("input", () => {
925
+ suggestIndex = 0;
926
+ autosize();
927
+ });
928
+ input.addEventListener("keydown", (e) => {
929
+ const box = $("suggest");
930
+ if (!box.hidden && box.list) {
931
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
932
+ e.preventDefault();
933
+ suggestIndex = (suggestIndex + (e.key === "ArrowDown" ? 1 : -1) + box.list.length) % box.list.length;
934
+ suggest();
935
+ return;
936
+ }
937
+ if (e.key === "Tab") {
938
+ e.preventDefault();
939
+ pick(box.list[suggestIndex]);
940
+ return;
941
+ }
942
+ if (e.key === "Escape") {
943
+ box.hidden = true;
944
+ return;
945
+ }
946
+ }
947
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
948
+ e.preventDefault();
949
+ $("composer").requestSubmit();
950
+ }
951
+ });
952
+ $("composer").addEventListener("submit", (e) => {
953
+ e.preventDefault();
954
+ const text = input.value;
955
+ if (!text.trim()) return;
956
+ input.value = "";
957
+ autosize();
958
+ sendMessage(text);
959
+ });
960
+ $("stop").addEventListener("click", () => api("/api/abort", {}).catch(() => {}));
961
+ $("new-btn").addEventListener("click", async () => {
962
+ closeSide();
963
+ if (busy) return note(t("busy"), "warning");
964
+ try {
965
+ await api("/api/new", {});
966
+ } catch (err) {
967
+ note(err.message, "error");
968
+ }
969
+ });
970
+ for (const b of document.querySelectorAll(".modes button")) {
971
+ b.addEventListener("click", () => {
972
+ if (b.dataset.mode !== mode()) sendMessage(`/${b.dataset.mode}`);
973
+ });
974
+ }
975
+ $("profile").addEventListener("change", (e) => sendMessage(`/profile ${e.target.value}`));
976
+ $("profile-side").addEventListener("change", (e) => sendMessage(`/profile ${e.target.value}`));
977
+ $("model-btn").addEventListener("click", openModels);
978
+ $("model-btn-side").addEventListener("click", () => {
979
+ closeSide();
980
+ openModels();
981
+ });
982
+ $("model-close").addEventListener("click", () => $("model-dialog").close());
983
+ $("main").addEventListener("click", () => {
984
+ if ($("side").classList.contains("open")) closeSide();
985
+ });
986
+ $("menu-btn").addEventListener("click", (e) => {
987
+ e.stopPropagation();
988
+ const side = $("side");
989
+ side.classList.toggle("open");
990
+ $("menu-btn").setAttribute("aria-expanded", String(side.classList.contains("open")));
991
+ });
992
+ $("theme-btn")?.addEventListener("click", toggleTheme);
993
+ $("theme-btn-side")?.addEventListener("click", () => {
994
+ closeSide();
995
+ toggleTheme();
996
+ });
997
+ $("session-search")?.addEventListener("input", (e) => renderSessionsList(e.target.value));
998
+ $("export-btn")?.addEventListener("click", exportConversation);
999
+ $("quit-btn").addEventListener("click", async () => {
1000
+ try {
1001
+ await api("/api/quit", {});
1002
+ } catch {
1003
+ /* closing */
1004
+ }
1005
+ stream?.close();
1006
+ document.body.replaceChildren(el("p", { class: "empty", text: t("quitDone") }));
1007
+ });
1008
+
1009
+ // ---------------------------------------------------------------- the event stream
1010
+
1011
+ let stream = null;
1012
+ let lostNote = null;
1013
+ function connect() {
1014
+ stream = new EventSource("/api/events");
1015
+ stream.onmessage = (m) => {
1016
+ lostNote?.remove();
1017
+ lostNote = null;
1018
+ let ev;
1019
+ try {
1020
+ ev = JSON.parse(m.data);
1021
+ } catch {
1022
+ return;
1023
+ }
1024
+ onEvent(ev);
1025
+ };
1026
+ stream.onerror = () => {
1027
+ if (!lostNote) lostNote = note(t("disconnected"), "warning");
1028
+ };
1029
+ }
1030
+
1031
+ setTheme(currentTheme);
1032
+ applyLang();
1033
+ connect();