@tiagojct/subsub 0.11.3 → 0.12.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/web/app.css CHANGED
@@ -631,6 +631,11 @@ body.layout-full { --measure: 72rem; }
631
631
  .note.mono { font-family: var(--mono); font-size: 0.76rem; line-height: 1.55; }
632
632
  .note pre { font-family: var(--mono); font-size: 0.74rem; white-space: pre-wrap; }
633
633
  .note button { margin-top: 0.5rem; }
634
+ .note p { margin: 0; }
635
+ .note details { margin-top: 0.4rem; color: var(--muted); }
636
+ .note details summary { cursor: pointer; font-size: 0.78rem; }
637
+ .note details pre { margin: 0.3rem 0 0; overflow-wrap: anywhere; }
638
+ .free-note { border-left: 3px solid var(--accent); padding-left: 0.6rem; }
634
639
 
635
640
  .working {
636
641
  margin-top: 1rem;
@@ -826,6 +831,14 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
826
831
  .models button:hover { background: var(--bg); }
827
832
  .models .current { color: var(--accent); font-weight: 600; background: var(--accent-soft); }
828
833
  .models .empty-models { padding: 0.65rem 0.8rem; color: var(--muted); font-size: 0.88rem; }
834
+ .model-search { width: 100%; margin-bottom: 0.5rem; }
835
+ .models { max-height: 20rem; }
836
+ .models .models-group { padding: 0.45rem 0.8rem 0.3rem; font-size: 0.78rem; font-weight: 600; color: var(--muted); background: var(--surface); }
837
+ .models button { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.6rem; }
838
+ .models .needs-key { opacity: 0.65; }
839
+ .models .model-name { min-width: 0; overflow-wrap: anywhere; }
840
+ .models .model-tag { font-family: var(--sans); font-size: 0.72rem; font-weight: 600; color: var(--accent); border: 1px solid currentColor; border-radius: 999px; padding: 0 0.45rem; }
841
+ .models .model-why { font-family: var(--sans); font-size: 0.75rem; color: var(--muted); }
829
842
 
830
843
  /* ---------------------------------------------------------------- narrow screens */
831
844
 
@@ -870,3 +883,14 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
870
883
  @media (prefers-reduced-motion: reduce) {
871
884
  *, *::before, *::after { animation: none !important; transition: none !important; }
872
885
  }
886
+
887
+ /* First run: what is still missing */
888
+ .setup { margin-block: 1.4rem 1rem; padding: 0.9rem 1rem; border: 1px solid var(--rule); border-left: 3px solid var(--accent); border-radius: 6px; background: var(--raised); text-align: left; max-width: 40rem; }
889
+ .setup h2 { margin: 0 0 0.5rem; font-size: 0.95rem; }
890
+ .setup ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.55rem; }
891
+ .setup li { display: grid; grid-template-columns: 4.5rem 1fr auto; gap: 0.6rem; align-items: start; font-size: 0.88rem; line-height: 1.45; }
892
+ .setup li > * { min-width: 0; }
893
+ .setup .mark { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; padding-top: 0.15rem; color: var(--muted); }
894
+ .setup li.todo .mark { color: var(--rem); }
895
+ .setup li.ok .step-text { color: var(--muted); }
896
+ @media (max-width: 560px) { .setup li { grid-template-columns: 1fr; gap: 0.2rem; } }
package/web/app.js CHANGED
@@ -368,6 +368,21 @@ function scrollDown(force = false) {
368
368
  if (force || near) log.scrollTop = log.scrollHeight;
369
369
  }
370
370
 
