@tiagojct/subsub 0.23.0 → 0.24.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/CHANGELOG.md +19 -0
- package/README.md +18 -4
- package/dist/chrome.js +1 -1
- package/dist/config.js +2 -1
- package/dist/cost.js +17 -0
- package/dist/doctor.js +13 -2
- package/dist/init.js +38 -2
- package/dist/look.js +2 -2
- package/dist/prompt.js +6 -1
- package/dist/providers.js +78 -0
- package/dist/roles.js +11 -0
- package/dist/screens.js +8 -2
- package/dist/subsub.js +67 -13
- package/dist/tui-view.js +1 -1
- package/dist/tui.js +76 -4
- package/dist/web.js +8 -4
- package/package.json +1 -1
- package/prompts/check-library.md +12 -0
- package/prompts/compare.md +1 -1
- package/prompts/lit.md +9 -4
- package/roles/librarian.md +2 -1
- package/roles/researcher.md +3 -2
- package/src/chrome.ts +1 -1
- package/src/config.ts +4 -1
- package/src/cost.ts +22 -0
- package/src/doctor.ts +12 -2
- package/src/init.ts +39 -2
- package/src/look.ts +2 -2
- package/src/prompt.ts +14 -1
- package/src/providers.ts +82 -0
- package/src/roles.ts +11 -0
- package/src/screens.ts +14 -2
- package/src/subsub.ts +67 -12
- package/src/tui-view.ts +1 -1
- package/src/tui.ts +79 -4
- package/src/web.ts +8 -3
- package/web/app.js +21 -7
- package/web/i18n.js +13 -2
- package/web/index.html +6 -0
package/src/screens.ts
CHANGED
|
@@ -237,6 +237,15 @@ export interface LibraryItem {
|
|
|
237
237
|
title?: string;
|
|
238
238
|
authors?: string;
|
|
239
239
|
year?: string | number;
|
|
240
|
+
/** A group library's item: "group:<id>" and the group's name. Groups are read-only. */
|
|
241
|
+
library?: string;
|
|
242
|
+
group?: string;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** How a command names an item: the citekey (or key), and the group library when it is in one. */
|
|
246
|
+
export function itemRef(it: LibraryItem): string {
|
|
247
|
+
const ref = it.citekey || it.key;
|
|
248
|
+
return it.library ? `${ref} in ${it.library}` : ref;
|
|
240
249
|
}
|
|
241
250
|
|
|
242
251
|
export interface LibraryLayout {
|
|
@@ -270,7 +279,7 @@ export function libraryLines(o: LibraryLayout): string[] {
|
|
|
270
279
|
const start = Math.max(0, Math.min(o.selected - Math.floor(fit / 2), o.items.length - fit));
|
|
271
280
|
for (const [k, it] of o.items.slice(start, start + fit).entries()) {
|
|
272
281
|
const chosen = start + k === o.selected;
|
|
273
|
-
const meta = [it.authors, it.year, it.citekey].filter(Boolean).join(" · ");
|
|
282
|
+
const meta = [it.authors, it.year, it.citekey, it.group].filter(Boolean).join(" · ");
|
|
274
283
|
out.push(chosen ? `${p.mark(g.pointer)} ${p.text(cut(it.title || it.key, w - 2))}` : ` ${p.muted(cut(it.title || it.key, w - 2))}`, ` ${p.dim(cut(meta, w - 2))}`);
|
|
275
284
|
}
|
|
276
285
|
}
|
|
@@ -480,6 +489,8 @@ export interface InputOptions {
|
|
|
480
489
|
title: string;
|
|
481
490
|
placeholder?: string;
|
|
482
491
|
prefill?: string;
|
|
492
|
+
/** An API key: each character is shown as a star. */
|
|
493
|
+
secret?: boolean;
|
|
483
494
|
submit: (text: string) => void;
|
|
484
495
|
cancel: () => void;
|
|
485
496
|
lang: Lang;
|
|
@@ -506,7 +517,8 @@ export class InputView {
|
|
|
506
517
|
}
|
|
507
518
|
|
|
508
519
|
render(width: number): string[] {
|
|
509
|
-
|
|
520
|
+
const text = this.o.secret ? "*".repeat([...this.text].length) : this.text;
|
|
521
|
+
return fill(inputLines({ title: this.o.title, placeholder: this.o.placeholder, text, width, lang: this.o.lang, paint: this.o.paint, glyphs: this.o.glyphs }), width);
|
|
510
522
|
}
|
|
511
523
|
|
|
512
524
|
invalidate(): void {}
|
package/src/subsub.ts
CHANGED
|
@@ -45,14 +45,15 @@ import { describeArgs, formatPreview, isEmptyPreview } from "./preview.ts";
|
|
|
45
45
|
import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.ts";
|
|
46
46
|
import { headerLines, LIBRARIAN_THEME, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
|
|
47
47
|
import { footerLines, toolRenderers, usageTotals } from "./chrome.ts";
|
|
48
|
+
import { type CostLevel, costStatus, money, usesModel } from "./cost.ts";
|
|
48
49
|
import { ApprovalView } from "./dialogs.ts";
|
|
49
50
|
import { allowedFile, listNotes, OPENABLE, openFile } from "./files.ts";
|
|
50
|
-
import { LibraryView, NotesView, type LibraryItem } from "./screens.ts";
|
|
51
|
+
import { itemRef, LibraryView, NotesView, type LibraryItem } from "./screens.ts";
|
|
51
52
|
import { glyphs, paintFromTheme, workingFrames } from "./skin.ts";
|
|
52
53
|
import { loadWords, toolWord, type Words } from "./words.ts";
|
|
53
54
|
import { systemAddition } from "./prompt.ts";
|
|
54
55
|
import { logUsage } from "./usage.ts";
|
|
55
|
-
import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason, outboundProblem } from "./roles.ts";
|
|
56
|
+
import { activeTools, gateKind, SERVER_PREFIXES, toolsFor, unavailableReason, outboundProblem } from "./roles.ts";
|
|
56
57
|
|
|
57
58
|
const MODE_ENTRY = "subsub-mode";
|
|
58
59
|
|
|
@@ -181,6 +182,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
181
182
|
let resumeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
182
183
|
let turnStart = 0;
|
|
183
184
|
let turnTools = 0;
|
|
185
|
+
/** The cost level already noted in this session, so each note comes once. */
|
|
186
|
+
let costNoted: CostLevel | undefined;
|
|
184
187
|
let webCtx: ExtensionContext | undefined;
|
|
185
188
|
function emitWeb(): void {
|
|
186
189
|
const ctx = webCtx;
|
|
@@ -195,6 +198,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
195
198
|
userName: cfg.userName ?? null,
|
|
196
199
|
vault: cfg.vault ?? null,
|
|
197
200
|
starbuck: starbuckEnabled(cfg),
|
|
201
|
+
// Shown next to the model only when there is a limit.
|
|
202
|
+
cost: cfg.costLimit ? { spent: usageTotals(ctx.sessionManager.getEntries()).cost, limit: cfg.costLimit } : null,
|
|
198
203
|
};
|
|
199
204
|
try {
|
|
200
205
|
ctx.ui.setStatus("subsub.web", JSON.stringify(state));
|
|
@@ -277,15 +282,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
277
282
|
/** /library: search as you type; Enter puts /read <item> in the box (the Librarian gets the key), ctrl+n puts /lit-note <item>. */
|
|
278
283
|
async function openLibrary(ctx: ExtensionContext): Promise<void> {
|
|
279
284
|
if (!screenOk(ctx)) return;
|
|
280
|
-
const ref =
|
|
285
|
+
const ref = itemRef;
|
|
281
286
|
await ctx.ui.custom<void>(
|
|
282
287
|
(tui, theme, _keys, done) =>
|
|
283
288
|
new LibraryView({
|
|
284
|
-
search:
|
|
285
|
-
const r = await current().call("zotero_find_items", { query, limit: 25 });
|
|
286
|
-
if (r.isError) throw new Error(r.text);
|
|
287
|
-
return ((r.data as { items?: LibraryItem[] })?.items ?? []).filter((it) => it && it.key);
|
|
288
|
-
},
|
|
289
|
+
search: searchLibraries,
|
|
289
290
|
choose: (it) => {
|
|
290
291
|
ctx.ui.setEditorText(mode === "researcher" ? `/read ${ref(it)}` : ref(it));
|
|
291
292
|
done();
|
|
@@ -326,6 +327,33 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
326
327
|
: text;
|
|
327
328
|
}
|
|
328
329
|
|
|
330
|
+
/** The group libraries in Zotero, from the last library check (read-only; searched with the user's library). */
|
|
331
|
+
let groups: Array<{ library: string; name: string }> = [];
|
|
332
|
+
|
|
333
|
+
/** Search the user's library, then each group library; a group's items carry the group. */
|
|
334
|
+
async function searchLibraries(query: string): Promise<LibraryItem[]> {
|
|
335
|
+
const first = await current().call("zotero_find_items", { query, limit: 25 });
|
|
336
|
+
if (first.isError) throw new Error(first.text);
|
|
337
|
+
const items = ((first.data as { items?: LibraryItem[] })?.items ?? []).filter((it) => it && it.key);
|
|
338
|
+
for (const g of groups) {
|
|
339
|
+
const r = await current().call("zotero_find_items", { query, limit: 25, library: g.library });
|
|
340
|
+
if (r.isError) continue;
|
|
341
|
+
for (const it of (r.data as { items?: LibraryItem[] })?.items ?? []) if (it?.key) items.push({ ...it, library: g.library, group: g.name });
|
|
342
|
+
}
|
|
343
|
+
return items;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function refreshGroups(): Promise<void> {
|
|
347
|
+
if (!current().has("zotero_list_libraries")) return;
|
|
348
|
+
try {
|
|
349
|
+
const r = await current().call("zotero_list_libraries", {});
|
|
350
|
+
const all = ((r.data as { libraries?: Array<{ library: string; name?: string }> })?.libraries ?? []);
|
|
351
|
+
groups = r.isError ? [] : all.filter((l) => l.library?.startsWith("group:")).map((l) => ({ library: l.library, name: l.name || l.library }));
|
|
352
|
+
} catch {
|
|
353
|
+
groups = [];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
329
357
|
async function refreshLibrary(): Promise<void> {
|
|
330
358
|
try {
|
|
331
359
|
const st = await current().call("zotero_status", {});
|
|
@@ -336,6 +364,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
336
364
|
const ov = await current().call("zotero_library_overview", {});
|
|
337
365
|
const o = (ov.data ?? {}) as Record<string, unknown>;
|
|
338
366
|
Object.assign(library, { zotero: "reachable", items: o.items as number, toReview: o.awaiting_review as number });
|
|
367
|
+
await refreshGroups();
|
|
339
368
|
}
|
|
340
369
|
} catch {
|
|
341
370
|
library.zotero = "down";
|
|
@@ -410,7 +439,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
410
439
|
/** Set the mode's tools and, unless `switchModel` is false, the mode's model. */
|
|
411
440
|
async function applyMode(ctx: ExtensionContext, next: Mode, remember: boolean, switchModel = true): Promise<void> {
|
|
412
441
|
mode = next;
|
|
413
|
-
pi.setActiveTools(toolsFor(mode, registered(), toolOpts()));
|
|
442
|
+
pi.setActiveTools(activeTools(pi.getActiveTools(), toolsFor(mode, registered(), toolOpts()), standalone));
|
|
414
443
|
const spec = cfg.models[mode];
|
|
415
444
|
if (spec && switchModel) {
|
|
416
445
|
const i = spec.indexOf("/");
|
|
@@ -442,6 +471,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
442
471
|
}
|
|
443
472
|
|
|
444
473
|
pi.on("session_start", async (_event, ctx) => {
|
|
474
|
+
costNoted = undefined; // a new or another conversation: its own cost
|
|
445
475
|
if (!bridge) {
|
|
446
476
|
const b = new Bridge();
|
|
447
477
|
await b.start(deps.specs ?? serverSpecs(cfg), cfg.startTimeout * 1000);
|
|
@@ -531,6 +561,17 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
531
561
|
// A per-minute limit (free tiers): wait as long as the provider says, then continue the task,
|
|
532
562
|
// up to three times per request of the user. Anything the user types cancels the wait.
|
|
533
563
|
pi.on("agent_end", async (_event, ctx) => {
|
|
564
|
+
const spent = costStatus(usageTotals(ctx.sessionManager.getEntries()).cost, cfg.costLimit);
|
|
565
|
+
if (spent.level !== "ok" && costNoted !== spent.level) {
|
|
566
|
+
costNoted = spent.level;
|
|
567
|
+
ctx.ui.notify(
|
|
568
|
+
spent.level === "near"
|
|
569
|
+
? L(`Sub-Sub: this conversation has used 80% of your cost limit (${money(spent.spent)} of ${money(spent.limit!)}).`, `Sub-Sub: esta conversa já usou 80% do seu limite de custo (${money(spent.spent)} de ${money(spent.limit!)}).`)
|
|
570
|
+
: L(`Sub-Sub: this conversation has reached your cost limit (${money(spent.spent)} of ${money(spent.limit!)}). Sub-Sub will ask before each new request.`, `Sub-Sub: esta conversa atingiu o seu limite de custo (${money(spent.spent)} de ${money(spent.limit!)}). O Sub-Sub vai perguntar antes de cada pedido novo.`),
|
|
571
|
+
"warning",
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
if (cfg.costLimit) emitWeb(); // the cost next to the model in the browser view
|
|
534
575
|
if (turnStart) logUsage(cfg.usageLog, { ev: "turn", seconds: Math.round((Date.now() - turnStart) / 1000), tools: turnTools, error: providerTrouble(lastError) ?? (lastError ? "other" : undefined) });
|
|
535
576
|
turnStart = 0;
|
|
536
577
|
const wait = perMinuteWait(lastError);
|
|
@@ -558,6 +599,19 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
558
599
|
resumeTimer = undefined;
|
|
559
600
|
resumes = 0;
|
|
560
601
|
const cmd = /^\/([\w-]+)(?:\s|$)/.exec(event.text.trim())?.[1];
|
|
602
|
+
// Over the cost limit: ask before each new request to the model (a running answer is not cut).
|
|
603
|
+
const spent = costStatus(usageTotals(ctx.sessionManager.getEntries()).cost, cfg.costLimit);
|
|
604
|
+
if (spent.level === "over" && usesModel(event.text)) {
|
|
605
|
+
const text = L(
|
|
606
|
+
`This conversation has cost ${money(spent.spent)}, over your limit of ${money(spent.limit!)} (costLimit in the settings). Send this request to the model?`,
|
|
607
|
+
`Esta conversa já custou ${money(spent.spent)}, acima do seu limite de ${money(spent.limit!)} (costLimit nas definições). Enviar este pedido ao modelo?`,
|
|
608
|
+
);
|
|
609
|
+
const go = ctx.hasUI ? await ctx.ui.confirm(L("Sub-Sub: cost limit", "Sub-Sub: limite de custo"), text) : false;
|
|
610
|
+
if (!go) {
|
|
611
|
+
if (!ctx.hasUI) ctx.ui.notify(text, "warning");
|
|
612
|
+
return { action: "handled" };
|
|
613
|
+
}
|
|
614
|
+
}
|
|
561
615
|
// The usage log keeps the kind of request and its length, never its text.
|
|
562
616
|
if (cmd !== "subsub-refresh") logUsage(cfg.usageLog, { ev: "ask", kind: cmd ? `/${cmd}` : "message", words: event.text.trim().split(/\s+/).filter(Boolean).length });
|
|
563
617
|
if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
|
|
@@ -578,7 +632,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
578
632
|
});
|
|
579
633
|
|
|
580
634
|
pi.on("before_agent_start", async (event, ctx) => ({
|
|
581
|
-
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, cfg, ctx.cwd)}`,
|
|
635
|
+
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, cfg, ctx.cwd, { zoteroDown: library.zotero === "down" })}`,
|
|
582
636
|
}));
|
|
583
637
|
|
|
584
638
|
/** Model test (subsub-bench): record every change instead of asking, and apply none. */
|
|
@@ -846,6 +900,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
846
900
|
? ov.text
|
|
847
901
|
: `Items: ${o.items}. Without tags: ${o.without_manual_tags}. Missing facets: ${JSON.stringify(o.missing_facet)}. Awaiting review (_agent): ${o.awaiting_review}.`,
|
|
848
902
|
);
|
|
903
|
+
await refreshGroups();
|
|
904
|
+
if (groups.length) lines.push(`Group libraries (read-only, searched with yours): ${groups.map((g) => g.name).join(", ")}.`);
|
|
849
905
|
}
|
|
850
906
|
}
|
|
851
907
|
redraw();
|
|
@@ -870,8 +926,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
870
926
|
const q = String(args ?? "").trim();
|
|
871
927
|
let out: Record<string, unknown>;
|
|
872
928
|
try {
|
|
873
|
-
|
|
874
|
-
out = r.isError ? { error: r.text } : { items: (r.data as { items?: unknown[] })?.items ?? [] };
|
|
929
|
+
out = { items: await searchLibraries(q) };
|
|
875
930
|
} catch (err) {
|
|
876
931
|
out = { error: (err as Error).message };
|
|
877
932
|
}
|
package/src/tui-view.ts
CHANGED
|
@@ -252,7 +252,7 @@ export function sideLines(s: SideState, width: number, rows: number, p: Paint, g
|
|
|
252
252
|
const w = Math.max(16, width);
|
|
253
253
|
const words = SIDE_WORDS[s.lang];
|
|
254
254
|
const mode = s.mode === "librarian" ? "Librarian" : "Researcher";
|
|
255
|
-
const lib = s.library.zotero === "reachable" ? `${s.library.items ?? "?"} ${s.lang === "pt" ? "itens" : "items"}` : s.library.zotero === "down" ? `${g.hollow} Zotero` : "Zotero...";
|
|
255
|
+
const lib = s.library.zotero === "reachable" ? `${s.library.items ?? "?"} ${s.lang === "pt" ? "itens" : "items"}` : s.library.zotero === "down" ? `${g.hollow} Zotero: ${s.lang === "pt" ? "só pesquisa" : "search only"}` : "Zotero...";
|
|
256
256
|
const lines = [p.mark(cut(` Sub-Sub ${s.version}`, w)), p.text(cut(` ${mode}`, w)), p.muted(cut(` ${lib}`, w)), p.dim(cut(` ${s.model}`, w)), rule(w, "solid", p.muted, g)];
|
|
257
257
|
for (const [key, word] of [["F2", words.notes], ["F3", words.library], ["F4", words.actions], ["F5", words.mode]] as const) lines.push(p.muted(cut(` ${key.padEnd(4)}${word}`, w)));
|
|
258
258
|
lines.push(rule(w, "solid", p.muted, g), p.text(cut(` ${words.conversations}`, w)));
|
package/src/tui.ts
CHANGED
|
@@ -12,12 +12,15 @@ import { CombinedAutocompleteProvider, Editor, HStack, Markdown, matchesKey, Pro
|
|
|
12
12
|
import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
|
|
13
13
|
import { fill, footerLines } from "./chrome.ts";
|
|
14
14
|
import { loadConfig, uiLanguage } from "./config.ts";
|
|
15
|
+
import { modelsAfterLogin } from "./init.ts";
|
|
15
16
|
import { type LibraryState, LIBRARIAN_THEME, DEFAULT_THEME, paintPreview, QUOTES } from "./look.ts";
|
|
16
17
|
import { PURPOSES } from "./read.ts";
|
|
17
18
|
import { allowedFile, listNotes, OPENABLE, openFile } from "./files.ts";
|
|
18
19
|
import { Agent, findLibrary, isDialog, Side, withHints } from "./rpc.ts";
|
|
20
|
+
import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
|
|
21
|
+
import { CUSTOM_DEFAULT_NAME, saveCustomProvider } from "./providers.ts";
|
|
19
22
|
import { ApprovalView } from "./dialogs.ts";
|
|
20
|
-
import { type Choice, ChoiceView, InputView, LibraryView, type LibraryItem, NotesView } from "./screens.ts";
|
|
23
|
+
import { itemRef, type Choice, ChoiceView, InputView, LibraryView, type LibraryItem, NotesView } from "./screens.ts";
|
|
21
24
|
import { glyphs as chooseGlyphs, type Glyphs, workingFrames } from "./skin.ts";
|
|
22
25
|
import { bannerLines, type Entry, entriesFromMessages, loadPalette, messageText, type Palette, schemeFromEnv, type Scheme, SIDE_WIDTH, sideLines, transcriptLines } from "./tui-view.ts";
|
|
23
26
|
import { loadWords, type Words } from "./words.ts";
|
|
@@ -58,6 +61,10 @@ export interface TuiDeps {
|
|
|
58
61
|
searchDelay?: number;
|
|
59
62
|
/** Called when the user quits or pi stops. */
|
|
60
63
|
onQuit?: (code: number) => void;
|
|
64
|
+
/** pi's agent folder, where /login saves keys (auth.json) and other providers (models.json). */
|
|
65
|
+
agentDir?: string;
|
|
66
|
+
/** After /login saved a provider: set the models when none are set (spec: the other provider's model). */
|
|
67
|
+
afterLogin?: (provider: string, spec?: string) => void;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
const SHORT = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 3)}...` : s);
|
|
@@ -213,6 +220,8 @@ export class TuiApp {
|
|
|
213
220
|
{ name: "library", description: pt ? "Pesquisar na biblioteca à medida que escreve" : "Search your library as you type" },
|
|
214
221
|
{ name: "actions", description: pt ? "As ações da vista no navegador" : "The actions of the browser view" },
|
|
215
222
|
{ name: "sessions", description: pt ? "Conversas anteriores" : "Earlier conversations" },
|
|
223
|
+
{ name: "resume", description: pt ? "Retomar uma conversa anterior" : "Resume an earlier conversation" },
|
|
224
|
+
{ name: "login", description: pt ? "Ligar um fornecedor do modelo" : "Connect a model provider" },
|
|
216
225
|
{ name: "new", description: pt ? "Uma conversa nova" : "A new conversation" },
|
|
217
226
|
{ name: "model", description: pt ? "Escolher o modelo" : "Choose the model" },
|
|
218
227
|
{ name: "restart", description: pt ? "Recomeçar o pi na mesma conversa" : "Start pi again in the same conversation" },
|
|
@@ -492,7 +501,7 @@ export class TuiApp {
|
|
|
492
501
|
/** Alt+L, /library: search as you type; Enter puts /read <item> in the editor (the Librarian gets the key), ctrl+n puts /lit-note <item>. */
|
|
493
502
|
async openLibrary(): Promise<void> {
|
|
494
503
|
if (this.overlay) return;
|
|
495
|
-
const ref =
|
|
504
|
+
const ref = itemRef;
|
|
496
505
|
this.open(
|
|
497
506
|
new LibraryView({
|
|
498
507
|
...this.common(),
|
|
@@ -558,6 +567,70 @@ export class TuiApp {
|
|
|
558
567
|
);
|
|
559
568
|
}
|
|
560
569
|
|
|
570
|
+
/** Ask one line on its own screen; undefined when the user presses Esc. */
|
|
571
|
+
private ask(title: string, opts: { placeholder?: string; secret?: boolean } = {}): Promise<string | undefined> {
|
|
572
|
+
return new Promise((resolve) => {
|
|
573
|
+
this.open(
|
|
574
|
+
new InputView({
|
|
575
|
+
...this.common(),
|
|
576
|
+
title,
|
|
577
|
+
...opts,
|
|
578
|
+
submit: (text) => (this.close(), resolve(text.trim())),
|
|
579
|
+
cancel: () => (this.close(), resolve(undefined)),
|
|
580
|
+
}),
|
|
581
|
+
true,
|
|
582
|
+
);
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** /login: a provider that pi has (its API key) or another provider that speaks the OpenAI API; then pi starts again. */
|
|
587
|
+
async openLogin(): Promise<void> {
|
|
588
|
+
if (this.overlay) return;
|
|
589
|
+
const pt = this.lang === "pt";
|
|
590
|
+
const agentDir = this.d.agentDir;
|
|
591
|
+
if (!agentDir) return void this.notice("error", pt ? "Sem pasta do agente: use subsub web." : "No agent folder: use subsub web.");
|
|
592
|
+
const choices: Choice[] = [
|
|
593
|
+
...KEY_PROVIDERS.map((p) => ({ label: p.label, hint: p.url, value: p.id })),
|
|
594
|
+
{ label: pt ? "Outro fornecedor (API da OpenAI)" : "Another provider (OpenAI API)", hint: pt ? "servidor da instituição, Ollama, LM Studio" : "your institution's server, Ollama, LM Studio", value: CUSTOM_DEFAULT_NAME },
|
|
595
|
+
];
|
|
596
|
+
const provider = await new Promise<string | undefined>((resolve) => {
|
|
597
|
+
this.open(
|
|
598
|
+
new ChoiceView({
|
|
599
|
+
...this.common(),
|
|
600
|
+
title: pt ? "Ligar um fornecedor" : "Connect a provider",
|
|
601
|
+
choices,
|
|
602
|
+
choose: (v) => (this.close(), resolve(v)),
|
|
603
|
+
cancel: () => (this.close(), resolve(undefined)),
|
|
604
|
+
}),
|
|
605
|
+
true,
|
|
606
|
+
);
|
|
607
|
+
});
|
|
608
|
+
if (!provider) return;
|
|
609
|
+
let spec: string | undefined;
|
|
610
|
+
try {
|
|
611
|
+
if (provider === CUSTOM_DEFAULT_NAME) {
|
|
612
|
+
const baseUrl = await this.ask(pt ? "Endereço do servidor" : "Server address", { placeholder: "https://llm.example.org/v1" });
|
|
613
|
+
if (baseUrl === undefined) return;
|
|
614
|
+
const model = await this.ask(pt ? "Identificador do modelo" : "Model id");
|
|
615
|
+
if (model === undefined) return;
|
|
616
|
+
const key = await this.ask(pt ? "Chave de API (Enter sem chave, num servidor local)" : "API key (Enter for none, for a local server)", { secret: true });
|
|
617
|
+
if (key === undefined) return;
|
|
618
|
+
[spec] = saveCustomProvider(agentDir, { name: CUSTOM_DEFAULT_NAME, baseUrl, apiKey: key, models: [model] });
|
|
619
|
+
} else {
|
|
620
|
+
const label = KEY_PROVIDERS.find((p) => p.id === provider)?.label ?? provider;
|
|
621
|
+
const key = await this.ask(pt ? `Chave de API de ${label}` : `API key for ${label}`, { secret: true });
|
|
622
|
+
if (!key) return;
|
|
623
|
+
saveApiKey(agentDir, provider, key);
|
|
624
|
+
}
|
|
625
|
+
this.d.afterLogin?.(provider, spec);
|
|
626
|
+
} catch (err) {
|
|
627
|
+
this.notice("error", (err as Error).message);
|
|
628
|
+
return void this.tui.requestRender();
|
|
629
|
+
}
|
|
630
|
+
this.notice("info", pt ? "Guardado. O pi recomeça com o fornecedor." : "Saved. pi starts again with the provider.");
|
|
631
|
+
await this.restart();
|
|
632
|
+
}
|
|
633
|
+
|
|
561
634
|
/** /new: an empty conversation. */
|
|
562
635
|
async newConversation(): Promise<void> {
|
|
563
636
|
try {
|
|
@@ -633,12 +706,12 @@ export class TuiApp {
|
|
|
633
706
|
this.editor.addToHistory?.(line);
|
|
634
707
|
this.editor.setText("");
|
|
635
708
|
if (/^\/(quit|exit)\s*$/.test(line)) return void (await this.quit());
|
|
636
|
-
const unavailable = /^\/(
|
|
709
|
+
const unavailable = /^\/(logout|settings|tree|fork|share|export|import|hotkeys|reload)\b/.exec(line);
|
|
637
710
|
if (unavailable) {
|
|
638
711
|
this.notice("info", this.lang === "pt" ? `/${unavailable[1]} não existe em subsub tui. Use subsub no terminal ou subsub web.` : `/${unavailable[1]} is not in subsub tui. Use subsub in the terminal, or subsub web.`);
|
|
639
712
|
return void this.tui.requestRender();
|
|
640
713
|
}
|
|
641
|
-
const own: Record<string, () => Promise<void> | void> = { "/restart": () => this.restart(), "/model": () => this.openModels(), "/notes": () => this.openNotes(), "/library": () => this.openLibrary(), "/actions": () => this.openActions(), "/sessions": () => this.openSessions(), "/new": () => this.newConversation() };
|
|
714
|
+
const own: Record<string, () => Promise<void> | void> = { "/restart": () => this.restart(), "/model": () => this.openModels(), "/notes": () => this.openNotes(), "/library": () => this.openLibrary(), "/actions": () => this.openActions(), "/sessions": () => this.openSessions(), "/resume": () => this.openSessions(), "/login": () => this.openLogin(), "/new": () => this.newConversation() };
|
|
642
715
|
if (own[line]) return void (await own[line]());
|
|
643
716
|
try {
|
|
644
717
|
await this.d.agent.request({ type: "prompt", message: line, ...(this.busy ? { streamingBehavior: "followUp" } : {}) });
|
|
@@ -703,6 +776,8 @@ export async function runTui(args: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
703
776
|
themes: { researcher: typeof cfg.themes === "string" ? cfg.themes : undefined, librarian: cfg.themesLibrarian ?? (typeof cfg.themes === "string" ? cfg.themes : undefined) },
|
|
704
777
|
sessions: async () => (await SessionManager.list(cwd)).filter((s) => s.messageCount > 0).sort((a, b) => +new Date(b.modified) - +new Date(a.modified)).map((s) => ({ title: s.name ?? s.firstMessage.slice(0, 60), path: s.path })),
|
|
705
778
|
onQuit: resolve,
|
|
779
|
+
agentDir: env.PI_CODING_AGENT_DIR,
|
|
780
|
+
afterLogin: (provider, spec) => modelsAfterLogin(env, provider, spec),
|
|
706
781
|
});
|
|
707
782
|
void app.start();
|
|
708
783
|
});
|
package/src/web.ts
CHANGED
|
@@ -21,8 +21,9 @@ import { homedir } from "node:os";
|
|
|
21
21
|
import { basename, dirname, extname, join, normalize, relative, resolve, sep } from "node:path";
|
|
22
22
|
import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
|
|
23
23
|
import { configPath, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, type SubsubConfig, uiLanguage, withStarbuck } from "./config.ts";
|
|
24
|
-
import {
|
|
24
|
+
import { modelsAfterLogin } from "./init.ts";
|
|
25
25
|
import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
|
|
26
|
+
import { CUSTOM_DEFAULT_NAME, saveCustomProvider } from "./providers.ts";
|
|
26
27
|
|
|
27
28
|
// ---------------------------------------------------------------- options
|
|
28
29
|
|
|
@@ -479,9 +480,13 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
479
480
|
if (busy || switching) return send(res, 409, { error: "busy" });
|
|
480
481
|
const body = await readJson(req);
|
|
481
482
|
return withRestart(res, () => {
|
|
483
|
+
if (body.provider === CUSTOM_DEFAULT_NAME) {
|
|
484
|
+
// Another provider (OpenAI API): models.json; its model becomes the default when none is set.
|
|
485
|
+
const [spec] = saveCustomProvider(agentDir, { name: CUSTOM_DEFAULT_NAME, baseUrl: String(body.baseUrl ?? ""), apiKey: String(body.key ?? ""), models: [String(body.model ?? "")] });
|
|
486
|
+
return modelsAfterLogin(env, CUSTOM_DEFAULT_NAME, spec);
|
|
487
|
+
}
|
|
482
488
|
saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
|
|
483
|
-
|
|
484
|
-
if (body.provider === "opencode-go" && Object.keys(current.models).length === 0) saveConfig({ models: TESTED_MODELS, model: undefined }, env);
|
|
489
|
+
modelsAfterLogin(env, String(body.provider ?? ""));
|
|
485
490
|
});
|
|
486
491
|
}
|
|
487
492
|
case "GET /api/notes":
|
package/web/app.js
CHANGED
|
@@ -12,6 +12,8 @@ let session = null; // pi's session state
|
|
|
12
12
|
let busy = false;
|
|
13
13
|
let commands = [];
|
|
14
14
|
let providers = [];
|
|
15
|
+
/** The login form's choice for a provider that pi does not include (src/providers.ts CUSTOM_DEFAULT_NAME). */
|
|
16
|
+
const OTHER_PROVIDER = "custom";
|
|
15
17
|
const dialogs = []; // waiting dialog requests, oldest first
|
|
16
18
|
let shownDialog = null;
|
|
17
19
|
let currentMsg = null; // assistant message being streamed
|
|
@@ -255,7 +257,10 @@ function renderHeader() {
|
|
|
255
257
|
$("starbuck-label").title = t("starbuckHint");
|
|
256
258
|
const m = state?.model ?? session?.model;
|
|
257
259
|
const none = !m || !m.id || m.id === "unknown";
|
|
258
|
-
|
|
260
|
+
// With a cost limit in the settings, the conversation's cost and the limit follow the model.
|
|
261
|
+
const c = state?.cost;
|
|
262
|
+
const cost = c && typeof c.spent === "number" ? ` · $${c.spent.toFixed(2)}/$${c.limit.toFixed(2)}` : "";
|
|
263
|
+
$("model-btn").textContent = $("model-btn-side").textContent = none ? t("noModel") : m.id + cost;
|
|
259
264
|
for (const b of [$("model-btn"), $("model-btn-side")]) b.classList.toggle("needs", none);
|
|
260
265
|
const lib = $("library");
|
|
261
266
|
const txt = lib.querySelector(".library-text") ?? lib;
|
|
@@ -363,7 +368,9 @@ function renderItems(items, message) {
|
|
|
363
368
|
if (message) return ul.append(el("li", { class: "muted", text: message }));
|
|
364
369
|
const researcher = mode() === "researcher";
|
|
365
370
|
for (const it of items) {
|
|
366
|
-
|
|
371
|
+
// A group library's item says its library (keep in step with itemRef in src/screens.ts).
|
|
372
|
+
const ref = it.library ? `${it.citekey || it.key} in ${it.library}` : it.citekey || it.key;
|
|
373
|
+
const scope = it.library ? `groups/${it.library.split(":")[1]}` : "library";
|
|
367
374
|
const run = (cmd) => () => {
|
|
368
375
|
closeSide();
|
|
369
376
|
sendMessage(`/${cmd} ${ref}`);
|
|
@@ -373,13 +380,13 @@ function renderItems(items, message) {
|
|
|
373
380
|
"li",
|
|
374
381
|
{},
|
|
375
382
|
el("div", { class: "item-title", text: it.title || it.key }),
|
|
376
|
-
el("div", { class: "item-meta", text: [it.authors, it.year, it.citekey].filter(Boolean).join(" · ") }),
|
|
383
|
+
el("div", { class: "item-meta", text: [it.authors, it.year, it.citekey, it.group].filter(Boolean).join(" · ") }),
|
|
377
384
|
el(
|
|
378
385
|
"div",
|
|
379
386
|
{ class: "item-actions" },
|
|
380
387
|
researcher ? el("button", { type: "button", text: t("readItem"), onclick: run("read") }) : null,
|
|
381
388
|
researcher ? el("button", { type: "button", text: t("noteItem"), onclick: run("lit-note") }) : null,
|
|
382
|
-
el("a", { href: `zotero://select/
|
|
389
|
+
el("a", { href: `zotero://select/${scope}/items/${encodeURIComponent(it.key)}`, text: "Zotero" }),
|
|
383
390
|
),
|
|
384
391
|
),
|
|
385
392
|
);
|
|
@@ -1260,15 +1267,22 @@ async function openModels() {
|
|
|
1260
1267
|
const ul = $("models");
|
|
1261
1268
|
ul.replaceChildren(el("li", { class: "empty-models", text: "…" }));
|
|
1262
1269
|
const sel = $("login-provider");
|
|
1263
|
-
sel.replaceChildren(...providers.map((p) => el("option", { value: p.id, text: p.label })));
|
|
1270
|
+
sel.replaceChildren(...providers.map((p) => el("option", { value: p.id, text: p.label })), el("option", { value: OTHER_PROVIDER, text: t("otherProvider") }));
|
|
1264
1271
|
const link = $("login-link");
|
|
1265
1272
|
const setLink = () => {
|
|
1266
1273
|
const p = providers.find((x) => x.id === sel.value);
|
|
1274
|
+
const other = sel.value === OTHER_PROVIDER;
|
|
1275
|
+
link.hidden = other;
|
|
1267
1276
|
link.href = p?.url ?? "#";
|
|
1277
|
+
$("login-url-row").hidden = !other;
|
|
1278
|
+
$("login-model-row").hidden = !other;
|
|
1279
|
+
$("login-url").required = other;
|
|
1280
|
+
$("login-model").required = other;
|
|
1281
|
+
$("login-key").required = !other;
|
|
1268
1282
|
const free = { google: "freeGoogle", openrouter: "freeOpenRouter" }[sel.value];
|
|
1269
1283
|
$("login-free").hidden = !free;
|
|
1270
1284
|
$("login-free").textContent = free ? t(free) : "";
|
|
1271
|
-
$("login-where").textContent = p?.where ? t(`where_${p.where}`) : "";
|
|
1285
|
+
$("login-where").textContent = other ? t("otherNote") : p?.where ? t(`where_${p.where}`) : "";
|
|
1272
1286
|
};
|
|
1273
1287
|
sel.onchange = setLink;
|
|
1274
1288
|
setLink();
|
|
@@ -1361,7 +1375,7 @@ $("login-form")?.addEventListener("submit", async (e) => {
|
|
|
1361
1375
|
btn.disabled = true;
|
|
1362
1376
|
btn.textContent = t("saving");
|
|
1363
1377
|
try {
|
|
1364
|
-
await api("/api/login", { provider: $("login-provider").value, key: $("login-key").value });
|
|
1378
|
+
await api("/api/login", { provider: $("login-provider").value, key: $("login-key").value, baseUrl: $("login-url").value, model: $("login-model").value });
|
|
1365
1379
|
$("login-key").value = "";
|
|
1366
1380
|
note(t("saved"));
|
|
1367
1381
|
await openModels();
|
package/web/i18n.js
CHANGED
|
@@ -24,7 +24,7 @@ export const STRINGS = {
|
|
|
24
24
|
starbuckReport: "Write the Starbuck report to {x}?",
|
|
25
25
|
approveHint: "Nothing changes until you select Yes.",
|
|
26
26
|
zoteroChecking: "Zotero: checking",
|
|
27
|
-
zoteroDown: "Zotero is
|
|
27
|
+
zoteroDown: "Zotero is closed: search only",
|
|
28
28
|
items: "{n} items",
|
|
29
29
|
toReview: "{n} to check",
|
|
30
30
|
toReviewHint: "Items that Sub-Sub tagged and that wait for your check (they carry the _agent tag). Select Library overview, or ask Sub-Sub to list them.",
|
|
@@ -59,6 +59,10 @@ export const STRINGS = {
|
|
|
59
59
|
save: "Save",
|
|
60
60
|
getKey: "Get a key",
|
|
61
61
|
keyNote: "The key is saved on this computer, in Sub-Sub's settings folder, and is sent only to that provider.",
|
|
62
|
+
otherProvider: "Another provider (OpenAI API)",
|
|
63
|
+
serverUrl: "Server address",
|
|
64
|
+
modelId: "Model id",
|
|
65
|
+
otherNote: "A server that speaks the OpenAI API: your institution's, or a local one (Ollama, LM Studio). The key can stay empty for a local server. Your messages go to that server.",
|
|
62
66
|
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.",
|
|
63
67
|
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.",
|
|
64
68
|
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.",
|
|
@@ -167,7 +171,7 @@ export const STRINGS = {
|
|
|
167
171
|
starbuckReport: "Escrever o relatório do Starbuck em {x}?",
|
|
168
172
|
approveHint: "Nada muda até selecionar Sim.",
|
|
169
173
|
zoteroChecking: "Zotero: a verificar",
|
|
170
|
-
zoteroDown: "O Zotero
|
|
174
|
+
zoteroDown: "O Zotero está fechado: só pesquisa",
|
|
171
175
|
items: "{n} itens",
|
|
172
176
|
toReview: "{n} por verificar",
|
|
173
177
|
toReviewHint: "Itens que o Sub-Sub etiquetou e que esperam a sua verificação (têm a etiqueta _agent). Selecione Resumo da biblioteca, ou peça ao Sub-Sub para os listar.",
|
|
@@ -202,6 +206,10 @@ export const STRINGS = {
|
|
|
202
206
|
save: "Guardar",
|
|
203
207
|
getKey: "Obter uma chave",
|
|
204
208
|
keyNote: "A chave fica guardada neste computador, na pasta de definições do Sub-Sub, e só é enviada a esse fornecedor.",
|
|
209
|
+
otherProvider: "Outro fornecedor (API da OpenAI)",
|
|
210
|
+
serverUrl: "Endereço do servidor",
|
|
211
|
+
modelId: "Identificador do modelo",
|
|
212
|
+
otherNote: "Um servidor que fala a API da OpenAI: o da sua instituição, ou um servidor local (Ollama, LM Studio). Num servidor local, a chave pode ficar vazia. As suas mensagens vão para esse servidor.",
|
|
205
213
|
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.",
|
|
206
214
|
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.",
|
|
207
215
|
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.",
|
|
@@ -316,6 +324,7 @@ export const ACTIONS = {
|
|
|
316
324
|
{ cmd: "subsub", send: true, en: "Library overview", pt: "Resumo da biblioteca" },
|
|
317
325
|
{ cmd: "tag-batch 10", send: true, en: "Tag 10 items", pt: "Etiquetar 10 itens" },
|
|
318
326
|
{ cmd: "clean-tags", send: true, en: "Clean up tags", pt: "Limpar etiquetas" },
|
|
327
|
+
{ cmd: "check-library", send: true, en: "Check the library", pt: "Verificar a biblioteca" },
|
|
319
328
|
{ cmd: "import-queue", send: true, en: "Import the queue", pt: "Importar a fila" },
|
|
320
329
|
{ cmd: "history", send: true, en: "Recent changes", pt: "Alterações recentes" },
|
|
321
330
|
{ cmd: "undo", send: true, en: "Undo the last change", pt: "Desfazer a última alteração" },
|
|
@@ -341,6 +350,7 @@ export const SUGGESTIONS = {
|
|
|
341
350
|
export const TOOLS = {
|
|
342
351
|
en: {
|
|
343
352
|
find_items: "search the library",
|
|
353
|
+
list_libraries: "list the libraries",
|
|
344
354
|
get_figure: "cut a figure out of the PDF",
|
|
345
355
|
standalone_items: "list files without a parent item",
|
|
346
356
|
set_parent_items: "give files a parent item",
|
|
@@ -402,6 +412,7 @@ export const TOOLS = {
|
|
|
402
412
|
},
|
|
403
413
|
pt: {
|
|
404
414
|
find_items: "pesquisar na biblioteca",
|
|
415
|
+
list_libraries: "listar as bibliotecas",
|
|
405
416
|
get_figure: "recortar uma figura do PDF",
|
|
406
417
|
standalone_items: "listar ficheiros sem item principal",
|
|
407
418
|
set_parent_items: "dar um item principal aos ficheiros",
|
package/web/index.html
CHANGED
|
@@ -120,6 +120,12 @@
|
|
|
120
120
|
<label><span data-i18n="provider">Provider</span>
|
|
121
121
|
<select id="login-provider"></select>
|
|
122
122
|
</label>
|
|
123
|
+
<label id="login-url-row" hidden><span data-i18n="serverUrl">Server address</span>
|
|
124
|
+
<input type="url" id="login-url" autocomplete="off" spellcheck="false" placeholder="https://llm.example.org/v1">
|
|
125
|
+
</label>
|
|
126
|
+
<label id="login-model-row" hidden><span data-i18n="modelId">Model id</span>
|
|
127
|
+
<input type="text" id="login-model" autocomplete="off" spellcheck="false">
|
|
128
|
+
</label>
|
|
123
129
|
<label><span data-i18n="apiKey">API key</span>
|
|
124
130
|
<input type="password" id="login-key" autocomplete="off" spellcheck="false" required>
|
|
125
131
|
</label>
|