@tiagojct/subsub 0.10.0 → 0.11.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/src/subsub.ts CHANGED
@@ -1,15 +1,14 @@
1
1
  /**
2
2
  * Sub-Sub: a Zotero librarian and research assistant for pi.
3
3
  *
4
- * - One assistant: it reads the library, searches PubMed, Europe PMC and
5
- * OpenAlex, writes notes in the Sub-Sub folder and, with library changes on,
6
- * changes the library. /library on|off is the switch (saved in the settings).
4
+ * - Two modes: the Librarian manages the library (Zotero tools only); the
5
+ * Researcher does the same and also searches PubMed, Europe PMC and OpenAlex
6
+ * and writes notes. Each mode can have its own model.
7
7
  * - An approval gate: every library change is previewed by the server (dry run)
8
8
  * and shown to the user; nothing is applied without a yes.
9
9
  * - Profiles (reader, scholar, author, editor) set what Sub-Sub writes and which
10
10
  * tools it offers; /profile changes it.
11
- * - Commands: /library, /profile, /subsub, /history, /undo. /librarian and
12
- * /researcher (the two modes before 0.10) turn library changes on and off.
11
+ * - Commands: /librarian, /researcher, /profile, /subsub, /history, /undo.
13
12
  * - Add-on: with "addons": ["starbuck"], Starbuck runs as a third server
14
13
  * ("verify") for reference checks.
15
14
  *
@@ -29,6 +28,7 @@ import {
29
28
  loadConfig,
30
29
  parseEnvFile,
31
30
  type Profile,
31
+ type Mode,
32
32
  saveConfig,
33
33
  serverCommand,
34
34
  starbuckCommand,
@@ -45,6 +45,8 @@ import { headerLines, type LibraryState, paintPreview, QUOTES, type Scheme, sche
45
45
  import { systemAddition } from "./prompt.ts";
46
46
  import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.ts";
47
47
 
48
+ const MODE_ENTRY = "subsub-mode";
49
+
48
50
  export interface SubsubDeps {
49
51
  config?: SubsubConfig;
50
52
  /** A ready bridge (tests). When given, Sub-Sub does not start or stop servers. */
@@ -127,12 +129,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
127
129
 
128
130
  if (deps.bridge) registerTools(deps.bridge);
129
131
 
130
- pi.registerFlag("read-only", { description: "Start Sub-Sub with library changes off (this run only)", type: "boolean", default: false });
131
- pi.registerFlag("librarian", { description: "Start Sub-Sub with library changes on (this run only)", type: "boolean", default: false });
132
+ pi.registerFlag("librarian", { description: "Start Sub-Sub in the Librarian mode", type: "boolean", default: false });
132
133
 
133
- let libraryChanges = cfg.libraryChanges;
134
+ let mode: Mode = cfg.defaultMode;
134
135
  const benchRun = Boolean(process.env.SUBSUB_BENCH_OUT);
135
- const toolOpts = () => ({ libraryChanges, profile, bench: benchRun });
136
+ const toolOpts = () => ({ profile, bench: benchRun });
136
137
  let profile: Profile = cfg.profile;
137
138
  const user = who(cfg);
138
139
  /** True when started with the `subsub` command (not as a package inside plain pi). */
@@ -154,7 +155,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
154
155
  if (!webView || !ctx || ctx.mode !== "rpc") return;
155
156
  const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
