@tiagojct/subsub 0.5.0 → 0.6.0

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,859 @@
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
+ // ---------------------------------------------------------------- header, side panel
81
+
82
+ function applyLang() {
83
+ document.documentElement.lang = lang === "pt" ? "pt-PT" : "en";
84
+ for (const e of document.querySelectorAll("[data-i18n]")) e.textContent = t(e.dataset.i18n);
85
+ }
86
+
87
+ function mode() {
88
+ return state?.mode === "librarian" ? "librarian" : "researcher";
89
+ }
90
+
91
+ function renderHeader() {
92
+ document.body.dataset.mode = mode();
93
+ for (const b of document.querySelectorAll(".modes button")) b.setAttribute("aria-pressed", String(b.dataset.mode === mode()));
94
+ if (state?.profile) $("profile").value = $("profile-side").value = state.profile;
95
+ const m = state?.model ?? session?.model;
96
+ $("model-btn").textContent = $("model-btn-side").textContent = m ? m.id : t("noModel");
97
+ const lib = $("library");
98
+ const L = state?.library;
99
+ lib.classList.remove("down");
100
+ if (!L || L.zotero === "checking") lib.textContent = t("zoteroChecking");
101
+ else if (L.zotero === "down") {
102
+ lib.textContent = t("zoteroDown");
103
+ lib.classList.add("down");
104
+ } else {
105
+ const parts = [t("items", { n: L.items ?? "?" })];
106
+ if (L.toReview) parts.push(t("toReview", { n: L.toReview }));
107
+ lib.textContent = `Zotero: ${parts.join(", ")}`;
108
+ }
109
+ if (state?.down?.length) {
110
+ lib.textContent += ` | ${t("serversDown", { x: state.down.join(", ") })}`;
111
+ lib.classList.add("down");
112
+ }
113
+ $("input").placeholder = mode() === "librarian" ? t("placeholderLibrarian") : t("placeholderResearcher");
114
+ renderActions();
115
+ renderBanner();
116
+ }
117
+
118
+ function hasCommand(name) {
119
+ const base = name.split(" ")[0];
120
+ return commands.length === 0 || commands.some((c) => c.name === base);
121
+ }
122
+
123
+ function renderActions() {
124
+ const box = $("actions");
125
+ box.replaceChildren();
126
+ const list = [...ACTIONS[mode()]].filter((a) => hasCommand(a.cmd));
127
+ for (const a of list) box.append(actionButton(a));
128
+ box.append(el("hr"));
129
+ for (const a of ACTIONS.both.filter((x) => hasCommand(x.cmd))) box.append(actionButton(a));
130
+ }
131
+
132
+ function actionButton(a) {
133
+ return el("button", {
134
+ type: "button",
135
+ text: a[lang] ?? a.en,
136
+ onclick: () => {
137
+ closeSide();
138
+ if (a.send) sendMessage(`/${a.cmd}`);
139
+ else {
140
+ const input = $("input");
141
+ input.value = `/${a.cmd} `;
142
+ input.focus();
143
+ autosize();
144
+ }
145
+ },
146
+ });
147
+ }
148
+
149
+ function renderBanner() {
150
+ const b = $("banner");
151
+ b.replaceChildren();
152
+ const m = state?.model ?? session?.model;
153
+ if (!m && session) {
154
+ b.append(el("span", { text: t("connectModel") }), el("button", { type: "button", class: "primary small", text: t("connect"), onclick: openModels }));
155
+ b.hidden = false;
156
+ } else b.hidden = true;
157
+ }
158
+
159
+ async function loadSessions() {
160
+ let list = [];
161
+ try {
162
+ list = (await api("/api/sessions")).sessions ?? [];
163
+ } catch {
164
+ return;
165
+ }
166
+ const ul = $("sessions");
167
+ ul.replaceChildren();
168
+ if (!list.length) {
169
+ ul.append(el("li", { class: "muted small", text: t("noConversations") }));
170
+ return;
171
+ }
172
+ const fmt = new Intl.DateTimeFormat(lang === "pt" ? "pt-PT" : "en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
173
+ for (const s of list) {
174
+ const current = session?.sessionFile === s.path;
175
+ ul.append(
176
+ el(
177
+ "li",
178
+ { class: current ? "current" : "" },
179
+ el(
180
+ "button",
181
+ {
182
+ type: "button",
183
+ "aria-current": current ? "true" : false,
184
+ onclick: async () => {
185
+ closeSide();
186
+ if (busy) return note(t("busy"), "warning");
187
+ try {
188
+ await api("/api/session", { path: s.path });
189
+ } catch (err) {
190
+ note(err.message, "error");
191
+ }
192
+ },
193
+ },
194
+ el("span", { class: "what", text: s.name || s.first || "…" }),
195
+ el("span", { class: "when", text: fmt.format(new Date(s.modified)) }),
196
+ ),
197
+ ),
198
+ );
199
+ }
200
+ }
201
+
202
+ function closeSide() {
203
+ $("side").classList.remove("open");
204
+ $("menu-btn").setAttribute("aria-expanded", "false");
205
+ }
206
+
207
+ // ---------------------------------------------------------------- conversation
208
+
209
+ function scrollDown(force = false) {
210
+ const near = log.scrollHeight - log.scrollTop - log.clientHeight < 160;
211
+ if (force || near) log.scrollTop = log.scrollHeight;
212
+ }
213
+
214
+ function note(text, kind = "info", mono = false) {
215
+ clearEmpty();
216
+ const n = el("div", { class: `note ${kind}${mono ? " mono" : ""}`, text });
217
+ log.append(n);
218
+ scrollDown(true);
219
+ return n;
220
+ }
221
+
222
+ function clearEmpty() {
223
+ log.querySelector(".empty")?.remove();
224
+ }
225
+
226
+ function renderEmpty() {
227
+ if (log.children.length) return;
228
+ const s = SUGGESTIONS[mode()][lang] ?? SUGGESTIONS[mode()].en;
229
+ log.append(
230
+ el(
231
+ "div",
232
+ { class: "empty" },
233
+ el("h1", { text: t("emptyTitle") }),
234
+ el(
235
+ "div",
236
+ { class: "chips" },
237
+ s.map((x) =>
238
+ el("button", {
239
+ type: "button",
240
+ text: x.trim(),
241
+ onclick: () => {
242
+ const input = $("input");
243
+ input.value = x;
244
+ input.focus();
245
+ autosize();
246
+ },
247
+ }),
248
+ ),
249
+ ),
250
+ ),
251
+ );
252
+ }
253
+
254
+ function textOf(content) {
255
+ if (typeof content === "string") return content;
256
+ if (!Array.isArray(content)) return "";
257
+ return content.filter((c) => c.type === "text").map((c) => c.text).join("\n");
258
+ }
259
+
260
+ /** Prompt templates arrive as their long, expanded text; show the command that was typed instead. */
261
+ const sentTemplates = [];
262
+
263
+ function userMessage(text) {
264
+ clearEmpty();
265
+ let bubble;
266
+ if (text.length > 700) {
267
+ const first = text.split("\n").find((l) => l.trim()) ?? "";
268
+ bubble = el("details", { class: "bubble" }, el("summary", { text: first.length > 120 ? `${first.slice(0, 117)}...` : first }), el("div", { text }));
269
+ } else bubble = el("div", { class: "bubble", text });
270
+ log.append(el("div", { class: "msg user" }, bubble));
271
+ scrollDown(true);
272
+ }
273
+
274
+ function toolLabel(name) {
275
+ const short = String(name).replace(/^(zotero|scholar)_/, "");
276
+ return (TOOLS[lang] ?? TOOLS.en)[short] ?? (TOOLS.en[short] || name);
277
+ }
278
+
279
+ function summarizeArgs(args) {
280
+ if (!args || typeof args !== "object") return "";
281
+ const parts = [];
282
+ for (const [k, v] of Object.entries(args)) {
283
+ if (k === "dry_run" || v === "" || v === null || v === undefined) continue;
284
+ let s;
285
+ if (Array.isArray(v)) s = v.every((x) => typeof x !== "object") ? v.join(", ") : `${v.length}`;
286
+ else s = typeof v === "string" ? v : JSON.stringify(v);
287
+ if (s.length > 80) s = `${s.slice(0, 77)}...`;
288
+ parts.push(`${k}: ${s}`);
289
+ if (parts.join(" ").length > 160) break;
290
+ }
291
+ return parts.join(" ");
292
+ }
293
+
294
+ function toolRow(id, name, args) {
295
+ let row = toolRows.get(id);
296
+ if (row) return row;
297
+ const summary = el("summary", {}, el("span", { class: "label", text: toolLabel(name) }), el("span", { class: "args", text: summarizeArgs(args) }));
298
+ row = el("details", { class: "tool running" }, summary);
299
+ row.dataset.name = name;
300
+ toolRows.set(id, row);
301
+ updateTool(row, name, args);
302
+ return row;
303
+ }
304
+
305
+ /** Arguments arrive after the tool call starts; files that Sub-Sub writes get an Open button. */
306
+ function updateTool(row, name, args) {
307
+ if (!args || !Object.keys(args).length) return;
308
+ row.querySelector(".args").textContent = summarizeArgs(args);
309
+ if (args.path && (name === "write" || name === "edit") && !row.querySelector(".open")) {
310
+ row.querySelector("summary").append(
311
+ el("button", {
312
+ type: "button",
313
+ class: "open",
314
+ text: t("open"),
315
+ onclick: (e) => {
316
+ e.preventDefault();
317
+ api("/api/open", { path: args.path }).catch((err) => note(err.message, "warning"));
318
+ },
319
+ }),
320
+ );
321
+ }
322
+ }
323
+
324
+ function finishTool(id, result, isError) {
325
+ const row = toolRows.get(id);
326
+ if (!row) return;
327
+ row.classList.remove("running");
328
+ row.classList.add(isError ? "error" : "done");
329
+ const text = textOf(result?.content ?? result);
330
+ row.querySelector("pre")?.remove();
331
+ if (text) row.append(el("pre", { text: text.length > 6000 ? `${text.slice(0, 6000)}\n...` : text }));
332
+ if (isError) row.querySelector(".label").textContent = `${toolLabel(row.dataset.name)} (${t("failed")})`;
333
+ }
334
+
335
+ /** A container for one assistant message; blocks are keyed by content index. */
336
+ function startAssistant() {
337
+ clearEmpty();
338
+ const box = el("div", { class: "msg assistant" });
339
+ log.append(box);
340
+ currentMsg = { box, blocks: new Map(), raf: 0 };
341
+ return currentMsg;
342
+ }
343
+
344
+ function block(msg, index, kind) {
345
+ let b = msg.blocks.get(index);
346
+ if (!b) {
347
+ b = { kind, text: "", node: kind === "text" ? el("div", { class: "md" }) : null };
348
+ msg.blocks.set(index, b);
349
+ if (b.node) msg.box.append(b.node);
350
+ }
351
+ return b;
352
+ }
353
+
354
+ function paint(msg) {
355
+ if (msg.raf) return;
356
+ msg.raf = requestAnimationFrame(() => {
357
+ msg.raf = 0;
358
+ for (const b of msg.blocks.values()) if (b.kind === "text" && b.dirty) {
359
+ b.node.innerHTML = md(b.text);
360
+ b.dirty = false;
361
+ }
362
+ scrollDown();
363
+ });
364
+ }
365
+
366
+ function renderAssistantFinal(message, msg = startAssistant()) {
367
+ msg.box.replaceChildren();
368
+ msg.blocks.clear();
369
+ for (const c of message.content ?? []) {
370
+ if (c.type === "text" && c.text?.trim()) {
371
+ const node = el("div", { class: "md" });
372
+ node.innerHTML = md(c.text);
373
+ msg.box.append(node);
374
+ } else if (c.type === "toolCall") {
375
+ const row = toolRow(c.id, c.name, c.arguments);
376
+ updateTool(row, c.name, c.arguments);
377
+ msg.box.append(row);
378
+ }
379
+ }
380
+ if (message.stopReason === "error" && message.errorMessage) msg.box.append(el("div", { class: "note error", text: message.errorMessage }));
381
+ if (message.stopReason === "aborted") msg.box.append(el("div", { class: "note", text: t("stopped") }));
382
+ if (!msg.box.children.length) msg.box.remove();
383
+ scrollDown();
384
+ }
385
+
386
+ function renderHistory(messages) {
387
+ log.replaceChildren();
388
+ toolRows.clear();
389
+ currentMsg = null;
390
+ for (const m of messages ?? []) {
391
+ if (m.role === "user") userMessage(textOf(m.content));
392
+ else if (m.role === "assistant") renderAssistantFinal(m);
393
+ else if (m.role === "toolResult") finishTool(m.toolCallId, m, m.isError);
394
+ else if (m.role === "custom" && m.display) note(textOf(m.content));
395
+ else if (m.role === "compactionSummary" || m.role === "branchSummary") note(t("earlier"));
396
+ }
397
+ for (const row of toolRows.values()) if (row.classList.contains("running")) row.classList.replace("running", "done");
398
+ renderEmpty();
399
+ scrollDown(true);
400
+ }
401
+
402
+ function setBusy(b) {
403
+ busy = b;
404
+ $("stop").hidden = !b;
405
+ if (b && !working) {
406
+ working = el("div", { class: "working", text: t("working") });
407
+ log.append(working);
408
+ scrollDown();
409
+ } else if (!b && working) {
410
+ working.remove();
411
+ working = null;
412
+ }
413
+ if (working) log.append(working); // keep it last
414
+ }
415
+
416
+ // ---------------------------------------------------------------- events from Sub-Sub
417
+
418
+ function onEvent(ev) {
419
+ switch (ev.type) {
420
+ case "hello":
421
+ lang = ev.lang === "pt" ? "pt" : "en";
422
+ applyLang();
423
+ state = ev.state ?? state;
424
+ session = ev.session ?? null;
425
+ commands = ev.commands ?? [];
426
+ providers = ev.providers ?? [];
427
+ $("version").textContent = ev.version ? `v${ev.version}` : "";
428
+ renderHeader();
429
+ renderHistory(ev.messages);
430
+ if (ev.error) note(ev.error, "error");
431
+ setBusy(!!ev.busy);
432
+ dialogs.length = 0;
433
+ for (const d of ev.dialogs ?? []) dialogs.push(d);
434
+ nextDialog();
435
+ loadSessions();
436
+ break;
437
+ case "subsub_state":
438
+ state = ev.state;
439
+ renderHeader();
440
+ break;
441
+ case "agent_start":
442
+ setBusy(true);
443
+ break;
444
+ case "agent_settled":
445
+ setBusy(false);
446
+ currentMsg = null;
447
+ refreshSession();
448
+ break;
449
+ case "message_start":
450
+ if (ev.message?.role === "user") userMessage(sentTemplates.shift() ?? textOf(ev.message.content));
451
+ else if (ev.message?.role === "assistant") startAssistant();
452
+ else if (ev.message?.role === "custom" && ev.message.display) note(textOf(ev.message.content));
453
+ if (working) log.append(working);
454
+ break;
455
+ case "message_update": {
456
+ const a = ev.assistantMessageEvent;
457
+ const msg = currentMsg ?? startAssistant();
458
+ if (a.type === "text_delta") {
459
+ const b = block(msg, a.contentIndex, "text");
460
+ b.text += a.delta;
461
+ b.dirty = true;
462
+ paint(msg);
463
+ } else if (a.type === "text_end") {
464
+ const b = block(msg, a.contentIndex, "text");
465
+ b.text = a.content ?? b.text;
466
+ b.dirty = true;
467
+ paint(msg);
468
+ } else if (a.type === "toolcall_start") {
469
+ const b = block(msg, a.contentIndex, "tool");
470
+ b.id = a.id;
471
+ msg.box.append(toolRow(a.id, a.toolName, {}));
472
+ } else if (a.type === "toolcall_end" && a.toolCall) {
473
+ const row = toolRow(a.toolCall.id, a.toolCall.name, a.toolCall.arguments);
474
+ updateTool(row, a.toolCall.name, a.toolCall.arguments);
475
+ if (!row.isConnected) msg.box.append(row);
476
+ }
477
+ if (working) log.append(working);
478
+ break;
479
+ }
480
+ case "message_end":
481
+ if (ev.message?.role === "assistant") {
482
+ renderAssistantFinal(ev.message, currentMsg ?? startAssistant());
483
+ currentMsg = null;
484
+ }
485
+ if (working) log.append(working);
486
+ break;
487
+ case "tool_execution_start": {
488
+ const row = toolRow(ev.toolCallId, ev.toolName, ev.args);
489
+ updateTool(row, ev.toolName, ev.args);
490
+ if (!row.isConnected) (currentMsg?.box ?? log).append(row);
491
+ break;
492
+ }
493
+ case "tool_execution_end":
494
+ finishTool(ev.toolCallId, ev.result, ev.isError);
495
+ break;
496
+ case "extension_ui_request":
497
+ if (ev.method === "notify") {
498
+ if (/^Sub-Sub: (librarian|researcher) mode$/.test(ev.message)) break;
499
+ note(ev.message, ev.notifyType === "error" ? "error" : ev.notifyType === "warning" ? "warning" : "info", /\n/.test(ev.message));
500
+ } else {
501
+ dialogs.push(ev);
502
+ nextDialog();
503
+ }
504
+ break;
505
+ case "dialog_closed": {
506
+ const i = dialogs.findIndex((d) => d.id === ev.id);
507
+ if (i >= 0) dialogs.splice(i, 1);
508
+ if (shownDialog?.id === ev.id) {
509
+ shownDialog = null;
510
+ $("dialog").close();
511
+ nextDialog();
512
+ }
513
+ break;
514
+ }
515
+ case "auto_retry_start":
516
+ note(`${ev.errorMessage ?? ""} (${ev.attempt}/${ev.maxAttempts})`, "warning");
517
+ break;
518
+ case "extension_error":
519
+ note(ev.error, "error");
520
+ break;
521
+ case "agent_exit":
522
+ setBusy(false);
523
+ log.append(
524
+ el(
525
+ "div",
526
+ { class: "note error" },
527
+ el("div", { text: t("agentExit") }),
528
+ ev.stderr ? el("pre", { class: "small", text: ev.stderr }) : null,
529
+ el("button", { type: "button", class: "primary small", text: t("startAgain"), onclick: () => api("/api/restart", {}).catch((e) => note(e.message, "error")) }),
530
+ ),
531
+ );
532
+ scrollDown(true);
533
+ break;
534
+ }
535
+ }
536
+
537
+ async function refreshSession() {
538
+ // The session file exists only after the first message; then the list can mark it.
539
+ try {
540
+ session = await api("/api/state");
541
+ } catch {
542
+ /* the stream reconnects */
543
+ }
544
+ renderBanner();
545
+ loadSessions();
546
+ }
547
+
548
+ // ---------------------------------------------------------------- dialogs (approvals)
549
+
550
+ function dialogTitle(d) {
551
+ const raw = String(d.title ?? "").replace(/^Sub-Sub:\s*/, "");
552
+ let m;
553
+ if ((m = raw.match(/^apply (\S+)\?$/))) return t("apply", { x: toolLabel(m[1]) });
554
+ if ((m = raw.match(/^run (\S+)\?$/))) return t("run", { x: toolLabel(m[1]) });
555
+ if (raw === "undo?") return t("undo");
556
+ if ((m = raw.match(/^(write|edit) (.+)\?$/))) return t("write", { x: m[2] });
557
+ return raw;
558
+ }
559
+
560
+ /** Colour "+ added" and "- removed" parts, as the terminal does. */
561
+ function paintPreview(pre, text) {
562
+ pre.replaceChildren();
563
+ const seg = /(^|: |; )([+-] [^;]+)/g;
564
+ for (const line of String(text ?? "").split("\n")) {
565
+ let last = 0;
566
+ for (const m of line.matchAll(seg)) {
567
+ const start = m.index + m[1].length;
568
+ if (start > last) pre.append(line.slice(last, start));
569
+ pre.append(el("span", { class: m[2].startsWith("+") ? "add" : "rem", text: m[2] }));
570
+ last = start + m[2].length;
571
+ }
572
+ if (last < line.length) pre.append(line.slice(last));
573
+ pre.append("\n");
574
+ }
575
+ }
576
+
577
+ function nextDialog() {
578
+ if (shownDialog || !dialogs.length) return;
579
+ const d = dialogs[0];
580
+ shownDialog = d;
581
+ const dlg = $("dialog");
582
+ $("dialog-title").textContent = dialogTitle(d);
583
+ const hint = $("dialog-hint");
584
+ hint.textContent = d.method === "confirm" ? t("approveHint") : "";
585
+ hint.hidden = d.method !== "confirm";
586
+ const pre = $("dialog-message");
587
+ pre.hidden = !d.message;
588
+ if (d.message) paintPreview(pre, d.message);
589
+ const body = $("dialog-body");
590
+ body.replaceChildren();
591
+ const buttons = $("dialog-buttons");
592
+ buttons.replaceChildren();
593
+ const answer = async (reply) => {
594
+ try {
595
+ await api("/api/dialog", { id: d.id, ...reply });
596
+ } catch (err) {
597
+ note(err.message, "error");
598
+ }
599
+ };
600
+ if (d.method === "confirm") {
601
+ buttons.append(
602
+ el("button", { type: "button", class: "secondary", text: t("no"), onclick: () => answer({ confirmed: false }) }),
603
+ el("button", { type: "button", class: "primary", text: t("yes"), onclick: () => answer({ confirmed: true }) }),
604
+ );
605
+ } else if (d.method === "select") {
606
+ for (const o of d.options ?? []) buttons.append(el("button", { type: "button", class: "secondary", text: o, onclick: () => answer({ value: o }) }));
607
+ buttons.append(el("button", { type: "button", class: "ghost", text: t("cancel"), onclick: () => answer({ cancelled: true }) }));
608
+ } else {
609
+ const field = d.method === "editor" ? el("textarea", {}) : el("input", { type: "text", placeholder: d.placeholder ?? "" });
610
+ field.value = d.prefill ?? "";
611
+ body.append(field);
612
+ buttons.append(
613
+ el("button", { type: "button", class: "ghost", text: t("cancel"), onclick: () => answer({ cancelled: true }) }),
614
+ el("button", { type: "button", class: "primary", text: t("ok"), onclick: () => answer({ value: field.value }) }),
615
+ );
616
+ }
617
+ dlg.oncancel = (e) => {
618
+ e.preventDefault();
619
+ answer(d.method === "confirm" ? { confirmed: false } : { cancelled: true });
620
+ };
621
+ if (!dlg.open) dlg.showModal();
622
+ buttons.querySelector(d.method === "confirm" ? ".secondary" : "button")?.focus();
623
+ }
624
+
625
+ // ---------------------------------------------------------------- models and keys
626
+
627
+ async function openModels() {
628
+ const dlg = $("model-dialog");
629
+ const ul = $("models");
630
+ ul.replaceChildren(el("li", { class: "empty-models", text: "…" }));
631
+ const sel = $("login-provider");
632
+ sel.replaceChildren(...providers.map((p) => el("option", { value: p.id, text: p.label })));
633
+ const link = $("login-link");
634
+ const setLink = () => {
635
+ const p = providers.find((x) => x.id === sel.value);
636
+ link.href = p?.url ?? "#";
637
+ };
638
+ sel.onchange = setLink;
639
+ setLink();
640
+ if (!dlg.open) dlg.showModal();
641
+ let models = [];
642
+ try {
643
+ models = (await api("/api/models")).models ?? [];
644
+ } catch (err) {
645
+ note(err.message, "error");
646
+ }
647
+ ul.replaceChildren();
648
+ if (!models.length) ul.append(el("li", { class: "empty-models", text: t("noModels") }));
649
+ const cur = state?.model ?? session?.model;
650
+ const isCurrent = (m) => cur && cur.provider === m.provider && cur.id === m.id;
651
+ models.sort((a, b) => Number(isCurrent(b)) - Number(isCurrent(a)) || `${a.provider}/${a.id}`.localeCompare(`${b.provider}/${b.id}`));
652
+ for (const m of models) {
653
+ const isCur = cur && cur.provider === m.provider && cur.id === m.id;
654
+ ul.append(
655
+ el(
656
+ "li",
657
+ {},
658
+ el("button", {
659
+ type: "button",
660
+ class: isCur ? "current" : "",
661
+ text: `${m.provider}/${m.id}`,
662
+ onclick: async () => {
663
+ if (busy) return note(t("busy"), "warning");
664
+ try {
665
+ await api("/api/model", { provider: m.provider, id: m.id, both: $("model-both").checked });
666
+ dlg.close();
667
+ } catch (err) {
668
+ note(err.message, "error");
669
+ }
670
+ },
671
+ }),
672
+ ),
673
+ );
674
+ }
675
+ }
676
+
677
+ $("login-form")?.addEventListener("submit", async (e) => {
678
+ e.preventDefault();
679
+ const btn = $("login-save");
680
+ btn.disabled = true;
681
+ btn.textContent = t("saving");
682
+ try {
683
+ await api("/api/login", { provider: $("login-provider").value, key: $("login-key").value });
684
+ $("login-key").value = "";
685
+ note(t("saved"));
686
+ await openModels();
687
+ } catch (err) {
688
+ note(err.message, "error");
689
+ } finally {
690
+ btn.disabled = false;
691
+ btn.textContent = t("save");
692
+ }
693
+ });
694
+
695
+ // ---------------------------------------------------------------- composer
696
+
697
+ const input = $("input");
698
+
699
+ function autosize() {
700
+ input.style.height = "auto";
701
+ input.style.height = `${Math.min(input.scrollHeight + 2, window.innerHeight * 0.4)}px`;
702
+ suggest();
703
+ }
704
+
705
+ async function sendMessage(text) {
706
+ const message = text.trim();
707
+ if (!message) return;
708
+ const name = message.startsWith("/") ? message.slice(1).split(/\s/)[0] : "";
709
+ const isTemplate = name && commands.some((c) => c.name === name && c.source === "prompt");
710
+ if (isTemplate) sentTemplates.push(message);
711
+ try {
712
+ await api("/api/prompt", { message });
713
+ } catch (err) {
714
+ if (isTemplate) sentTemplates.pop();
715
+ note(err.message, "error");
716
+ }
717
+ }
718
+
719
+ let suggestIndex = 0;
720
+ function suggest() {
721
+ const box = $("suggest");
722
+ const v = input.value;
723
+ if (!/^\/\S*$/.test(v)) {
724
+ box.hidden = true;
725
+ return;
726
+ }
727
+ const q = v.slice(1).toLowerCase();
728
+ const list = commands.filter((c) => c.name !== "subsub-refresh" && c.name.toLowerCase().startsWith(q)).slice(0, 12);
729
+ if (!list.length) {
730
+ box.hidden = true;
731
+ return;
732
+ }
733
+ suggestIndex = Math.min(suggestIndex, list.length - 1);
734
+ box.replaceChildren(
735
+ ...list.map((c, i) =>
736
+ el(
737
+ "button",
738
+ {
739
+ type: "button",
740
+ role: "option",
741
+ "aria-selected": String(i === suggestIndex),
742
+ onclick: () => pick(c),
743
+ },
744
+ el("span", { class: "name", text: `/${c.name}` }),
745
+ el("span", { class: "desc", text: (c.description ?? "").replace(/^Sub-Sub:\s*/, "") }),
746
+ ),
747
+ ),
748
+ );
749
+ box.hidden = false;
750
+ box.list = list;
751
+ }
752
+ function pick(c) {
753
+ input.value = `/${c.name} `;
754
+ $("suggest").hidden = true;
755
+ input.focus();
756
+ }
757
+
758
+ input.addEventListener("input", () => {
759
+ suggestIndex = 0;
760
+ autosize();
761
+ });
762
+ input.addEventListener("keydown", (e) => {
763
+ const box = $("suggest");
764
+ if (!box.hidden && box.list) {
765
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
766
+ e.preventDefault();
767
+ suggestIndex = (suggestIndex + (e.key === "ArrowDown" ? 1 : -1) + box.list.length) % box.list.length;
768
+ suggest();
769
+ return;
770
+ }
771
+ if (e.key === "Tab") {
772
+ e.preventDefault();
773
+ pick(box.list[suggestIndex]);
774
+ return;
775
+ }
776
+ if (e.key === "Escape") {
777
+ box.hidden = true;
778
+ return;
779
+ }
780
+ }
781
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
782
+ e.preventDefault();
783
+ $("composer").requestSubmit();
784
+ }
785
+ });
786
+ $("composer").addEventListener("submit", (e) => {
787
+ e.preventDefault();
788
+ const text = input.value;
789
+ if (!text.trim()) return;
790
+ input.value = "";
791
+ autosize();
792
+ sendMessage(text);
793
+ });
794
+ $("stop").addEventListener("click", () => api("/api/abort", {}).catch(() => {}));
795
+ $("new-btn").addEventListener("click", async () => {
796
+ closeSide();
797
+ if (busy) return note(t("busy"), "warning");
798
+ try {
799
+ await api("/api/new", {});
800
+ } catch (err) {
801
+ note(err.message, "error");
802
+ }
803
+ });
804
+ for (const b of document.querySelectorAll(".modes button")) {
805
+ b.addEventListener("click", () => {
806
+ if (b.dataset.mode !== mode()) sendMessage(`/${b.dataset.mode}`);
807
+ });
808
+ }
809
+ $("profile").addEventListener("change", (e) => sendMessage(`/profile ${e.target.value}`));
810
+ $("profile-side").addEventListener("change", (e) => sendMessage(`/profile ${e.target.value}`));
811
+ $("model-btn").addEventListener("click", openModels);
812
+ $("model-btn-side").addEventListener("click", () => {
813
+ closeSide();
814
+ openModels();
815
+ });
816
+ $("model-close").addEventListener("click", () => $("model-dialog").close());
817
+ $("main").addEventListener("click", () => {
818
+ if ($("side").classList.contains("open")) closeSide();
819
+ });
820
+ $("menu-btn").addEventListener("click", (e) => {
821
+ e.stopPropagation();
822
+ const side = $("side");
823
+ side.classList.toggle("open");
824
+ $("menu-btn").setAttribute("aria-expanded", String(side.classList.contains("open")));
825
+ });
826
+ $("quit-btn").addEventListener("click", async () => {
827
+ try {
828
+ await api("/api/quit", {});
829
+ } catch {
830
+ /* closing */
831
+ }
832
+ stream?.close();
833
+ document.body.replaceChildren(el("p", { class: "empty", text: t("quitDone") }));
834
+ });
835
+
836
+ // ---------------------------------------------------------------- the event stream
837
+
838
+ let stream = null;
839
+ let lostNote = null;
840
+ function connect() {
841
+ stream = new EventSource("/api/events");
842
+ stream.onmessage = (m) => {
843
+ lostNote?.remove();
844
+ lostNote = null;
845
+ let ev;
846
+ try {
847
+ ev = JSON.parse(m.data);
848
+ } catch {
849
+ return;
850
+ }
851
+ onEvent(ev);
852
+ };
853
+ stream.onerror = () => {
854
+ if (!lostNote) lostNote = note(t("disconnected"), "warning");
855
+ };
856
+ }
857
+
858
+ applyLang();
859
+ connect();