@tiagojct/subsub 0.16.0 → 0.18.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/CHANGELOG.md +28 -0
- package/README.md +9 -12
- package/dist/bench.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/config.js +1 -7
- package/dist/doctor.js +1 -2
- package/dist/feedback.js +55 -0
- package/dist/init.js +7 -20
- package/dist/look.js +2 -2
- package/dist/notes.js +41 -11
- package/dist/prompt.js +4 -6
- package/dist/read.js +89 -0
- package/dist/roles.js +4 -10
- package/dist/subsub.js +161 -53
- package/dist/web.js +139 -9
- package/package.json +1 -1
- package/prompts/compare.md +1 -1
- package/prompts/gaps.md +1 -1
- package/prompts/lit.md +28 -9
- package/roles/researcher.md +8 -3
- package/src/bench.ts +1 -1
- package/src/cli.ts +2 -2
- package/src/config.ts +2 -12
- package/src/doctor.ts +1 -2
- package/src/feedback.ts +70 -0
- package/src/init.ts +6 -31
- package/src/look.ts +2 -4
- package/src/notes.ts +44 -10
- package/src/prompt.ts +6 -8
- package/src/read.ts +108 -0
- package/src/roles.ts +5 -12
- package/src/subsub.ts +154 -57
- package/src/usage.ts +1 -1
- package/src/web.ts +129 -9
- package/templates/Zotero agent.md +2 -2
- package/web/app.css +72 -2
- package/web/app.js +297 -23
- package/web/i18n.js +61 -25
- package/web/index.html +17 -16
- package/dist/profiles.js +0 -96
- package/src/profiles.ts +0 -118
package/web/app.js
CHANGED
|
@@ -7,7 +7,7 @@ import { ACTIONS, STRINGS, SUGGESTIONS, TOOLS } from "/i18n.js";
|
|
|
7
7
|
// ---------------------------------------------------------------- state
|
|
8
8
|
|
|
9
9
|
let lang = "en";
|
|
10
|
-
let state = null; // Sub-Sub's own state: mode,
|
|
10
|
+
let state = null; // Sub-Sub's own state: mode, model, library, down
|
|
11
11
|
let session = null; // pi's session state
|
|
12
12
|
let busy = false;
|
|
13
13
|
let commands = [];
|
|
@@ -49,9 +49,27 @@ async function api(path, body) {
|
|
|
49
49
|
// ---------------------------------------------------------------- markdown (no raw HTML, safe links)
|
|
50
50
|
|
|
51
51
|
const SAFE_LINK = /^(https?:|mailto:|zotero:|obsidian:)/i;
|
|
52
|
+
/** A path or name that the notes pane can open: a .md file without a scheme. */
|
|
53
|
+
const NOTE_PATH = /^(?![a-z][\w+.-]*:)[^\n]*\.md$/i;
|
|
54
|
+
const noteLink = (target, inner) => `<a href="#" data-note="${escapeHtml(target)}">${inner}</a>`;
|
|
52
55
|
function escapeHtml(s) {
|
|
53
56
|
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
54
57
|
}
|
|
58
|
+
// [[citekey]] and [[name|label]], as in Obsidian: a link to the note in the notes pane.
|
|
59
|
+
marked.use({
|
|
60
|
+
extensions: [
|
|
61
|
+
{
|
|
62
|
+
name: "wikilink",
|
|
63
|
+
level: "inline",
|
|
64
|
+
start: (src) => src.indexOf("[["),
|
|
65
|
+
tokenizer(src) {
|
|
66
|
+
const m = /^\[\[([^\]|\n#]+)(?:#[^\]|\n]*)?(?:\|([^\]\n]+))?\]\]/.exec(src);
|
|
67
|
+
if (m) return { type: "wikilink", raw: m[0], target: m[1].trim(), label: (m[2] ?? m[1]).trim() };
|
|
68
|
+
},
|
|
69
|
+
renderer: (tok) => noteLink(tok.target, escapeHtml(tok.label)),
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
});
|
|
55
73
|
marked.use({
|
|
56
74
|
gfm: true,
|
|
57
75
|
breaks: false,
|
|
@@ -61,10 +79,25 @@ marked.use({
|
|
|
61
79
|
},
|
|
62
80
|
link(token) {
|
|
63
81
|
const text = this.parser.parseInline(token.tokens);
|
|
82
|
+
if (NOTE_PATH.test(decodeURI(token.href ?? ""))) return noteLink(decodeURI(token.href), text);
|
|
64
83
|
if (!SAFE_LINK.test(token.href ?? "")) return text;
|
|
65
84
|
return `<a href="${escapeHtml(token.href)}" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
|
66
85
|
},
|
|
86
|
+
code(token) {
|
|
87
|
+
return (token.lang === "mermaid" && flowHtml(token.text)) || false;
|
|
88
|
+
},
|
|
89
|
+
codespan(token) {
|
|
90
|
+
const code = `<code>${escapeHtml(token.text)}</code>`;
|
|
91
|
+
return NOTE_PATH.test(token.text) ? noteLink(token.text, code) : code;
|
|
92
|
+
},
|
|
67
93
|
image(token) {
|
|
94
|
+
// In the notes pane, an image next to the note (a figure cut out of the PDF) is shown;
|
|
95
|
+
// anywhere else, and for any other address, only its description.
|
|
96
|
+
const href = decodeURI(token.href ?? "");
|
|
97
|
+
if (noteDir && /^(?![a-z][\w+.-]*:|[\\/])[^?#]+\.(png|jpe?g|gif|webp)$/i.test(href)) {
|
|
98
|
+
const src = `/api/note-file?path=${encodeURIComponent(`${noteDir}/${href}`)}`;
|
|
99
|
+
return `<img class="note-img" src="${escapeHtml(src)}" alt="${escapeHtml(token.text ?? "")}" loading="lazy">`;
|
|
100
|
+
}
|
|
68
101
|
return escapeHtml(token.text || token.href || "");
|
|
69
102
|
},
|
|
70
103
|
table(token) {
|
|
@@ -87,6 +120,45 @@ marked.use({
|
|
|
87
120
|
},
|
|
88
121
|
},
|
|
89
122
|
});
|
|
123
|
+
/**
|
|
124
|
+
* The flow of works that /lit writes as Mermaid (a chain of boxes, with dotted branches to the side
|
|
125
|
+
* for what was left out), drawn as boxes. Any other diagram stays as code; Obsidian draws it.
|
|
126
|
+
*/
|
|
127
|
+
function flowHtml(src) {
|
|
128
|
+
const lines = String(src).split("\n").map((l) => l.trim()).filter(Boolean);
|
|
129
|
+
if (!/^(flowchart|graph)\s+(TD|TB)\b/.test(lines.shift() ?? "")) return null;
|
|
130
|
+
const label = {};
|
|
131
|
+
const next = {};
|
|
132
|
+
const side = {};
|
|
133
|
+
const into = new Set();
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
const n = /^(\w+)\["([^"]*)"\]$/.exec(line);
|
|
136
|
+
if (n) {
|
|
137
|
+
label[n[1]] = n[2];
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const m = /^(\w+)(?:\["([^"]*)"\])?\s*(-->|-\.->)\s*(\w+)(?:\["([^"]*)"\])?$/.exec(line);
|
|
141
|
+
if (!m) return null;
|
|
142
|
+
const [, a, la, arrow, b, lb] = m;
|
|
143
|
+
if (la !== undefined) label[a] = la;
|
|
144
|
+
if (lb !== undefined) label[b] = lb;
|
|
145
|
+
if (arrow === "-->") {
|
|
146
|
+
if (next[a]) return null; // a branch of the main chain: not this kind of diagram
|
|
147
|
+
next[a] = b;
|
|
148
|
+
into.add(b);
|
|
149
|
+
} else (side[a] ??= []).push(b);
|
|
150
|
+
}
|
|
151
|
+
const rows = [];
|
|
152
|
+
const seen = new Set();
|
|
153
|
+
for (let id = Object.keys(next).find((k) => !into.has(k)); id && !seen.has(id); id = next[id]) {
|
|
154
|
+
seen.add(id);
|
|
155
|
+
const sides = (side[id] ?? []).map((s) => `<div class="flow-side">${escapeHtml(label[s] ?? s)}</div>`).join("");
|
|
156
|
+
rows.push(`<div class="flow-row"><div class="flow-box">${escapeHtml(label[id] ?? id)}</div>${sides}</div>`);
|
|
157
|
+
}
|
|
158
|
+
if (rows.length < 2) return null;
|
|
159
|
+
return `<div class="flow">${rows.join("")}</div>`;
|
|
160
|
+
}
|
|
161
|
+
|
|
90
162
|
function md(text) {
|
|
91
163
|
try {
|
|
92
164
|
return marked.parse(String(text ?? ""));
|
|
@@ -179,7 +251,6 @@ function renderHeader() {
|
|
|
179
251
|
b.setAttribute("aria-pressed", String(b.dataset.mode === mode()));
|
|
180
252
|
b.title = t(b.dataset.mode === "librarian" ? "librarianHint" : "researcherHint");
|
|
181
253
|
}
|
|
182
|
-
if (state?.profile) $("profile").value = $("profile-side").value = state.profile;
|
|
183
254
|
$("starbuck-toggle").checked = Boolean(state?.starbuck);
|
|
184
255
|
$("starbuck-label").title = t("starbuckHint");
|
|
185
256
|
const m = state?.model ?? session?.model;
|
|
@@ -229,10 +300,7 @@ function renderActions() {
|
|
|
229
300
|
const box = $("actions");
|
|
230
301
|
box.replaceChildren();
|
|
231
302
|
const shown = (a) =>
|
|
232
|
-
hasCommand(a.cmd) &&
|
|
233
|
-
|
|
234
|
-
(!a.needs || state?.[a.needs]) &&
|
|
235
|
-
(!a.profiles || a.profiles.includes(state?.profile ?? "scholar"));
|
|
303
|
+
hasCommand(a.cmd) && (!a.needs || state?.[a.needs]);
|
|
236
304
|
const groups = mode() === "librarian" ? [["library", "groupLibrary"]] : [["research", "groupResearch"], ["library", "groupLibrary"]];
|
|
237
305
|
for (const [group, label] of groups) {
|
|
238
306
|
const list = ACTIONS[group].filter(shown);
|
|
@@ -247,6 +315,7 @@ function actionButton(a) {
|
|
|
247
315
|
type: "button",
|
|
248
316
|
text: a[lang] ?? a.en,
|
|
249
317
|
onclick: () => {
|
|
318
|
+
if (a.item) return $("library-search").focus();
|
|
250
319
|
closeSide();
|
|
251
320
|
if (a.send) sendMessage(`/${a.cmd}`);
|
|
252
321
|
else {
|
|
@@ -269,6 +338,134 @@ function renderBanner() {
|
|
|
269
338
|
} else b.hidden = true;
|
|
270
339
|
}
|
|
271
340
|
|
|
341
|
+
// ---------------------------------------------------------------- library search
|
|
342
|
+
|
|
343
|
+
let libraryTimer = 0;
|
|
344
|
+
let libraryQuery = "";
|
|
345
|
+
|
|
346
|
+
function renderItems(items, message) {
|
|
347
|
+
const ul = $("items");
|
|
348
|
+
ul.replaceChildren();
|
|
349
|
+
if (message) return ul.append(el("li", { class: "muted", text: message }));
|
|
350
|
+
const researcher = mode() === "researcher";
|
|
351
|
+
for (const it of items) {
|
|
352
|
+
const ref = it.citekey || it.key;
|
|
353
|
+
const run = (cmd) => () => {
|
|
354
|
+
closeSide();
|
|
355
|
+
sendMessage(`/${cmd} ${ref}`);
|
|
356
|
+
};
|
|
357
|
+
ul.append(
|
|
358
|
+
el(
|
|
359
|
+
"li",
|
|
360
|
+
{},
|
|
361
|
+
el("div", { class: "item-title", text: it.title || it.key }),
|
|
362
|
+
el("div", { class: "item-meta", text: [it.authors, it.year, it.citekey].filter(Boolean).join(" · ") }),
|
|
363
|
+
el(
|
|
364
|
+
"div",
|
|
365
|
+
{ class: "item-actions" },
|
|
366
|
+
researcher ? el("button", { type: "button", text: t("readItem"), onclick: run("read") }) : null,
|
|
367
|
+
researcher ? el("button", { type: "button", text: t("noteItem"), onclick: run("lit-note") }) : null,
|
|
368
|
+
el("a", { href: `zotero://select/library/items/${encodeURIComponent(it.key)}`, text: "Zotero" }),
|
|
369
|
+
),
|
|
370
|
+
),
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function searchLibrary() {
|
|
376
|
+
const q = $("library-search").value.replace(/\s+/g, " ").trim();
|
|
377
|
+
libraryQuery = q;
|
|
378
|
+
clearTimeout(libraryTimer);
|
|
379
|
+
if (!q) return renderItems([]);
|
|
380
|
+
libraryTimer = setTimeout(async () => {
|
|
381
|
+
renderItems([], t("searching"));
|
|
382
|
+
try {
|
|
383
|
+
const data = await api(`/api/library?q=${encodeURIComponent(q)}`);
|
|
384
|
+
if (data.q !== libraryQuery) return; // an older search answered late
|
|
385
|
+
if (data.error) renderItems([], data.error);
|
|
386
|
+
else renderItems(data.items ?? [], data.items?.length ? "" : t("noItems"));
|
|
387
|
+
} catch (err) {
|
|
388
|
+
if (q === libraryQuery) renderItems([], err.message);
|
|
389
|
+
}
|
|
390
|
+
}, 350);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------- notes pane
|
|
394
|
+
|
|
395
|
+
let noteShown = null; // the note on screen, or null for the list
|
|
396
|
+
let noteDir = ""; // while a note is rendered: its folder, for the images next to it
|
|
397
|
+
let notesList = [];
|
|
398
|
+
|
|
399
|
+
function showPane(open) {
|
|
400
|
+
$("note-pane").hidden = !open;
|
|
401
|
+
document.querySelector(".app").classList.toggle("note-open", open);
|
|
402
|
+
$("notes-btn").setAttribute("aria-expanded", String(open));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function renderNoteList() {
|
|
406
|
+
const body = $("note-body");
|
|
407
|
+
const q = $("note-filter").value.trim().toLowerCase();
|
|
408
|
+
const list = notesList.filter((n) => !q || n.rel.toLowerCase().includes(q));
|
|
409
|
+
const fmt = new Intl.DateTimeFormat(lang === "pt" ? "pt-PT" : "en-GB", { day: "numeric", month: "short", year: "numeric" });
|
|
410
|
+
body.replaceChildren(
|
|
411
|
+
list.length
|
|
412
|
+
? el(
|
|
413
|
+
"ul",
|
|
414
|
+
{ class: "note-list" },
|
|
415
|
+
list.map((n) =>
|
|
416
|
+
el(
|
|
417
|
+
"li",
|
|
418
|
+
{},
|
|
419
|
+
el("button", { type: "button", onclick: () => openNote(n.path) }, el("span", { class: "rel", text: n.rel }), el("span", { class: "when", text: fmt.format(new Date(n.modified)) })),
|
|
420
|
+
),
|
|
421
|
+
),
|
|
422
|
+
)
|
|
423
|
+
: el("p", { class: "muted small", text: t("noNotes") }),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function showNoteList() {
|
|
428
|
+
noteShown = null;
|
|
429
|
+
$("note-name").textContent = t("notes");
|
|
430
|
+
$("note-back").hidden = true;
|
|
431
|
+
$("note-app").hidden = true;
|
|
432
|
+
$("note-filter").hidden = false;
|
|
433
|
+
showPane(true);
|
|
434
|
+
try {
|
|
435
|
+
notesList = (await api("/api/notes")).notes ?? [];
|
|
436
|
+
} catch (err) {
|
|
437
|
+
notesList = [];
|
|
438
|
+
note(err.message, "warning");
|
|
439
|
+
}
|
|
440
|
+
renderNoteList();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Show a note in the pane; anything that is not a note in the Sub-Sub folder goes to the system, as before. */
|
|
444
|
+
async function openNote(path) {
|
|
445
|
+
let data;
|
|
446
|
+
try {
|
|
447
|
+
data = await api(`/api/note?path=${encodeURIComponent(path)}`);
|
|
448
|
+
} catch {
|
|
449
|
+
return api("/api/open", { path }).catch((err) => note(err.message, "warning"));
|
|
450
|
+
}
|
|
451
|
+
noteShown = data;
|
|
452
|
+
$("note-name").textContent = data.name;
|
|
453
|
+
$("note-name").title = data.path;
|
|
454
|
+
$("note-back").hidden = false;
|
|
455
|
+
$("note-app").hidden = false;
|
|
456
|
+
$("note-filter").hidden = true;
|
|
457
|
+
const front = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(data.text);
|
|
458
|
+
const content = el("div", { class: "md" });
|
|
459
|
+
noteDir = data.path.replace(/[\\/][^\\/]*$/, "");
|
|
460
|
+
content.innerHTML = md(front ? data.text.slice(front[0].length) : data.text);
|
|
461
|
+
noteDir = "";
|
|
462
|
+
attachCopyButtons(content);
|
|
463
|
+
attachTableButtons(content, data.name.replace(/\.md$/i, ""));
|
|
464
|
+
$("note-body").replaceChildren(front ? el("details", { class: "front" }, el("summary", { text: t("properties") }), el("pre", { text: front[1] })) : "", content);
|
|
465
|
+
$("note-body").scrollTop = 0;
|
|
466
|
+
showPane(true);
|
|
467
|
+
}
|
|
468
|
+
|
|
272
469
|
let loadedSessions = [];
|
|
273
470
|
|
|
274
471
|
function renderSessionsList(filterText = "") {
|
|
@@ -283,12 +480,30 @@ function renderSessionsList(filterText = "") {
|
|
|
283
480
|
return;
|
|
284
481
|
}
|
|
285
482
|
const fmt = new Intl.DateTimeFormat(lang === "pt" ? "pt-PT" : "en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
|
483
|
+
// shortcut: the browser's own prompt and confirm; move to the page's dialog if they look out of place.
|
|
484
|
+
const rename = (s) => async () => {
|
|
485
|
+
const name = window.prompt(t("renamePrompt"), s.name || s.first || "");
|
|
486
|
+
if (!name?.trim()) return;
|
|
487
|
+
await api("/api/session/name", { path: s.path, name }).catch((err) => note(err.message, "warning"));
|
|
488
|
+
loadSessions();
|
|
489
|
+
};
|
|
490
|
+
const trash = (s) => async () => {
|
|
491
|
+
if (!window.confirm(t("trashConfirm"))) return;
|
|
492
|
+
await api("/api/session/trash", { path: s.path }).catch((err) => note(err.message, "warning"));
|
|
493
|
+
loadSessions();
|
|
494
|
+
};
|
|
286
495
|
for (const s of filtered) {
|
|
287
496
|
const current = session?.sessionFile === s.path;
|
|
288
497
|
ul.append(
|
|
289
498
|
el(
|
|
290
499
|
"li",
|
|
291
500
|
{ class: current ? "current" : "" },
|
|
501
|
+
el(
|
|
502
|
+
"span",
|
|
503
|
+
{ class: "session-tools" },
|
|
504
|
+
el("button", { type: "button", text: t("rename"), onclick: rename(s) }),
|
|
505
|
+
current ? null : el("button", { type: "button", text: t("trash"), onclick: trash(s) }),
|
|
506
|
+
),
|
|
292
507
|
el(
|
|
293
508
|
"button",
|
|
294
509
|
{
|
|
@@ -333,29 +548,64 @@ function exportConversation() {
|
|
|
333
548
|
note(t("noConversations"), "warning");
|
|
334
549
|
return;
|
|
335
550
|
}
|
|
551
|
+
const fence = (x) => `\`\`\`text\n${x.length > 3000 ? `${x.slice(0, 3000)}\n...` : x}\n\`\`\``;
|
|
336
552
|
for (const m of items) {
|
|
337
553
|
if (m.classList.contains("user")) {
|
|
338
|
-
const
|
|
339
|
-
|
|
554
|
+
const b = m.querySelector(".bubble");
|
|
555
|
+
// A long message is folded: export its full text, not the summary line.
|
|
556
|
+
const text = (b?.tagName === "DETAILS" ? b.querySelector("div")?.innerText : b?.innerText) ?? "";
|
|
557
|
+
if (text && text !== "/subsub-refresh") msgs.push(`### ${t("exportUser")}\n\n${text}\n`);
|
|
340
558
|
} else if (m.classList.contains("assistant")) {
|
|
341
|
-
const
|
|
342
|
-
|
|
559
|
+
const parts = [];
|
|
560
|
+
for (const c of m.children) {
|
|
561
|
+
if (c.classList.contains("md")) parts.push(c.innerText);
|
|
562
|
+
else if (c.classList.contains("tool")) {
|
|
563
|
+
const label = c.querySelector(".label")?.textContent ?? "";
|
|
564
|
+
const args = c.querySelector(".args")?.textContent ?? "";
|
|
565
|
+
const out = c.querySelector("pre")?.innerText;
|
|
566
|
+
parts.push(`> ${label}${args ? `: ${args}` : ""}${out ? `\n\n${fence(out)}` : ""}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (parts.length) msgs.push(`### Sub-Sub\n\n${parts.join("\n\n")}\n`);
|
|
343
570
|
}
|
|
344
571
|
}
|
|
345
572
|
if (!msgs.length) return;
|
|
346
573
|
const date = new Date().toISOString().slice(0, 10);
|
|
347
|
-
const content = `#
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
574
|
+
const content = `# ${t("exportHeading")} (${date})\n\n${t("exportMode")}: ${t(mode())}\n\n---\n\n${msgs.join("\n---\n\n")}`;
|
|
575
|
+
download(content, `Sub-Sub-${date}.md`, "text/markdown;charset=utf-8");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Save text as a file in the downloads folder. */
|
|
579
|
+
function download(content, name, type) {
|
|
580
|
+
const url = URL.createObjectURL(new Blob([content], { type }));
|
|
581
|
+
const a = el("a", { href: url, download: name });
|
|
353
582
|
document.body.append(a);
|
|
354
583
|
a.click();
|
|
355
584
|
a.remove();
|
|
356
585
|
URL.revokeObjectURL(url);
|
|
357
586
|
}
|
|
358
587
|
|
|
588
|
+
/** A table as CSV for Excel, Rayyan or Covidence. Excel in Portuguese expects semicolons; the BOM keeps the accents. */
|
|
589
|
+
function tableCsv(table) {
|
|
590
|
+
const sep = lang === "pt" ? ";" : ",";
|
|
591
|
+
const cell = (c) => {
|
|
592
|
+
const v = c.textContent.replace(/\s+/g, " ").trim(); // not innerText: the header is upper case only in CSS
|
|
593
|
+
return /[",;\n]/.test(v) ? `"${v.replaceAll('"', '""')}"` : v;
|
|
594
|
+
};
|
|
595
|
+
return `\ufeff${[...table.rows].map((r) => [...r.cells].map(cell).join(sep)).join("\r\n")}\r\n`;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** A "Download CSV" button under each table (evidence tables, extraction sheets, matrices). */
|
|
599
|
+
function attachTableButtons(root, name) {
|
|
600
|
+
const wraps = [...root.querySelectorAll(".md-table-wrap")];
|
|
601
|
+
wraps.forEach((wrap, i) => {
|
|
602
|
+
if (wrap.nextElementSibling?.classList.contains("table-actions")) return;
|
|
603
|
+
const file = `${name}${wraps.length > 1 ? `-${i + 1}` : ""}.csv`;
|
|
604
|
+
const btn = el("button", { type: "button", class: "msg-action-btn", text: t("downloadCsv"), onclick: () => download(tableCsv(wrap.querySelector("table")), file, "text/csv;charset=utf-8") });
|
|
605
|
+
wrap.after(el("div", { class: "table-actions" }, btn));
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
359
609
|
function closeSide() {
|
|
360
610
|
$("side").classList.remove("open");
|
|
361
611
|
$("menu-btn").setAttribute("aria-expanded", "false");
|
|
@@ -521,7 +771,7 @@ function updateTool(row, name, args) {
|
|
|
521
771
|
text: t("open"),
|
|
522
772
|
onclick: (e) => {
|
|
523
773
|
e.preventDefault();
|
|
524
|
-
|
|
774
|
+
openNote(args.path);
|
|
525
775
|
},
|
|
526
776
|
}),
|
|
527
777
|
);
|
|
@@ -627,6 +877,7 @@ function renderAssistantFinal(message, msg = startAssistant()) {
|
|
|
627
877
|
const node = el("div", { class: "md" });
|
|
628
878
|
node.innerHTML = md(c.text);
|
|
629
879
|
attachCopyButtons(node);
|
|
880
|
+
attachTableButtons(node, `Sub-Sub-${new Date().toISOString().slice(0, 10)}`);
|
|
630
881
|
msg.box.append(node);
|
|
631
882
|
} else if (c.type === "toolCall") {
|
|
632
883
|
const row = toolRow(c.id, c.name, c.arguments);
|
|
@@ -847,7 +1098,7 @@ function dialogTitle(d) {
|
|
|
847
1098
|
// The program sends the title in the user's language; both forms get the same friendly title.
|
|
848
1099
|
if ((m = raw.match(/^(?:apply|aplicar) (\S+)\?$/))) return t("apply", { x: toolLabel(m[1]) });
|
|
849
1100
|
if ((m = raw.match(/^(?:run|executar) (\S+)\?$/))) return t("run", { x: toolLabel(m[1]) });
|
|
850
|
-
if (raw === "undo?" || raw === "anular?") return t("
|
|
1101
|
+
if (raw === "undo?" || raw === "anular?") return t("undoAsk");
|
|
851
1102
|
if ((m = raw.match(/^(?:write the Starbuck report to|escrever o relatório do Starbuck em) (.+)\?$/))) return t("starbuckReport", { x: m[1] });
|
|
852
1103
|
if ((m = raw.match(/^(write|edit) (.+)\?$/))) return t("write", { x: m[2] });
|
|
853
1104
|
return raw;
|
|
@@ -895,6 +1146,7 @@ function nextDialog() {
|
|
|
895
1146
|
body.replaceChildren();
|
|
896
1147
|
const buttons = $("dialog-buttons");
|
|
897
1148
|
buttons.replaceChildren();
|
|
1149
|
+
buttons.classList.toggle("choices", d.method === "select" && (d.options ?? []).some((o) => o.length > 24));
|
|
898
1150
|
const answer = async (reply) => {
|
|
899
1151
|
try {
|
|
900
1152
|
await api("/api/dialog", { id: d.id, ...reply });
|
|
@@ -1120,12 +1372,20 @@ let suggestIndex = 0;
|
|
|
1120
1372
|
function suggest() {
|
|
1121
1373
|
const box = $("suggest");
|
|
1122
1374
|
const v = input.value;
|
|
1375
|
+
// "/lit-note " (a command and a space): show what it needs.
|
|
1376
|
+
const chosen = /^\/(\S+) $/.exec(v) && commands.find((c) => c.name === v.slice(1, -1));
|
|
1377
|
+
if (chosen?.hint) {
|
|
1378
|
+
box.replaceChildren(el("div", { class: "hint-row" }, el("span", { class: "name", text: `/${chosen.name}` }), el("span", { class: "desc", text: chosen.hint })));
|
|
1379
|
+
box.hidden = false;
|
|
1380
|
+
box.list = null;
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1123
1383
|
if (!/^\/\S*$/.test(v)) {
|
|
1124
1384
|
box.hidden = true;
|
|
1125
1385
|
return;
|
|
1126
1386
|
}
|
|
1127
1387
|
const q = v.slice(1).toLowerCase();
|
|
1128
|
-
const list = commands.filter((c) => c.name
|
|
1388
|
+
const list = commands.filter((c) => !c.name.startsWith("subsub-") && c.name.toLowerCase().startsWith(q)).slice(0, 12);
|
|
1129
1389
|
if (!list.length) {
|
|
1130
1390
|
box.hidden = true;
|
|
1131
1391
|
return;
|
|
@@ -1141,7 +1401,7 @@ function suggest() {
|
|
|
1141
1401
|
"aria-selected": String(i === suggestIndex),
|
|
1142
1402
|
onclick: () => pick(c),
|
|
1143
1403
|
},
|
|
1144
|
-
el("span", { class: "name", text: `/${c.name}` }),
|
|
1404
|
+
el("span", { class: "name", text: c.hint ? `/${c.name} ${c.hint}` : `/${c.name}` }),
|
|
1145
1405
|
el("span", { class: "desc", text: (c.description ?? "").replace(/^Sub-Sub:\s*/, "") }),
|
|
1146
1406
|
),
|
|
1147
1407
|
),
|
|
@@ -1151,8 +1411,8 @@ function suggest() {
|
|
|
1151
1411
|
}
|
|
1152
1412
|
function pick(c) {
|
|
1153
1413
|
input.value = `/${c.name} `;
|
|
1154
|
-
$("suggest").hidden = true;
|
|
1155
1414
|
input.focus();
|
|
1415
|
+
suggest();
|
|
1156
1416
|
}
|
|
1157
1417
|
|
|
1158
1418
|
input.addEventListener("input", () => {
|
|
@@ -1206,7 +1466,6 @@ for (const b of document.querySelectorAll(".modes button")) {
|
|
|
1206
1466
|
if (b.dataset.mode !== mode()) sendMessage(`/${b.dataset.mode}`);
|
|
1207
1467
|
});
|
|
1208
1468
|
}
|
|
1209
|
-
$("profile").addEventListener("change", (e) => sendMessage(`/profile ${e.target.value}`));
|
|
1210
1469
|
$("starbuck-toggle").addEventListener("change", async (e) => {
|
|
1211
1470
|
const on = e.target.checked;
|
|
1212
1471
|
e.target.disabled = true;
|
|
@@ -1220,7 +1479,6 @@ $("starbuck-toggle").addEventListener("change", async (e) => {
|
|
|
1220
1479
|
e.target.disabled = false;
|
|
1221
1480
|
}
|
|
1222
1481
|
});
|
|
1223
|
-
$("profile-side").addEventListener("change", (e) => sendMessage(`/profile ${e.target.value}`));
|
|
1224
1482
|
$("model-btn").addEventListener("click", showModels);
|
|
1225
1483
|
$("model-btn-side").addEventListener("click", () => {
|
|
1226
1484
|
closeSide();
|
|
@@ -1251,6 +1509,22 @@ $("theme-btn-side")?.addEventListener("click", () => {
|
|
|
1251
1509
|
toggleTheme();
|
|
1252
1510
|
});
|
|
1253
1511
|
$("session-search")?.addEventListener("input", (e) => renderSessionsList(e.target.value));
|
|
1512
|
+
$("library-search").addEventListener("input", searchLibrary);
|
|
1513
|
+
$("notes-btn").addEventListener("click", () => ($("note-pane").hidden ? showNoteList() : showPane(false)));
|
|
1514
|
+
$("note-back").addEventListener("click", showNoteList);
|
|
1515
|
+
$("note-close").addEventListener("click", () => showPane(false));
|
|
1516
|
+
$("note-app").addEventListener("click", () => noteShown && api("/api/open", { path: noteShown.path }).catch((err) => note(err.message, "warning")));
|
|
1517
|
+
$("note-filter").addEventListener("input", renderNoteList);
|
|
1518
|
+
$("note-pane").addEventListener("keydown", (e) => {
|
|
1519
|
+
if (e.key === "Escape") showPane(false);
|
|
1520
|
+
});
|
|
1521
|
+
// Links to notes, in replies and in notes: [[wikilinks]], .md paths in code, relative .md links.
|
|
1522
|
+
document.addEventListener("click", (e) => {
|
|
1523
|
+
const a = e.target.closest?.("a[data-note]");
|
|
1524
|
+
if (!a) return;
|
|
1525
|
+
e.preventDefault();
|
|
1526
|
+
openNote(a.dataset.note);
|
|
1527
|
+
});
|
|
1254
1528
|
$("export-btn")?.addEventListener("click", exportConversation);
|
|
1255
1529
|
$("quit-btn").addEventListener("click", async () => {
|
|
1256
1530
|
try {
|