156
157
  const state = {
157
- libraryChanges,
158
+ mode,
158
159
  profile,
159
160
  profileLabel: profileSpec(profile).label,
160
161
  model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null,
@@ -221,7 +222,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
221
222
  const th = ctx.ui.theme;
222
223
  return headerLines({
223
224
  version,
224
- libraryChanges,
225
+ mode,
225
226
  profile: profileSpec(profile).label,
226
227
  model: modelId,
227
228
  library,
@@ -244,37 +245,27 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
244
245
  function status(ctx: ExtensionContext): void {
245
246
  modelId = ctx.model ? `${ctx.model.id}` : "no model";
246
247
  const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
247
- const lib = libraryChanges ? "library changes on" : "read only";
248
- ctx.ui.setStatus("subsub", `Sub-Sub: ${lib} | ${profileSpec(profile).label} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
249
- if (standalone) ctx.ui.setTitle(libraryChanges ? "Sub-Sub" : "Sub-Sub (read only)");
248
+ ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${profileSpec(profile).label} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
249
+ if (standalone) ctx.ui.setTitle(`Sub-Sub: ${mode}`);
250
250
  headerTui?.requestRender();
251
251
  webCtx = ctx;
252
252
  emitWeb();
253
253
  }
254
254
 
255
- /** Set the tools for the switch and the profile; save the switch when asked. */
256
- function applyLibrary(ctx: ExtensionContext, on: boolean, save: boolean): void {
257
- libraryChanges = on;
258
- pi.setActiveTools(toolsFor(registered(), toolOpts()));
259
- if (save) {
260
- try {
261
- saveConfig({ libraryChanges: on });
262
- } catch (err) {
263
- ctx.ui.notify(`Sub-Sub: the setting was not saved: ${(err as Error).message}`, "warning");
264
- }
255
+ async function applyMode(ctx: ExtensionContext, next: Mode, remember: boolean): Promise<void> {
256
+ mode = next;
257
+ pi.setActiveTools(toolsFor(mode, registered(), toolOpts()));
258
+ const spec = cfg.models[mode];
259
+ if (spec) {
260
+ const i = spec.indexOf("/");
261
+ const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
262
+ const ok = model ? await pi.setModel(model) : false;
263
+ if (!ok) ctx.ui.notify(`Sub-Sub: cannot use ${spec} (unknown model or no key); keeping the current model.`, "warning");
265
264
  }
265
+ if (remember) pi.appendEntry(MODE_ENTRY, { mode });
266
266
  status(ctx);
267
267
  }
268
268
 
269
- async function applyModel(ctx: ExtensionContext): Promise<void> {
270
- const spec = cfg.model;
271
- if (!spec) return;
272
- const i = spec.indexOf("/");
273
- const model = i > 0 ? ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1)) : undefined;
274
- const ok = model ? await pi.setModel(model) : false;
275
- if (!ok) ctx.ui.notify(`Sub-Sub: cannot use ${spec} (unknown model or no key); keeping the current model.`, "warning");
276
- }
277
-
278
269
  pi.on("session_start", async (_event, ctx) => {
279
270
  if (!bridge) {
280
271
  const b = new Bridge();
@@ -282,12 +273,15 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
282
273
  bridge = b;
283
274
  registerTools(b);
284
275
  }
276
+ let restored: Mode | undefined;
277
+ for (const e of ctx.sessionManager.getBranch() as Array<{ customType?: string; data?: { mode?: unknown } }>) {
278
+ const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
279
+ if (m === "librarian" || m === "researcher") restored = m;
280
+ }
285
281
  // Judge light or dark before Sub-Sub changes the theme.
286
282
  if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
287
- await applyModel(ctx);
288
283
  applyTheme(ctx);
289
- // The flags change this run only; the saved setting stays.
290
- applyLibrary(ctx, pi.getFlag("read-only") ? false : pi.getFlag("librarian") ? true : cfg.libraryChanges, false);
284
+ await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : cfg.defaultMode), false);
291
285
  if (lookOn(ctx)) {
292
286
  setHeader(ctx);
293
287
  void refreshLibrary();
@@ -318,7 +312,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
318
312
  });
319
313
 
320
314
  pi.on("before_agent_start", async (event, ctx) => ({
321
- systemPrompt: `${event.systemPrompt}\n\n${systemAddition({ ...cfg, profile }, ctx.cwd, libraryChanges)}`,
315
+ systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
322
316
  }));
323
317
 
324
318
  /** Model test (subsub-bench): record every change instead of asking, and apply none. */
@@ -333,7 +327,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
333
327
  if (schema) coerceArgs(schema, input);
334
328
  resolvePathArgs(name, input, (p) => normalizeToolPath(p, ctx.cwd));
335
329
  if (SERVER_PREFIXES.some((p) => name.startsWith(p))) {
336
- const why = unavailableReason(name, registered(), toolOpts());
330
+ const why = unavailableReason(name, mode, registered(), toolOpts());
337
331
  if (why) {
338
332
  if (benchOut) record({ kind: "wrong_mode", tool: name, input });
339
333
  return { block: true, reason: why };
@@ -447,39 +441,19 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
447
441
  };
448
442
  }
449
443
 
450
- const libraryNote = (on: boolean) =>
451
- on
452
- ? "Sub-Sub: library changes on. Every change shows a preview first."
453
- : "Sub-Sub: library changes off. Sub-Sub reads, searches and writes notes; it does not change the library.";
454
-
455
- pi.registerCommand("library", {
456
- description: "Sub-Sub: turn library changes on or off (/library on, /library off)",
457
- handler: async (args, ctx) => {
458
- const a = args.trim().toLowerCase();
459
- const on = a === "on" ? true : a === "off" ? false : a === "" ? !libraryChanges : undefined;
460
- if (on === undefined) {
461
- ctx.ui.notify("Type /library on or /library off.", "warning");
462
- return;
463
- }
464
- applyLibrary(ctx, on, true);
465
- ctx.ui.notify(libraryNote(on), "info");
466
- },
467
- });
468
-
469
- // The two modes before 0.10, kept so that old habits and notes still work.
470
444
  pi.registerCommand("librarian", {
471
- description: "Sub-Sub: same as /library on",
445
+ description: "Sub-Sub: switch to the Librarian (manages the library: tags, imports, metadata, PDFs)",
472
446
  handler: async (_args, ctx) => {
473
- applyLibrary(ctx, true, true);
474
- ctx.ui.notify(`${libraryNote(true)} (/librarian is now /library on.)`, "info");
447
+ await applyMode(ctx, "librarian", true);
448
+ ctx.ui.notify("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "info");
475
449
  },
476
450
  });
477
451
 
478
452
  pi.registerCommand("researcher", {
479
- description: "Sub-Sub: same as /library off",
453
+ description: "Sub-Sub: switch to the Researcher (searches, notes, reviews, and everything the Librarian does)",
480
454
  handler: async (_args, ctx) => {
481
- applyLibrary(ctx, false, true);
482
- ctx.ui.notify(`${libraryNote(false)} (/researcher is now /library off.)`, "info");
455
+ await applyMode(ctx, "researcher", true);
456
+ ctx.ui.notify("Sub-Sub: Researcher mode. It searches, writes notes and also changes the library, with a preview.", "info");
483
457
  },
484
458
  });
485
459
 
@@ -502,7 +476,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
502
476
  } catch (err) {
503
477
  saved = ` Not saved: ${(err as Error).message}`;
504
478
  }
505
- applyLibrary(ctx, libraryChanges, false);
479
+ await applyMode(ctx, mode, false);
506
480
  ctx.ui.notify(`Sub-Sub: ${profileSpec(profile).label} profile (${profileSpec(profile).summary}).${saved}`, "info");
507
481
  },
508
482
  });
@@ -510,7 +484,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
510
484
  pi.registerCommand("subsub", {
511
485
  description: "Sub-Sub: Zotero connection and library overview",
512
486
  handler: async (_args, ctx) => {
513
- const lines = [`Library changes: ${libraryChanges ? "on" : "off"}. Profile: ${profileSpec(profile).label}. Model: ${ctx.model?.id ?? "none"}.`];
487
+ const lines = [`Mode: ${mode}. Profile: ${profileSpec(profile).label}. Model: ${ctx.model?.id ?? "none"}.`];
514
488
  if (!bridge) {
515
489
  ctx.ui.notify(`${lines[0]}\nThe Zotero servers are not running. Try /reload.`, "warning");
516
490
  return;
package/src/web.ts CHANGED
@@ -23,7 +23,7 @@ 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
25
  import { expand, loadConfig, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
26
- import { TESTED_MODEL } from "./init.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
 
@@ -568,9 +568,11 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
568
568
  if (busy) return send(res, 409, { error: "busy" });
569
569
  const body = await readJson(req);
570
570
  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);
571
+ // Remember the model for the current mode (or both), then restart pi so the extension reads it.
572
+ const mode = (subsubState as { mode?: string } | null)?.mode === "librarian" ? "librarian" : "researcher";
573
+ const models: Record<string, string> = { ...(loadConfig(env).models ?? {}), [mode]: spec };
574
+ if (body.both) models[mode === "librarian" ? "researcher" : "librarian"] = spec;
575
+ saveConfig({ models, model: undefined }, env);
574
576
  await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) });
575
577
  await restartAgent();
576
578
  return send(res, 200, { ok: true });
@@ -594,7 +596,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
594
596
  const body = await readJson(req);
595
597
  saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
596
598
  const current = loadConfig(env);
597
- if (body.provider === "opencode-go" && !current.model) saveConfig({ model: TESTED_MODEL, models: undefined }, env);
599
+ if (body.provider === "opencode-go" && Object.keys(current.models).length === 0) saveConfig({ models: TESTED_MODELS, model: undefined }, env);
598
600
  await restartAgent();
599
601
  return send(res, 200, { ok: true });
600
602
  }
@@ -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" },
@@ -715,8 +717,8 @@ function onEvent(ev) {
715
717
  break;
716
718
  case "extension_ui_request":
717
719
  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;
720
+ // The mode buttons show the mode; these confirmations would only repeat it.
721
+ if (/^Sub-Sub: (Librarian|Researcher) mode\./.test(ev.message)) break;
720
722
  note(ev.message, ev.notifyType === "error" ? "error" : ev.notifyType === "warning" ? "warning" : "info", /\n/.test(ev.message));
721
723
  } else {
722
724
  dialogs.push(ev);
@@ -901,7 +903,7 @@ async function openModels() {
901
903
  onclick: async () => {
902
904
  if (busy) return note(t("busy"), "warning");
903
905
  try {
904
- await api("/api/model", { provider: m.provider, id: m.id });
906
+ await api("/api/model", { provider: m.provider, id: m.id, both: $("model-both").checked });
905
907
  dlg.close();
906
908
  } catch (err) {
907
909
  note(err.message, "error");
@@ -1042,12 +1044,11 @@ $("new-btn").addEventListener("click", async () => {
1042
1044
  note(err.message, "error");
1043
1045
  }
1044
1046
  });
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
- });
1047
+ for (const b of document.querySelectorAll(".modes button")) {
1048
+ b.addEventListener("click", () => {
1049
+ if (b.dataset.mode !== mode()) sendMessage(`/${b.dataset.mode}`);
1050
+ });
1051
+ }
1051
1052
  $("profile").addEventListener("change", (e) => sendMessage(`/profile ${e.target.value}`));
1052
1053
  $("starbuck-toggle").addEventListener("change", async (e) => {
1053
1054
  const on = e.target.checked;
package/web/i18n.js CHANGED
@@ -79,14 +79,15 @@ export const STRINGS = {
79
79
  starbuckOff: "Reference checks are off.",
80
80
  composerHint: "Enter sends. Shift+Enter starts a new line. / shows the commands.",
81
81
  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.",
82
+ researcher: "Researcher",
83
+ librarian: "Librarian",
84
+ researcherHint: "Searches, reads and writes notes, and also changes the library (every change is previewed).",
85
+ librarianHint: "Manages the library only: tags, imports, metadata, PDFs. No literature search.",
86
+ placeholderResearcher: "Ask about your library or the literature, or type / for commands",
87
+ placeholderLibrarian: "Ask for a change to your library, or type / for commands",
88
+ 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.",
89
+ 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.",
90
+ forBoth: "Use it in both modes",
90
91
  groupResearch: "Research",
91
92
  groupLibrary: "Library",
92
93
  },
@@ -167,14 +168,15 @@ export const STRINGS = {
167
168
  starbuckOff: "A verificação de referências está desligada.",
168
169
  composerHint: "Enter envia. Shift+Enter muda de linha. / mostra os comandos.",
169
170
  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.",
171
+ researcher: "Investigador",
172
+ librarian: "Bibliotecário",
173
+ researcherHint: "Pesquisa, lê e escreve notas, e também altera a biblioteca (cada alteração é pré-visualizada).",
174
+ librarianHint: "Só gere a biblioteca: etiquetas, importações, metadados, PDF. Não pesquisa a literatura.",
175
+ placeholderResearcher: "Pergunte sobre a sua biblioteca ou a literatura, ou escreva / para ver os comandos",
176
+ placeholderLibrarian: "Peça uma alteração à biblioteca, ou escreva / para ver os comandos",
177
+ 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.",
178
+ 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.",
179
+ forBoth: "Usar nos dois modos",
178
180
  groupResearch: "Investigação",
179
181
  groupLibrary: "Biblioteca",
180
182
  },
@@ -182,7 +184,7 @@ export const STRINGS = {
182
184
 
183
185
  /**
184
186
  * 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.
187
+ * The Librarian sees the Library group only. `profiles` and `needs` limit a button to some profiles or add-ons.
186
188
  */
187
189
  export const ACTIONS = {
188
190
  research: [
@@ -200,21 +202,27 @@ export const ACTIONS = {
200
202
  ],
201
203
  library: [
202
204
  { 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" },
205
+ { cmd: "tag-batch 10", send: true, en: "Tag 10 items", pt: "Etiquetar 10 itens" },
206
+ { cmd: "clean-tags", send: true, en: "Clean up tags", pt: "Limpar etiquetas" },
207
+ { cmd: "import-queue", send: true, en: "Import the queue", pt: "Importar a fila" },
206
208
  { 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" },
209
+ { cmd: "undo", send: true, en: "Undo the last change", pt: "Desfazer a última alteração" },
208
210
  ],
209
211
  };
210
212
 
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
- ];
213
+ /** Suggestions on an empty conversation, per mode. */
214
+ export const SUGGESTIONS = {
215
+ researcher: [
216
+ { en: "What do I have on ", pt: "O que tenho sobre " },
217
+ { en: "Find recent systematic reviews on ", pt: "Procure revisões sistemáticas recentes sobre " },
218
+ { en: "Which items have no PDF?", pt: "Que itens não têm PDF?" },
219
+ ],
220
+ librarian: [
221
+ { en: "Tag the next 10 items without a topic", pt: "Etiquete os próximos 10 itens sem tópico" },
222
+ { en: "Import 10.1136/bmj.n71", pt: "Importe 10.1136/bmj.n71" },
223
+ { en: "Find duplicates", pt: "Procure duplicados" },
224
+ ],
225
+ };
218
226
 
219
227
  /** Plain names for the tools, shown while they run. Unknown tools keep their name. */
220
228
  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">