@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/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 { TESTED_MODEL } from "./init.ts";
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", ...(opts.librarian ? ["--librarian"] : []), ...opts.piArgs];
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
- agent.start();
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
- sessionFile = ((await agent.request({ type: "get_state" })) as { sessionFile?: string }).sessionFile;
440
+ lastSession = ((await agent.request({ type: "get_state" })) as { sessionFile?: string }).sessionFile ?? lastSession;
428
441
  } catch {
429
- /* start fresh */
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
- agent.start(sessionFile && existsSync(sessionFile) ? ["--session", sessionFile] : []);
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 (and drop the per-mode models of versions before 0.10), then restart pi
572
- // so the extension reads the new setting.
573
- saveConfig({ model: spec, models: undefined }, env);
574
- await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
575
- await restartAgent();
576
- return send(res, 200, { ok: true });
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
- saveConfig({ addons: withStarbuck(loadConfig(env).addons, body.starbuck) }, env);
584
- await restartAgent();
585
- return send(res, 200, { ok: true });
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
- agent.start();
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
- saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
596
- const current = loadConfig(env);
597
- if (body.provider === "opencode-go" && !current.model) saveConfig({ model: TESTED_MODEL, models: undefined }, env);
598
- await restartAgent();
599
- return send(res, 200, { ok: true });
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, and writes notes in this folder.
6
- - With library changes on, it also changes the library: tags, imports, metadata, PDFs, collections, notes in Zotero. Every change shows a preview and needs the user's yes.
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
- /* One accent, blue. Rust marks the switch that lets Sub-Sub change the library. */
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
- /* The library switch in the top bar: rust when Sub-Sub may change the library. */
223
- .lib-switch {
224
- padding: 0.3rem 0.7rem 0.3rem 0.5rem;
225
- border: 1px solid var(--rule);
226
- border-radius: 999px;
223
+ .modes {
224
+ display: inline-flex;
225
+ padding: 3px;
226
+ border-radius: 8px;
227
227
  background: var(--surface);
228
- font-size: 0.84rem;
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
- white-space: nowrap;
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
- .lib-switch > span:last-child { display: none; }
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: libraryChanges, profile, model, library, down
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
- /** Library changes on (the default until the state arrives). */
173
- function libOn() {
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.library = libOn() ? "on" : "off";
179
- $("library-switch").checked = libOn();
180
- $("library-switch-label").title = t("libraryHint");
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 = libOn() ? t("placeholder") : t("placeholderReadOnly");
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
- (!a.changes || libOn()) &&
229
+
229
230
  (!a.needs || state?.[a.needs]) &&
230
231
  (!a.profiles || a.profiles.includes(state?.profile ?? "scholar"));
231
- for (const [group, label] of [["research", "groupResearch"], ["library", "groupLibrary"]]) {
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.filter((x) => !x.changes || libOn()).map((x) => x[lang] ?? x.en);
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: libOn() ? t("assistant") : `${t("assistant")} · ${t("readOnly")}` }),
386
+ el("p", { class: "kicker", text: t(mode()) }),
385
387
  el("h1", { text: t("emptyTitle") }),
386
- el("p", { class: "lede", text: t(libOn() ? "emptyOn" : "emptyOff") }),
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 switch shows the state; these confirmations would only repeat it.
719
- if (/^Sub-Sub: library changes (on|off)\./.test(ev.message)) break;
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.key === "y" || e.key === "Y" || (e.key === "Enter" && (e.metaKey || e.ctrlKey))) {
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
- $("library-switch").addEventListener("change", async (e) => {
1046
- const on = e.target.checked;
1047
- if (on === libOn()) return;
1048
- const ok = await sendMessage(`/library ${on ? "on" : "off"}`);
1049
- if (!ok) e.target.checked = libOn();
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
- assistant: "Assistant",
83
- readOnly: "Read only",
84
- libraryChanges: "Library changes",
85
- libraryHint: "On: Sub-Sub can import, tag and repair items, and every change shows a preview first. Off: it only reads, searches and writes notes.",
86
- placeholder: "Ask about your library, or type / for commands",
87
- placeholderReadOnly: "Ask about your library (read only), or type / for commands",
88
- emptyOn: "Sub-Sub reads your library, searches PubMed, Europe PMC and OpenAlex, and writes notes. It also imports and tags: every change shows a preview, and nothing changes until you approve it.",
89
- emptyOff: "Library changes are off. Sub-Sub reads your library, searches and writes notes, but does not change the library. Turn them on with the switch at the top.",
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: "Y para aprovar, N para recusar",
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
- assistant: "Assistente",
171
- readOnly: "Só leitura",
172
- libraryChanges: "Alterações à biblioteca",
173
- libraryHint: "Ligado: o Sub-Sub pode importar, etiquetar e corrigir itens, e cada alteração mostra primeiro uma pré-visualização. Desligado: só lê, pesquisa e escreve notas.",
174
- placeholder: "Pergunte sobre a sua biblioteca, ou escreva / para ver os comandos",
175
- placeholderReadOnly: "Pergunte sobre a sua biblioteca (só leitura), ou escreva / para ver os comandos",
176
- emptyOn: "O Sub-Sub lê a sua biblioteca, pesquisa na PubMed, Europe PMC e OpenAlex e escreve notas. Também importa e etiqueta: cada alteração mostra uma pré-visualização, e nada muda até a aprovar.",
177
- emptyOff: "As alterações à biblioteca estão desligadas. O Sub-Sub lê a biblioteca, pesquisa e escreve notas, mas não altera a biblioteca. Ligue-as no interruptor no topo.",
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
- * `changes`: shown only with library changes on. `profiles` and `needs` limit a button to some profiles or add-ons.
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, changes: true, en: "Tag 10 items", pt: "Etiquetar 10 itens" },
204
- { cmd: "clean-tags", send: true, changes: true, en: "Clean up tags", pt: "Limpar etiquetas" },
205
- { cmd: "import-queue", send: true, changes: true, en: "Import the queue", pt: "Importar a fila" },
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, changes: true, en: "Undo the last change", pt: "Desfazer a última alteração" },
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. `changes`: only with library changes on. */
212
- export const SUGGESTIONS = [
213
- { en: "What do I have on ", pt: "O que tenho sobre " },
214
- { en: "Find recent systematic reviews on ", pt: "Procure revisões sistemáticas recentes sobre " },
215
- { en: "Tag the next 10 items without a topic", pt: "Etiquete os próximos 10 itens sem tópico", changes: true },
216
- { en: "Import 10.1136/bmj.n71", pt: "Importe 10.1136/bmj.n71", changes: true },
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-library="on">
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
- <label class="switch lib-switch" id="library-switch-label"><input type="checkbox" role="switch" id="library-switch" checked><span class="track" aria-hidden="true"></span><span data-i18n="libraryChanges">Library changes</span></label>
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">