@tiagojct/subsub 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -178
- package/dist/bench.js +7 -6
- package/dist/cli.js +34 -1
- package/dist/config.js +21 -13
- package/dist/doctor.js +3 -2
- package/dist/init.js +34 -24
- package/dist/look.js +2 -2
- package/dist/paths.js +20 -0
- package/dist/prompt.js +6 -6
- package/dist/roles.js +12 -12
- package/dist/shortcut.js +2 -2
- package/dist/subsub.js +69 -67
- package/dist/web.js +62 -28
- package/package.json +2 -3
- package/prompts/lit-note.md +1 -1
- package/roles/librarian.md +46 -0
- package/roles/{assistant.md → researcher.md} +4 -5
- package/src/bench.ts +7 -10
- package/src/cli.ts +34 -1
- package/src/config.ts +24 -17
- package/src/doctor.ts +3 -2
- package/src/init.ts +36 -32
- package/src/look.ts +3 -4
- package/src/paths.ts +21 -0
- package/src/prompt.ts +8 -7
- package/src/roles.ts +14 -15
- package/src/shortcut.ts +2 -2
- package/src/subsub.ts +67 -68
- package/src/web.ts +58 -27
- package/templates/Zotero agent.md +2 -2
- package/web/app.css +21 -14
- package/web/app.js +51 -23
- package/web/i18n.js +43 -29
- package/web/index.html +6 -2
- package/docs/Subsub.md +0 -140
package/src/web.ts
CHANGED
|
@@ -22,8 +22,8 @@ import { homedir } from "node:os";
|
|
|
22
22
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
23
23
|
import { StringDecoder } from "node:string_decoder";
|
|
24
24
|
import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
|
|
25
|
-
import { expand, loadConfig, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
|
|
26
|
-
import {
|
|
25
|
+
import { configPath, expand, loadConfig, readConfigFile, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
|
|
26
|
+
import { TESTED_MODELS } from "./init.ts";
|
|
27
27
|
import { obsidianRoot } from "./layout.ts";
|
|
28
28
|
import { realish, within } from "./paths.ts";
|
|
29
29
|
|
|
@@ -336,7 +336,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
336
336
|
env.PI_CODING_AGENT_DIR = agentDir; // for the session list below
|
|
337
337
|
|
|
338
338
|
const childEnv: NodeJS.ProcessEnv = { ...env, SUBSUB_WEB: "1" };
|
|
339
|
-
const baseArgs = [bin, "--mode", "rpc", "--here", ...
|
|
339
|
+
const baseArgs = [bin, "--mode", "rpc", "--here", ...opts.piArgs];
|
|
340
340
|
const agent = new Agent(process.execPath, baseArgs, cwd, childEnv);
|
|
341
341
|
|
|
342
342
|
// ---- state shared with the pages
|
|
@@ -346,8 +346,20 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
346
346
|
let busy = false;
|
|
347
347
|
let lastSeen = Date.now();
|
|
348
348
|
let restarting = false;
|
|
349
|
+
let switching = false; // a restart for a new model, add-on or key is under way
|
|
350
|
+
let lastSession: string | undefined;
|
|
349
351
|
let refreshAfterRun = false;
|
|
350
352
|
|
|
353
|
+
/** The mode to start pi in: the one on screen, else the --librarian flag. A session's own mode still wins. */
|
|
354
|
+
function modeArgs(): string[] {
|
|
355
|
+
const shown = (subsubState as { mode?: string } | null)?.mode;
|
|
356
|
+
if (shown === "librarian" || shown === "researcher") return [`--${shown}`];
|
|
357
|
+
return opts.librarian ? ["--librarian"] : [];
|
|
358
|
+
}
|
|
359
|
+
function startAgent(): void {
|
|
360
|
+
agent.start([...modeArgs(), ...(lastSession && existsSync(lastSession) ? ["--session", lastSession] : [])]);
|
|
361
|
+
}
|
|
362
|
+
|
|
351
363
|
function broadcast(ev: unknown): void {
|
|
352
364
|
const data = `data: ${JSON.stringify(ev)}\n\n`;
|
|
353
365
|
for (const c of clients) c.write(data);
|
|
@@ -384,12 +396,13 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
384
396
|
agent.onExit = (code) => {
|
|
385
397
|
if (restarting) return;
|
|
386
398
|
busy = false;
|
|
399
|
+
for (const id of dialogs.keys()) broadcast({ type: "dialog_closed", id });
|
|
387
400
|
dialogs.clear();
|
|
388
401
|
broadcast({ type: "agent_exit", code, stderr: agent.stderr.slice(-1500) });
|
|
389
402
|
log(`pi stopped (code ${code}).\n${agent.stderr.slice(-2000)}`);
|
|
390
403
|
};
|
|
391
404
|
|
|
392
|
-
|
|
405
|
+
startAgent();
|
|
393
406
|
|
|
394
407
|
async function snapshot(): Promise<Record<string, unknown>> {
|
|
395
408
|
const out: Record<string, unknown> = {
|
|
@@ -403,6 +416,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
403
416
|
};
|
|
404
417
|
try {
|
|
405
418
|
out.session = await agent.request({ type: "get_state" });
|
|
419
|
+
lastSession = (out.session as { sessionFile?: string }).sessionFile ?? lastSession;
|
|
406
420
|
out.messages = ((await agent.request({ type: "get_messages" })) as { messages: unknown[] }).messages;
|
|
407
421
|
out.commands = ((await agent.request({ type: "get_commands" })) as { commands: unknown[] }).commands;
|
|
408
422
|
} catch (err) {
|
|
@@ -422,11 +436,10 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
422
436
|
}
|
|
423
437
|
|
|
424
438
|
async function restartAgent(): Promise<void> {
|
|
425
|
-
let sessionFile: string | undefined;
|
|
426
439
|
try {
|
|
427
|
-
|
|
440
|
+
lastSession = ((await agent.request({ type: "get_state" })) as { sessionFile?: string }).sessionFile ?? lastSession;
|
|
428
441
|
} catch {
|
|
429
|
-
/*
|
|
442
|
+
/* keep the last one known */
|
|
430
443
|
}
|
|
431
444
|
restarting = true;
|
|
432
445
|
for (const id of dialogs.keys()) broadcast({ type: "dialog_closed", id });
|
|
@@ -434,10 +447,23 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
434
447
|
await agent.stop();
|
|
435
448
|
restarting = false;
|
|
436
449
|
busy = false;
|
|
437
|
-
|
|
450
|
+
startAgent();
|
|
438
451
|
broadcast(await snapshot());
|
|
439
452
|
}
|
|
440
453
|
|
|
454
|
+
/** Run a change that restarts pi, one at a time. */
|
|
455
|
+
async function withRestart(res: ServerResponse, change: () => Promise<void> | void): Promise<void> {
|
|
456
|
+
if (busy || switching) return send(res, 409, { error: "busy" });
|
|
457
|
+
switching = true;
|
|
458
|
+
try {
|
|
459
|
+
await change();
|
|
460
|
+
await restartAgent();
|
|
461
|
+
} finally {
|
|
462
|
+
switching = false;
|
|
463
|
+
}
|
|
464
|
+
return send(res, 200, { ok: true });
|
|
465
|
+
}
|
|
466
|
+
|
|
441
467
|
// ---- idle stop
|
|
442
468
|
let closing = false;
|
|
443
469
|
const idleTimer = setInterval(() => {
|
|
@@ -565,38 +591,43 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
565
591
|
return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
|
|
566
592
|
}
|
|
567
593
|
case "POST /api/model": {
|
|
568
|
-
if (busy) return send(res, 409, { error: "busy" });
|
|
594
|
+
if (busy || switching) return send(res, 409, { error: "busy" });
|
|
569
595
|
const body = await readJson(req);
|
|
570
596
|
const spec = `${String(body.provider)}/${String(body.id)}`;
|
|
571
|
-
// Remember the model
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
597
|
+
// Remember the model for the current mode (or both), then restart pi so the extension reads it.
|
|
598
|
+
const mode = (subsubState as { mode?: string } | null)?.mode === "librarian" ? "librarian" : "researcher";
|
|
599
|
+
// Start from what the file says, not from the built-in defaults, so a model the user never chose is not saved.
|
|
600
|
+
const raw = readConfigFile(configPath(env));
|
|
601
|
+
const saved = raw.models && typeof raw.models === "object" ? (raw.models as Record<string, string>) : typeof raw.model === "string" && raw.model ? { librarian: raw.model, researcher: raw.model } : {};
|
|
602
|
+
const models: Record<string, string> = { ...saved, [mode]: spec };
|
|
603
|
+
if (body.both) models[mode === "librarian" ? "researcher" : "librarian"] = spec;
|
|
604
|
+
saveConfig({ models, model: undefined }, env);
|
|
605
|
+
return withRestart(res, async () => {
|
|
606
|
+
await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
|
|
607
|
+
});
|
|
577
608
|
}
|
|
578
609
|
case "POST /api/addons": {
|
|
579
610
|
// Turn an add-on on or off, then restart pi so the extension starts or stops its server.
|
|
580
|
-
if (busy) return send(res, 409, { error: "busy" });
|
|
611
|
+
if (busy || switching) return send(res, 409, { error: "busy" });
|
|
581
612
|
const body = await readJson(req);
|
|
582
613
|
if (typeof body.starbuck !== "boolean") return send(res, 400, { error: "starbuck must be true or false" });
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
614
|
+
return withRestart(res, () => {
|
|
615
|
+
saveConfig({ addons: withStarbuck(loadConfig(env).addons, body.starbuck) }, env);
|
|
616
|
+
});
|
|
586
617
|
}
|
|
587
618
|
case "POST /api/restart":
|
|
588
|
-
if (agent.proc) return send(res, 200, { ok: true });
|
|
589
|
-
|
|
619
|
+
if (agent.proc || switching) return send(res, 200, { ok: true });
|
|
620
|
+
startAgent();
|
|
590
621
|
broadcast(await snapshot());
|
|
591
622
|
return send(res, 200, { ok: true });
|
|
592
623
|
case "POST /api/login": {
|
|
593
|
-
if (busy) return send(res, 409, { error: "busy" });
|
|
624
|
+
if (busy || switching) return send(res, 409, { error: "busy" });
|
|
594
625
|
const body = await readJson(req);
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
626
|
+
return withRestart(res, () => {
|
|
627
|
+
saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
|
|
628
|
+
const current = loadConfig(env);
|
|
629
|
+
if (body.provider === "opencode-go" && Object.keys(current.models).length === 0) saveConfig({ models: TESTED_MODELS, model: undefined }, env);
|
|
630
|
+
});
|
|
600
631
|
}
|
|
601
632
|
case "POST /api/open": {
|
|
602
633
|
const body = await readJson(req);
|
|
@@ -2,8 +2,8 @@ Shared rules for Sub-Sub. Sub-Sub reads this file at the start of every session.
|
|
|
2
2
|
|
|
3
3
|
## What Sub-Sub does
|
|
4
4
|
|
|
5
|
-
- Sub-Sub reads the library, searches PubMed, Europe PMC and OpenAlex,
|
|
6
|
-
-
|
|
5
|
+
- Sub-Sub has two modes. The Librarian manages the library: tags, imports, metadata, PDFs, collections, notes in Zotero. The Researcher reads the library, searches PubMed, Europe PMC and OpenAlex, writes notes in this folder, and also does everything the Librarian does.
|
|
6
|
+
- Every library change shows a preview and needs the user's yes.
|
|
7
7
|
- Works found in a search are imported directly when the user chooses them. For a list to choose from later, Sub-Sub writes Inbox/Zotero import queue.md; the user ticks the lines and Sub-Sub imports them.
|
|
8
8
|
|
|
9
9
|
## Safety
|
package/web/app.css
CHANGED
|
@@ -87,8 +87,9 @@
|
|
|
87
87
|
--warn: #d6a84e;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
/*
|
|
90
|
+
/* The mode sets the accent: blue for the Researcher, rust for the Librarian. */
|
|
91
91
|
body { --accent: var(--researcher); --accent-soft: var(--researcher-soft); }
|
|
92
|
+
body[data-mode="librarian"] { --accent: var(--librarian); --accent-soft: var(--librarian-soft); }
|
|
92
93
|
|
|
93
94
|
/* ---------------------------------------------------------------- base */
|
|
94
95
|
|
|
@@ -219,20 +220,27 @@ select:hover { border-color: var(--rule-strong); }
|
|
|
219
220
|
.brand .mark { color: var(--accent); }
|
|
220
221
|
.brand .hyphen { color: var(--accent); }
|
|
221
222
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
padding:
|
|
225
|
-
border:
|
|
226
|
-
border-radius: 999px;
|
|
223
|
+
.modes {
|
|
224
|
+
display: inline-flex;
|
|
225
|
+
padding: 3px;
|
|
226
|
+
border-radius: 8px;
|
|
227
227
|
background: var(--surface);
|
|
228
|
-
|
|
228
|
+
border: 1px solid var(--rule);
|
|
229
|
+
}
|
|
230
|
+
.modes button {
|
|
231
|
+
padding: 0.28rem 0.8rem;
|
|
232
|
+
border-radius: 5px;
|
|
233
|
+
font-size: 0.86rem;
|
|
229
234
|
color: var(--muted);
|
|
230
|
-
|
|
235
|
+
border: 0;
|
|
236
|
+
}
|
|
237
|
+
.modes button:hover { color: var(--text); }
|
|
238
|
+
.modes button[aria-pressed="true"] {
|
|
239
|
+
background: var(--raised);
|
|
240
|
+
color: var(--accent);
|
|
241
|
+
font-weight: 600;
|
|
242
|
+
box-shadow: 0 1px 2px rgb(0 0 0 / 0.08), 0 0 0 1px var(--rule);
|
|
231
243
|
}
|
|
232
|
-
.lib-switch:hover { border-color: var(--rule-strong); color: var(--text); }
|
|
233
|
-
.top .lib-switch input:checked + .track { background: var(--librarian); }
|
|
234
|
-
body[data-library="on"] .lib-switch { color: var(--text); }
|
|
235
|
-
body[data-library="off"] .top { border-top-color: var(--rule-strong); }
|
|
236
244
|
|
|
237
245
|
.status {
|
|
238
246
|
margin-left: auto;
|
|
@@ -855,8 +863,7 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
|
|
|
855
863
|
|
|
856
864
|
@media (max-width: 30rem) {
|
|
857
865
|
.brand > span { display: none; }
|
|
858
|
-
.
|
|
859
|
-
.lib-switch { padding: 0.35rem 0.5rem; }
|
|
866
|
+
.modes button { padding: 0.28rem 0.6rem; }
|
|
860
867
|
}
|
|
861
868
|
|
|
862
869
|
@media (prefers-reduced-motion: reduce) {
|
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:
|
|
10
|
+
let state = null; // Sub-Sub's own state: mode, profile, model, library, down
|
|
11
11
|
let session = null; // pi's session state
|
|
12
12
|
let busy = false;
|
|
13
13
|
let commands = [];
|
|
@@ -169,15 +169,16 @@ function applyLang() {
|
|
|
169
169
|
updateLayoutUI();
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return state?.libraryChanges !== false;
|
|
172
|
+
function mode() {
|
|
173
|
+
return state?.mode === "librarian" ? "librarian" : "researcher";
|
|
175
174
|
}
|
|
176
175
|
|
|
177
176
|
function renderHeader() {
|
|
178
|
-
document.body.dataset.
|
|
179
|
-
|
|
180
|
-
|
|
177
|
+
document.body.dataset.mode = mode();
|
|
178
|
+
for (const b of document.querySelectorAll(".modes button")) {
|
|
179
|
+
b.setAttribute("aria-pressed", String(b.dataset.mode === mode()));
|
|
180
|
+
b.title = t(b.dataset.mode === "librarian" ? "librarianHint" : "researcherHint");
|
|
181
|
+
}
|
|
181
182
|
if (state?.profile) $("profile").value = $("profile-side").value = state.profile;
|
|
182
183
|
$("starbuck-toggle").checked = Boolean(state?.starbuck);
|
|
183
184
|
$("starbuck-label").title = t("starbuckHint");
|
|
@@ -205,7 +206,7 @@ function renderHeader() {
|
|
|
205
206
|
lib.classList.add("down");
|
|
206
207
|
}
|
|
207
208
|
lib.title = txt.textContent;
|
|
208
|
-
$("input").placeholder =
|
|
209
|
+
$("input").placeholder = mode() === "librarian" ? t("placeholderLibrarian") : t("placeholderResearcher");
|
|
209
210
|
if (log.querySelector(".empty")) {
|
|
210
211
|
clearEmpty();
|
|
211
212
|
renderEmpty();
|
|
@@ -225,10 +226,11 @@ function renderActions() {
|
|
|
225
226
|
box.replaceChildren();
|
|
226
227
|
const shown = (a) =>
|
|
227
228
|
hasCommand(a.cmd) &&
|
|
228
|
-
|
|
229
|
+
|
|
229
230
|
(!a.needs || state?.[a.needs]) &&
|
|
230
231
|
(!a.profiles || a.profiles.includes(state?.profile ?? "scholar"));
|
|
231
|
-
|
|
232
|
+
const groups = mode() === "librarian" ? [["library", "groupLibrary"]] : [["research", "groupResearch"], ["library", "groupLibrary"]];
|
|
233
|
+
for (const [group, label] of groups) {
|
|
232
234
|
const list = ACTIONS[group].filter(shown);
|
|
233
235
|
if (!list.length) continue;
|
|
234
236
|
box.append(el("p", { class: "group", text: t(label) }));
|
|
@@ -376,14 +378,14 @@ function clearEmpty() {
|
|
|
376
378
|
|
|
377
379
|
function renderEmpty() {
|
|
378
380
|
if (log.children.length) return;
|
|
379
|
-
const s = SUGGESTIONS
|
|
381
|
+
const s = SUGGESTIONS[mode()].map((x) => x[lang] ?? x.en);
|
|
380
382
|
log.append(
|
|
381
383
|
el(
|
|
382
384
|
"div",
|
|
383
385
|
{ class: "empty" },
|
|
384
|
-
el("p", { class: "kicker", text:
|
|
386
|
+
el("p", { class: "kicker", text: t(mode()) }),
|
|
385
387
|
el("h1", { text: t("emptyTitle") }),
|
|
386
|
-
el("p", { class: "lede", text: t(
|
|
388
|
+
el("p", { class: "lede", text: t(mode() === "librarian" ? "emptyLibrarian" : "emptyResearcher") }),
|
|
387
389
|
el(
|
|
388
390
|
"div",
|
|
389
391
|
{ class: "chips" },
|
|
@@ -648,6 +650,7 @@ function onEvent(ev) {
|
|
|
648
650
|
setBusy(!!ev.busy);
|
|
649
651
|
dialogs.length = 0;
|
|
650
652
|
for (const d of ev.dialogs ?? []) dialogs.push(d);
|
|
653
|
+
dropStaleDialog();
|
|
651
654
|
nextDialog();
|
|
652
655
|
loadSessions();
|
|
653
656
|
break;
|
|
@@ -715,8 +718,8 @@ function onEvent(ev) {
|
|
|
715
718
|
break;
|
|
716
719
|
case "extension_ui_request":
|
|
717
720
|
if (ev.method === "notify") {
|
|
718
|
-
// The
|
|
719
|
-
if (/^Sub-Sub:
|
|
721
|
+
// The mode buttons show the mode; these confirmations would only repeat it.
|
|
722
|
+
if (/^Sub-Sub: (Librarian|Researcher) mode\./.test(ev.message)) break;
|
|
720
723
|
note(ev.message, ev.notifyType === "error" ? "error" : ev.notifyType === "warning" ? "warning" : "info", /\n/.test(ev.message));
|
|
721
724
|
} else {
|
|
722
725
|
dialogs.push(ev);
|
|
@@ -741,6 +744,8 @@ function onEvent(ev) {
|
|
|
741
744
|
break;
|
|
742
745
|
case "agent_exit":
|
|
743
746
|
setBusy(false);
|
|
747
|
+
dialogs.length = 0;
|
|
748
|
+
dropStaleDialog();
|
|
744
749
|
log.append(
|
|
745
750
|
el(
|
|
746
751
|
"div",
|
|
@@ -774,6 +779,7 @@ function dialogTitle(d) {
|
|
|
774
779
|
if ((m = raw.match(/^apply (\S+)\?$/))) return t("apply", { x: toolLabel(m[1]) });
|
|
775
780
|
if ((m = raw.match(/^run (\S+)\?$/))) return t("run", { x: toolLabel(m[1]) });
|
|
776
781
|
if (raw === "undo?") return t("undo");
|
|
782
|
+
if ((m = raw.match(/^write the Starbuck report to (.+)\?$/))) return t("starbuckReport", { x: m[1] });
|
|
777
783
|
if ((m = raw.match(/^(write|edit) (.+)\?$/))) return t("write", { x: m[2] });
|
|
778
784
|
return raw;
|
|
779
785
|
}
|
|
@@ -795,6 +801,14 @@ function paintPreview(pre, text) {
|
|
|
795
801
|
}
|
|
796
802
|
}
|
|
797
803
|
|
|
804
|
+
/** Close the dialog on screen if Sub-Sub no longer waits for it (pi restarted, or another tab answered). */
|
|
805
|
+
function dropStaleDialog() {
|
|
806
|
+
if (shownDialog && !dialogs.some((d) => d.id === shownDialog.id)) {
|
|
807
|
+
shownDialog = null;
|
|
808
|
+
if ($("dialog").open) $("dialog").close();
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
798
812
|
function nextDialog() {
|
|
799
813
|
if (shownDialog || !dialogs.length) return;
|
|
800
814
|
const d = dialogs[0];
|
|
@@ -819,6 +833,7 @@ function nextDialog() {
|
|
|
819
833
|
note(err.message, "error");
|
|
820
834
|
}
|
|
821
835
|
};
|
|
836
|
+
$("dialog-form").onsubmit = (e) => e.preventDefault();
|
|
822
837
|
if (d.method === "confirm") {
|
|
823
838
|
buttons.append(
|
|
824
839
|
el("span", { class: "modal-shortcut-hint", text: t("confirmShortcutHint") }),
|
|
@@ -832,14 +847,25 @@ function nextDialog() {
|
|
|
832
847
|
const field = d.method === "editor" ? el("textarea", {}) : el("input", { type: "text", placeholder: d.placeholder ?? "" });
|
|
833
848
|
field.value = d.prefill ?? "";
|
|
834
849
|
body.append(field);
|
|
850
|
+
$("dialog-form").onsubmit = (e) => {
|
|
851
|
+
e.preventDefault();
|
|
852
|
+
answer({ value: field.value });
|
|
853
|
+
};
|
|
835
854
|
buttons.append(
|
|
836
855
|
el("button", { type: "button", class: "ghost", text: t("cancel"), onclick: () => answer({ cancelled: true }) }),
|
|
837
856
|
el("button", { type: "button", class: "primary", text: t("ok"), onclick: () => answer({ value: field.value }) }),
|
|
838
857
|
);
|
|
839
858
|
}
|
|
859
|
+
// Keys typed just before the dialog opened (the user was writing a message) must not answer it.
|
|
860
|
+
const openedAt = performance.now();
|
|
861
|
+
const yesKeys = lang === "pt" ? ["s", "S", "y", "Y"] : ["y", "Y"];
|
|
840
862
|
dlg.onkeydown = (e) => {
|
|
841
863
|
if (d.method === "confirm") {
|
|
842
|
-
if (e.
|
|
864
|
+
if (e.repeat || performance.now() - openedAt < 700) {
|
|
865
|
+
if (!e.metaKey && !e.ctrlKey && e.key.length === 1) e.preventDefault();
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (yesKeys.includes(e.key) || (e.key === "Enter" && (e.metaKey || e.ctrlKey))) {
|
|
843
869
|
e.preventDefault();
|
|
844
870
|
answer({ confirmed: true });
|
|
845
871
|
} else if (e.key === "n" || e.key === "N") {
|
|
@@ -865,6 +891,9 @@ function showModels() {
|
|
|
865
891
|
|
|
866
892
|
async function openModels() {
|
|
867
893
|
const dlg = $("model-dialog");
|
|
894
|
+
// Name the mode the choice is saved for; "both modes" is a choice for this time only.
|
|
895
|
+
dlg.querySelector("h2").textContent = t(mode() === "librarian" ? "modelTitleLibrarian" : "modelTitleResearcher");
|
|
896
|
+
$("model-both").checked = false;
|
|
868
897
|
const ul = $("models");
|
|
869
898
|
ul.replaceChildren(el("li", { class: "empty-models", text: "…" }));
|
|
870
899
|
const sel = $("login-provider");
|
|
@@ -901,7 +930,7 @@ async function openModels() {
|
|
|
901
930
|
onclick: async () => {
|
|
902
931
|
if (busy) return note(t("busy"), "warning");
|
|
903
932
|
try {
|
|
904
|
-
await api("/api/model", { provider: m.provider, id: m.id });
|
|
933
|
+
await api("/api/model", { provider: m.provider, id: m.id, both: $("model-both").checked });
|
|
905
934
|
dlg.close();
|
|
906
935
|
} catch (err) {
|
|
907
936
|
note(err.message, "error");
|
|
@@ -1042,12 +1071,11 @@ $("new-btn").addEventListener("click", async () => {
|
|
|
1042
1071
|
note(err.message, "error");
|
|
1043
1072
|
}
|
|
1044
1073
|
});
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
});
|
|
1074
|
+
for (const b of document.querySelectorAll(".modes button")) {
|
|
1075
|
+
b.addEventListener("click", () => {
|
|
1076
|
+
if (b.dataset.mode !== mode()) sendMessage(`/${b.dataset.mode}`);
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1051
1079
|
$("profile").addEventListener("change", (e) => sendMessage(`/profile ${e.target.value}`));
|
|
1052
1080
|
$("starbuck-toggle").addEventListener("change", async (e) => {
|
|
1053
1081
|
const on = e.target.checked;
|
package/web/i18n.js
CHANGED
|
@@ -21,6 +21,7 @@ export const STRINGS = {
|
|
|
21
21
|
run: "Run {x}?",
|
|
22
22
|
undo: "Undo this change?",
|
|
23
23
|
write: "Allow writing to {x}?",
|
|
24
|
+
starbuckReport: "Write the Starbuck report to {x}?",
|
|
24
25
|
approveHint: "Nothing changes until you select Yes.",
|
|
25
26
|
zoteroChecking: "Zotero: checking",
|
|
26
27
|
zoteroDown: "Zotero is not running",
|
|
@@ -39,6 +40,8 @@ export const STRINGS = {
|
|
|
39
40
|
connectModel: "Connect a model to start.",
|
|
40
41
|
connect: "Connect",
|
|
41
42
|
modelTitle: "Model",
|
|
43
|
+
modelTitleResearcher: "Model for the Researcher",
|
|
44
|
+
modelTitleLibrarian: "Model for the Librarian",
|
|
42
45
|
chooseModel: "Choose a model",
|
|
43
46
|
noModels: "No models yet. Connect a provider below.",
|
|
44
47
|
connectProvider: "Connect a provider",
|
|
@@ -79,14 +82,15 @@ export const STRINGS = {
|
|
|
79
82
|
starbuckOff: "Reference checks are off.",
|
|
80
83
|
composerHint: "Enter sends. Shift+Enter starts a new line. / shows the commands.",
|
|
81
84
|
previewKicker: "Preview",
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
researcher: "Researcher",
|
|
86
|
+
librarian: "Librarian",
|
|
87
|
+
researcherHint: "Searches, reads and writes notes, and also changes the library (every change is previewed).",
|
|
88
|
+
librarianHint: "Manages the library only: tags, imports, metadata, PDFs. No literature search.",
|
|
89
|
+
placeholderResearcher: "Ask about your library or the literature, or type / for commands",
|
|
90
|
+
placeholderLibrarian: "Ask for a change to your library, or type / for commands",
|
|
91
|
+
emptyResearcher: "The Researcher reads your library, searches PubMed, Europe PMC and OpenAlex and writes notes. It also imports, tags and repairs items: every change shows a preview, and nothing changes until you approve it.",
|
|
92
|
+
emptyLibrarian: "The Librarian manages your library: tags, imports, metadata and PDFs. Every change shows a preview, and nothing changes until you approve it. For searches and notes, switch to the Researcher.",
|
|
93
|
+
forBoth: "Use it in both modes",
|
|
90
94
|
groupResearch: "Research",
|
|
91
95
|
groupLibrary: "Library",
|
|
92
96
|
},
|
|
@@ -109,6 +113,7 @@ export const STRINGS = {
|
|
|
109
113
|
run: "Executar {x}?",
|
|
110
114
|
undo: "Desfazer esta alteração?",
|
|
111
115
|
write: "Permitir escrever em {x}?",
|
|
116
|
+
starbuckReport: "Escrever o relatório do Starbuck em {x}?",
|
|
112
117
|
approveHint: "Nada muda até selecionar Sim.",
|
|
113
118
|
zoteroChecking: "Zotero: a verificar",
|
|
114
119
|
zoteroDown: "O Zotero não está aberto",
|
|
@@ -127,6 +132,8 @@ export const STRINGS = {
|
|
|
127
132
|
connectModel: "Ligue um modelo para começar.",
|
|
128
133
|
connect: "Ligar",
|
|
129
134
|
modelTitle: "Modelo",
|
|
135
|
+
modelTitleResearcher: "Modelo do Investigador",
|
|
136
|
+
modelTitleLibrarian: "Modelo do Bibliotecário",
|
|
130
137
|
chooseModel: "Escolha um modelo",
|
|
131
138
|
noModels: "Ainda não há modelos. Ligue um fornecedor abaixo.",
|
|
132
139
|
connectProvider: "Ligar um fornecedor",
|
|
@@ -160,21 +167,22 @@ export const STRINGS = {
|
|
|
160
167
|
searchConversations: "Procurar conversas",
|
|
161
168
|
export: "Exportar",
|
|
162
169
|
exportTitle: "Exportar conversa",
|
|
163
|
-
confirmShortcutHint: "
|
|
170
|
+
confirmShortcutHint: "S para aprovar, N para recusar",
|
|
164
171
|
starbuck: "Verificação de referências (Starbuck)",
|
|
165
172
|
starbuckHint: "Verifica se as obras citadas existem, correspondem à citação e não foram retratadas. O Sub-Sub reinicia quando muda esta opção.",
|
|
166
173
|
starbuckOn: "A verificação de referências está ligada. Use /verify, ou peça ao investigador que verifique as referências de um manuscrito.",
|
|
167
174
|
starbuckOff: "A verificação de referências está desligada.",
|
|
168
175
|
composerHint: "Enter envia. Shift+Enter muda de linha. / mostra os comandos.",
|
|
169
176
|
previewKicker: "Pré-visualização",
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
researcher: "Investigador",
|
|
178
|
+
librarian: "Bibliotecário",
|
|
179
|
+
researcherHint: "Pesquisa, lê e escreve notas, e também altera a biblioteca (cada alteração é pré-visualizada).",
|
|
180
|
+
librarianHint: "Só gere a biblioteca: etiquetas, importações, metadados, PDF. Não pesquisa a literatura.",
|
|
181
|
+
placeholderResearcher: "Pergunte sobre a sua biblioteca ou a literatura, ou escreva / para ver os comandos",
|
|
182
|
+
placeholderLibrarian: "Peça uma alteração à biblioteca, ou escreva / para ver os comandos",
|
|
183
|
+
emptyResearcher: "O Investigador lê a sua biblioteca, pesquisa na PubMed, Europe PMC e OpenAlex e escreve notas. Também importa, etiqueta e corrige itens: cada alteração mostra uma pré-visualização, e nada muda até a aprovar.",
|
|
184
|
+
emptyLibrarian: "O Bibliotecário gere a sua biblioteca: etiquetas, importações, metadados e PDF. Cada alteração mostra uma pré-visualização, e nada muda até a aprovar. Para pesquisar e escrever notas, mude para o Investigador.",
|
|
185
|
+
forBoth: "Usar nos dois modos",
|
|
178
186
|
groupResearch: "Investigação",
|
|
179
187
|
groupLibrary: "Biblioteca",
|
|
180
188
|
},
|
|
@@ -182,7 +190,7 @@ export const STRINGS = {
|
|
|
182
190
|
|
|
183
191
|
/**
|
|
184
192
|
* Buttons in the side panel, in two groups. `fill` puts the command in the message box; `send` runs it at once.
|
|
185
|
-
*
|
|
193
|
+
* The Librarian sees the Library group only. `profiles` and `needs` limit a button to some profiles or add-ons.
|
|
186
194
|
*/
|
|
187
195
|
export const ACTIONS = {
|
|
188
196
|
research: [
|
|
@@ -200,21 +208,27 @@ export const ACTIONS = {
|
|
|
200
208
|
],
|
|
201
209
|
library: [
|
|
202
210
|
{ cmd: "subsub", send: true, en: "Library overview", pt: "Resumo da biblioteca" },
|
|
203
|
-
{ cmd: "tag-batch 10", send: true,
|
|
204
|
-
{ cmd: "clean-tags", send: true,
|
|
205
|
-
{ cmd: "import-queue", send: true,
|
|
211
|
+
{ cmd: "tag-batch 10", send: true, en: "Tag 10 items", pt: "Etiquetar 10 itens" },
|
|
212
|
+
{ cmd: "clean-tags", send: true, en: "Clean up tags", pt: "Limpar etiquetas" },
|
|
213
|
+
{ cmd: "import-queue", send: true, en: "Import the queue", pt: "Importar a fila" },
|
|
206
214
|
{ cmd: "history", send: true, en: "Recent changes", pt: "Alterações recentes" },
|
|
207
|
-
{ cmd: "undo", send: true,
|
|
215
|
+
{ cmd: "undo", send: true, en: "Undo the last change", pt: "Desfazer a última alteração" },
|
|
208
216
|
],
|
|
209
217
|
};
|
|
210
218
|
|
|
211
|
-
/** Suggestions on an empty conversation
|
|
212
|
-
export const SUGGESTIONS =
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
]
|
|
219
|
+
/** Suggestions on an empty conversation, per mode. */
|
|
220
|
+
export const SUGGESTIONS = {
|
|
221
|
+
researcher: [
|
|
222
|
+
{ en: "What do I have on ", pt: "O que tenho sobre " },
|
|
223
|
+
{ en: "Find recent systematic reviews on ", pt: "Procure revisões sistemáticas recentes sobre " },
|
|
224
|
+
{ en: "Which items have no PDF?", pt: "Que itens não têm PDF?" },
|
|
225
|
+
],
|
|
226
|
+
librarian: [
|
|
227
|
+
{ en: "Tag the next 10 items without a topic", pt: "Etiquete os próximos 10 itens sem tópico" },
|
|
228
|
+
{ en: "Import 10.1136/bmj.n71", pt: "Importe 10.1136/bmj.n71" },
|
|
229
|
+
{ en: "Find duplicates", pt: "Procure duplicados" },
|
|
230
|
+
],
|
|
231
|
+
};
|
|
218
232
|
|
|
219
233
|
/** Plain names for the tools, shown while they run. Unknown tools keep their name. */
|
|
220
234
|
export const TOOLS = {
|
package/web/index.html
CHANGED
|
@@ -10,12 +10,15 @@
|
|
|
10
10
|
<link rel="stylesheet" href="/app.css">
|
|
11
11
|
<script type="module" src="/app.js"></script>
|
|
12
12
|
</head>
|
|
13
|
-
<body data-
|
|
13
|
+
<body data-mode="researcher">
|
|
14
14
|
<div class="app">
|
|
15
15
|
<header class="top">
|
|
16
16
|
<button type="button" class="icon-btn" id="menu-btn" aria-controls="side" aria-expanded="true" data-i18n-title="menu" title="Menu"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M9.5 4.5v15"/></svg><span class="visually-hidden" data-i18n="menu">Menu</span></button>
|
|
17
17
|
<div class="brand"><svg class="mark" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="1.5"/><path d="M6.5 9h7M6.5 12h11"/><circle cx="12" cy="16" r="1.1"/></svg><span>Sub<span class="hyphen">-</span>Sub</span></div>
|
|
18
|
-
<
|
|
18
|
+
<div class="modes" role="group" aria-label="Mode">
|
|
19
|
+
<button type="button" data-mode="researcher" aria-pressed="true" data-i18n="researcher">Researcher</button>
|
|
20
|
+
<button type="button" data-mode="librarian" aria-pressed="false" data-i18n="librarian">Librarian</button>
|
|
21
|
+
</div>
|
|
19
22
|
<div class="status">
|
|
20
23
|
<span class="library" id="library"><span class="library-dot" aria-hidden="true"></span><span class="library-text"></span></span>
|
|
21
24
|
<label class="profile wide-only"><span class="visually-hidden" data-i18n="profile">Profile</span>
|
|
@@ -107,6 +110,7 @@
|
|
|
107
110
|
<button type="button" class="link-btn" id="model-close" data-i18n="close">Close</button>
|
|
108
111
|
</div>
|
|
109
112
|
<h3 data-i18n="chooseModel">Choose a model</h3>
|
|
113
|
+
<label class="check"><input type="checkbox" id="model-both"> <span data-i18n="forBoth">Use it in both modes</span></label>
|
|
110
114
|
<ul class="models" id="models"></ul>
|
|
111
115
|
<h3 data-i18n="connectProvider">Connect a provider</h3>
|
|
112
116
|
<form id="login-form" class="login">
|