@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/README.md +16 -2
- package/assets/icon.icns +0 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.png +0 -0
- package/dist/cli.js +12 -0
- package/dist/init.js +2 -2
- package/dist/shortcut.js +165 -0
- package/dist/subsub.js +40 -0
- package/dist/web.js +702 -0
- package/docs/Subsub.md +11 -0
- package/package.json +5 -3
- package/src/cli.ts +12 -0
- package/src/init.ts +2 -2
- package/src/shortcut.ts +184 -0
- package/src/subsub.ts +38 -0
- package/src/web.ts +710 -0
- package/web/app.css +203 -0
- package/web/app.js +859 -0
- package/web/fonts/IBMPlexMono.woff2 +0 -0
- package/web/fonts/IBMPlexSans.woff2 +0 -0
- package/web/fonts/IBMPlexSerif.woff2 +0 -0
- package/web/glauca.css +82 -0
- package/web/i18n.js +263 -0
- package/web/icon.svg +1 -0
- package/web/index.html +112 -0
- package/web/vendor/marked.esm.js +78 -0
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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();
|