371
+ // A provider error in plain words (keep in step with providerTrouble in src/keys.ts), with the raw text below.
372
+ function troubleOf(text) {
373
+ if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|usage limit/i.test(text)) return "quota";
374
+ if (/\b(503|529)\b|UNAVAILABLE|high demand|overloaded/i.test(text)) return "busy";
375
+ if (/RECITATION|SAFETY|PROHIBITED_CONTENT/.test(text)) return "blocked";
376
+ if (/\b(401|403)\b|API key not valid|invalid api key|PERMISSION_DENIED|UNAUTHENTICATED/i.test(text)) return "key";
377
+ return null;
378
+ }
379
+
380
+ function errorNote(text) {
381
+ const kind = troubleOf(text);
382
+ if (!kind) return el("div", { class: "note error", text });
383
+ return el("div", { class: "note error" }, el("p", { text: t(`trouble_${kind}`) }), el("details", {}, el("summary", { text: t("details") }), el("pre", { text })));
384
+ }
385
+
371
386
  function note(text, kind = "info", mono = false) {
372
387
  clearEmpty();
373
388
  const n = el("div", { class: `note ${kind}${mono ? " mono" : ""}`, text });
@@ -380,6 +395,36 @@ function clearEmpty() {
380
395
  log.querySelector(".empty")?.remove();
381
396
  }
382
397
 
398
+ /** First run: what is still missing (Zotero, a model, the servers), each with its fix. Hidden when all is ready. */
399
+ function setupList() {
400
+ const L = state?.library;
401
+ const m = state?.model ?? session?.model;
402
+ const steps = [
403
+ { ok: L?.zotero === "reachable", wait: !L || L.zotero === "checking", text: t("stepZotero") },
404
+ { ok: Boolean(m && m.id && m.id !== "unknown"), text: t("stepModel"), action: { label: t("connect"), run: openModels } },
405
+ { ok: !state?.down?.length, text: t("stepServers", { x: (state?.down ?? []).join(", ") }) },
406
+ ];
407
+ if (steps.every((x) => x.ok || x.wait)) return null;
408
+ return el(
409
+ "div",
410
+ { class: "setup" },
411
+ el("h2", { text: t("setupTitle") }),
412
+ el(
413
+ "ol",
414
+ {},
415
+ steps.map((x) =>
416
+ el(
417
+ "li",
418
+ { class: x.ok ? "ok" : x.wait ? "wait" : "todo" },
419
+ el("span", { class: "mark", text: x.ok ? t("stepDone") : x.wait ? "…" : t("stepTodo") }),
420
+ el("span", { class: "step-text", text: x.ok ? x.text.split(". ")[0] : x.text }),
421
+ !x.ok && x.action ? el("button", { type: "button", class: "primary small-btn", text: x.action.label, onclick: x.action.run }) : null,
422
+ ),
423
+ ),
424
+ ),
425
+ );
426
+ }
427
+
383
428
  function renderEmpty() {
384
429
  if (log.children.length) return;
385
430
  const s = SUGGESTIONS[mode()].map((x) => x[lang] ?? x.en);
@@ -390,6 +435,7 @@ function renderEmpty() {
390
435
  el("p", { class: "kicker", text: t(mode()) }),
391
436
  el("h1", { text: t("emptyTitle") }),
392
437
  el("p", { class: "lede", text: t(mode() === "librarian" ? "emptyLibrarian" : "emptyResearcher") }),
438
+ setupList(),
393
439
  el(
394
440
  "div",
395
441
  { class: "chips" },
@@ -490,6 +536,23 @@ function finishTool(id, result, isError) {
490
536
  row.querySelector("pre")?.remove();
491
537
  if (text) row.append(el("pre", { text: text.length > 6000 ? `${text.slice(0, 6000)}\n...` : text }));
492
538
  if (isError) row.querySelector(".label").textContent = `${toolLabel(row.dataset.name)} (${t("failed")})`;
539
+ // An applied library change gets an Undo button; it runs /undo with its id, which previews first.
540
+ const jid = !isError && !/undo/.test(row.dataset.name) && /"journal_id":\s*"(\d{8}-\d{6}-\d{2}-[a-z_]+)"/.exec(text ?? "")?.[1];
541
+ if (jid && !row.querySelector(".undo")) {
542
+ row.querySelector("summary").append(
543
+ el("button", {
544
+ type: "button",
545
+ class: "open undo",
546
+ text: t("undo"),
547
+ title: t("undoTitle"),
548
+ onclick: (e) => {
549
+ e.preventDefault();
550
+ if (busy) return note(t("busy"), "warning");
551
+ sendMessage(`/undo ${jid}`);
552
+ },
553
+ }),
554
+ );
555
+ }
493
556
  }
494
557
 
495
558
  function attachCopyButtons(root) {
@@ -596,7 +659,7 @@ function renderAssistantFinal(message, msg = startAssistant()) {
596
659
  );
597
660
  msg.box.append(actions);
598
661
  }
599
- if (message.stopReason === "error" && message.errorMessage) msg.box.append(el("div", { class: "note error", text: message.errorMessage }));
662
+ if (message.stopReason === "error" && message.errorMessage) msg.box.append(errorNote(message.errorMessage));
600
663
  if (message.stopReason === "aborted") msg.box.append(el("div", { class: "note", text: t("stopped") }));
601
664
  if (!msg.box.children.length) msg.box.remove();
602
665
  scrollDown();
@@ -907,44 +970,94 @@ async function openModels() {
907
970
  const setLink = () => {
908
971
  const p = providers.find((x) => x.id === sel.value);
909
972
  link.href = p?.url ?? "#";
973
+ const free = { google: "freeGoogle", openrouter: "freeOpenRouter" }[sel.value];
974
+ $("login-free").hidden = !free;
975
+ $("login-free").textContent = free ? t(free) : "";
976
+ $("login-where").textContent = p?.where ? t(`where_${p.where}`) : "";
910
977
  };
911
978
  sel.onchange = setLink;
912
979
  setLink();
913
980
  if (!dlg.open) dlg.showModal();
914
981
  let models = [];
982
+ let recommended = [];
915
983
  try {
916
- models = (await api("/api/models")).models ?? [];
984
+ const data = await api("/api/models");
985
+ models = data.models ?? [];
986
+ recommended = data.recommended ?? [];
917
987
  } catch (err) {
918
988
  note(err.message, "error");
919
989
  }
920
- ul.replaceChildren();
921
- if (!models.length) ul.append(el("li", { class: "empty-models", text: t("noModels") }));
922
990
  const cur = state?.model ?? session?.model;
991
+ const specOf = (m) => `${m.provider}/${m.id}`;
923
992
  const isCurrent = (m) => cur && cur.provider === m.provider && cur.id === m.id;
924
- models.sort((a, b) => Number(isCurrent(b)) - Number(isCurrent(a)) || `${a.provider}/${a.id}`.localeCompare(`${b.provider}/${b.id}`));
925
- for (const m of models) {
926
- const isCur = cur && cur.provider === m.provider && cur.id === m.id;
927
- ul.append(
993
+ const available = new Set(models.map(specOf));
994
+ // Recommended for this mode first (also when the provider has no key yet), then the rest.
995
+ const mine = recommended.filter((r) => r.modes.includes(mode()));
996
+ const recSpecs = new Set(mine.map((r) => r.spec));
997
+ const others = models
998
+ .filter((m) => !recSpecs.has(specOf(m)))
999
+ .sort((a, b) => Number(isCurrent(b)) - Number(isCurrent(a)) || specOf(a).localeCompare(specOf(b)));
1000
+ const choose = async (m) => {
1001
+ if (busy) return note(t("busy"), "warning");
1002
+ try {
1003
+ await api("/api/model", { provider: m.provider, id: m.id, both: $("model-both").checked });
1004
+ dlg.close();
1005
+ } catch (err) {
1006
+ note(err.message, "error");
1007
+ }
1008
+ };
1009
+ const row = (m, rec) => {
1010
+ const spec = specOf(m);
1011
+ const can = available.has(spec);
1012
+ const label = providers.find((p) => p.id === m.provider)?.label ?? m.provider;
1013
+ return el(
1014
+ "li",
1015
+ { "data-spec": spec.toLowerCase() },
928
1016
  el(
929
- "li",
930
- {},
931
- el("button", {
1017
+ "button",
1018
+ {
932
1019
  type: "button",
933
- class: isCur ? "current" : "",
934
- text: `${m.provider}/${m.id}`,
935
- onclick: async () => {
936
- if (busy) return note(t("busy"), "warning");
937
- try {
938
- await api("/api/model", { provider: m.provider, id: m.id, both: $("model-both").checked });
939
- dlg.close();
940
- } catch (err) {
941
- note(err.message, "error");
1020
+ class: [isCurrent(m) ? "current" : "", rec ? "rec" : "", can ? "" : "needs-key"].join(" ").trim(),
1021
+ // Without a key, the click goes to the form below: that provider, and the key field.
1022
+ onclick: () => {
1023
+ if (can) return choose(m);
1024
+ if (providers.some((p) => p.id === m.provider)) {
1025
+ sel.value = m.provider;
1026
+ setLink();
942
1027
  }
1028
+ $("login-key").focus();
943
1029
  },
944
- }),
1030
+ },
1031
+ el("span", { class: "model-name", text: spec }),
1032
+ rec ? el("span", { class: "model-tag", text: t(`rec_${rec.why}`) }) : null,
1033
+ rec && !can ? el("span", { class: "model-why", text: t("needsKey", { x: label }) }) : null,
945
1034
  ),
946
1035
  );
947
- }
1036
+ };
1037
+ const search = $("model-search");
1038
+ const render = () => {
1039
+ const q = search.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
1040
+ const hit = (spec) => q.every((w) => spec.toLowerCase().includes(w));
1041
+ const recRows = mine.filter((r) => hit(r.spec)).map((r) => {
1042
+ const i = r.spec.indexOf("/");
1043
+ return row({ provider: r.spec.slice(0, i), id: r.spec.slice(i + 1) }, r);
1044
+ });
1045
+ const rest = others.filter((m) => hit(specOf(m))).map((m) => row(m, null));
1046
+ ul.replaceChildren();
1047
+ if (recRows.length) ul.append(el("li", { class: "models-group", text: t("recommended") }), ...recRows);
1048
+ if (rest.length) ul.append(el("li", { class: "models-group", text: t(recRows.length ? "otherModels" : "allModels") }), ...rest);
1049
+ if (!recRows.length && !rest.length) ul.append(el("li", { class: "empty-models", text: models.length ? t("noMatch") : t("noModels") }));
1050
+ };
1051
+ search.value = "";
1052
+ search.oninput = render;
1053
+ search.onkeydown = (e) => {
1054
+ if (e.key === "Enter") {
1055
+ e.preventDefault();
1056
+ ul.querySelector("button:not(.needs-key)")?.click();
1057
+ }
1058
+ };
1059
+ render();
1060
+ search.focus();
948
1061
  }
949
1062
 
950
1063
  $("login-form")?.addEventListener("submit", async (e) => {
package/web/i18n.js CHANGED
@@ -45,6 +45,16 @@ export const STRINGS = {
45
45
  modelTitleResearcher: "Model for the Researcher",
46
46
  modelTitleLibrarian: "Model for the Librarian",
47
47
  chooseModel: "Choose a model",
48
+ searchModels: "Search, e.g. flash or openrouter free",
49
+ recommended: "Recommended for this mode",
50
+ otherModels: "Other models",
51
+ allModels: "All models",
52
+ noMatch: "No model matches.",
53
+ rec_tested: "tested default",
54
+ rec_cheaper: "tested, cheaper",
55
+ rec_free: "tested, free",
56
+ rec_eu: "tested, EU",
57
+ needsKey: "needs a key from {x}",
48
58
  noModels: "No models yet. Connect a provider below.",
49
59
  connectProvider: "Connect a provider",
50
60
  provider: "Provider",
@@ -52,6 +62,25 @@ export const STRINGS = {
52
62
  save: "Save",
53
63
  getKey: "Get a key",
54
64
  keyNote: "The key is saved on this computer, in Sub-Sub's settings folder, and is sent only to that provider.",
65
+ freeGoogle: "Free to try: a Google AI Studio key needs no card. The free tier limits the text sent each minute and the requests each day, enough to try Sub-Sub, and gemini-3.1-flash-lite is the tested free model. You must be 18 or older. In the EU, the UK and Switzerland, Google does not use your prompts to improve its products; elsewhere, on the free tier, it may.",
66
+ freeOpenRouter: "Free to try: models whose names end in :free cost nothing, with 50 requests a day (1000 after you buy 10 dollars of credit). The providers of some free models may keep your prompts.",
67
+ trouble_quota: "The model provider refused the request: a limit of your account was reached. Free tiers limit the text sent each minute and the requests each day. Wait a minute and ask Sub-Sub to continue; if it happens again, the daily limit is used up (it resets the next day). Or choose another model with the model button.",
68
+ trouble_busy: "The model provider is busy at the moment. Try again in a few minutes, or choose another model with the model button.",
69
+ trouble_blocked: "The model provider stopped the reply (for example, because it repeated a source word for word). Ask again in other words, or choose another model.",
70
+ trouble_key: "The model provider did not accept the API key. Enter it again with the model button.",
71
+ details: "Details",
72
+ undo: "Undo",
73
+ where_eu: "Where your data goes: servers in the European Union. Covered by the GDPR, the choice for material you would not send outside the EU.",
74
+ where_us: "Where your data goes: servers outside the EU, mostly in the United States, under the provider's terms for the EU. Read them before you send unpublished or sensitive material.",
75
+ where_cn: "Where your data goes: servers in China. Do not send unpublished or sensitive material.",
76
+ where_varies: "Where your data goes: it depends on the model. Each model runs at another company, in the United States, China or elsewhere. For material you would not send outside the EU, choose Mistral.",
77
+ setupTitle: "Before you start",
78
+ stepZotero: "Zotero is open and lets Sub-Sub in. If not: start Zotero, then in Settings > Advanced turn on \"Allow other applications on this computer to communicate with Zotero\".",
79
+ stepModel: "A model is connected. If not: select Connect and paste an API key. To try Sub-Sub for free, choose Google Gemini and get a free key there.",
80
+ stepServers: "The Zotero servers are running. If not ({x}): close Sub-Sub, open a terminal and type subsub doctor. Each line marked FIX says what to do.",
81
+ stepDone: "done",
82
+ stepTodo: "to do",
83
+ undoTitle: "Undo this change. Sub-Sub shows what it would restore before it changes anything.",
55
84
  oauthNote: "To sign in through a provider's website (for example with a subscription), open a terminal, type subsub, then /login.",
56
85
  saving: "Saving",
57
86
  saved: "Saved.",
@@ -140,6 +169,16 @@ export const STRINGS = {
140
169
  modelTitleResearcher: "Modelo do Investigador",
141
170
  modelTitleLibrarian: "Modelo do Bibliotecário",
142
171
  chooseModel: "Escolha um modelo",
172
+ searchModels: "Pesquisar, por exemplo flash ou openrouter free",
173
+ recommended: "Recomendados para este modo",
174
+ otherModels: "Outros modelos",
175
+ allModels: "Todos os modelos",
176
+ noMatch: "Nenhum modelo corresponde.",
177
+ rec_tested: "testado, por omissão",
178
+ rec_cheaper: "testado, mais barato",
179
+ rec_free: "testado, gratuito",
180
+ rec_eu: "testado, UE",
181
+ needsKey: "precisa de uma chave de {x}",
143
182
  noModels: "Ainda não há modelos. Ligue um fornecedor abaixo.",
144
183
  connectProvider: "Ligar um fornecedor",
145
184
  provider: "Fornecedor",
@@ -147,6 +186,25 @@ export const STRINGS = {
147
186
  save: "Guardar",
148
187
  getKey: "Obter uma chave",
149
188
  keyNote: "A chave fica guardada neste computador, na pasta de definições do Sub-Sub, e só é enviada a esse fornecedor.",
189
+ freeGoogle: "Para experimentar sem pagar: uma chave do Google AI Studio não pede cartão. O nível gratuito limita o texto enviado por minuto e os pedidos por dia, o que chega para experimentar o Sub-Sub; o modelo gratuito testado é o gemini-3.1-flash-lite. Tem de ter 18 anos ou mais. Na UE, no Reino Unido e na Suíça, a Google não usa os seus pedidos para melhorar os produtos; noutros países, no nível gratuito, pode usar.",
190
+ freeOpenRouter: "Para experimentar sem pagar: os modelos cujo nome termina em :free não custam nada, com 50 pedidos por dia (1000 depois de comprar 10 dólares de crédito). Os fornecedores de alguns modelos gratuitos podem guardar os seus pedidos.",
191
+ trouble_quota: "O fornecedor do modelo recusou o pedido: atingiu um limite da sua conta. Os níveis gratuitos limitam o texto enviado por minuto e os pedidos por dia. Espere um minuto e peça ao Sub-Sub para continuar; se voltar a acontecer, esgotou o limite diário (renova-se no dia seguinte). Ou escolha outro modelo no botão do modelo.",
192
+ trouble_busy: "O fornecedor do modelo está sobrecarregado neste momento. Tente outra vez dentro de alguns minutos ou escolha outro modelo no botão do modelo.",
193
+ trouble_blocked: "O fornecedor do modelo interrompeu a resposta (por exemplo, por repetir uma fonte palavra por palavra). Peça outra vez por outras palavras ou escolha outro modelo.",
194
+ trouble_key: "O fornecedor do modelo não aceitou a chave da API. Volte a introduzi-la no botão do modelo.",
195
+ details: "Detalhes",
196
+ undo: "Anular",
197
+ where_eu: "Para onde vão os seus dados: servidores na União Europeia. Abrangidos pelo RGPD; a escolha para material que não enviaria para fora da UE.",
198
+ where_us: "Para onde vão os seus dados: servidores fora da UE, sobretudo nos Estados Unidos, com os termos do fornecedor para a UE. Leia-os antes de enviar material inédito ou sensível.",
199
+ where_cn: "Para onde vão os seus dados: servidores na China. Não envie material inédito ou sensível.",
200
+ where_varies: "Para onde vão os seus dados: depende do modelo. Cada modelo funciona noutra empresa, nos Estados Unidos, na China ou noutro país. Para material que não enviaria para fora da UE, escolha a Mistral.",
201
+ setupTitle: "Antes de começar",
202
+ stepZotero: "O Zotero está aberto e deixa o Sub-Sub entrar. Se não: abra o Zotero e, em Definições > Avançado, ative \"Allow other applications on this computer to communicate with Zotero\".",
203
+ stepModel: "Há um modelo ligado. Se não: selecione Ligar e cole uma chave de API. Para experimentar sem pagar, escolha Google Gemini e obtenha lá uma chave gratuita.",
204
+ stepServers: "Os servidores do Zotero estão a funcionar. Se não ({x}): feche o Sub-Sub, abra um terminal e escreva subsub doctor. Cada linha marcada FIX diz o que fazer.",
205
+ stepDone: "feito",
206
+ stepTodo: "por fazer",
207
+ undoTitle: "Anular esta alteração. O Sub-Sub mostra o que repõe antes de alterar alguma coisa.",
150
208
  oauthNote: "Para entrar através do site de um fornecedor (por exemplo, com uma subscrição), abra um terminal, escreva subsub e depois /login.",
151
209
  saving: "A guardar",
152
210
  saved: "Guardado.",
package/web/index.html CHANGED
@@ -111,6 +111,7 @@
111
111
  </div>
112
112
  <h3 data-i18n="chooseModel">Choose a model</h3>
113
113
  <label class="check"><input type="checkbox" id="model-both"> <span data-i18n="forBoth">Use it in both modes</span></label>
114
+ <input type="search" id="model-search" class="model-search" data-i18n-placeholder="searchModels" placeholder="Search models" autocomplete="off" spellcheck="false">
114
115
  <ul class="models" id="models"></ul>
115
116
  <h3 data-i18n="connectProvider">Connect a provider</h3>
116
117
  <form id="login-form" class="login">
@@ -121,6 +122,8 @@
121
122
  <input type="password" id="login-key" autocomplete="off" spellcheck="false" required>
122
123
  </label>
123
124
  <p class="small"><a id="login-link" href="#" target="_blank" rel="noopener noreferrer" data-i18n="getKey">Get a key</a></p>
125
+ <p class="small muted" id="login-where"></p>
126
+ <p class="small free-note" id="login-free" hidden></p>
124
127
  <p class="small muted" data-i18n="keyNote"></p>
125
128
  <div class="modal-buttons"><button type="submit" class="primary" id="login-save" data-i18n="save">Save</button></div>
126
129
  <p class="small muted" data-i18n="oauthNote"></p>