@tiagojct/subsub 0.11.1 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/bridge.js +5 -1
- package/dist/config.js +15 -1
- package/dist/doctor.js +7 -3
- package/dist/init.js +8 -2
- package/dist/paths.js +14 -7
- package/dist/preview.js +7 -2
- package/dist/subsub.js +41 -5
- package/dist/web.js +18 -5
- package/package.json +1 -1
- package/src/bridge.ts +4 -1
- package/src/config.ts +14 -1
- package/src/doctor.ts +6 -2
- package/src/init.ts +6 -1
- package/src/paths.ts +14 -6
- package/src/preview.ts +8 -2
- package/src/subsub.ts +34 -8
- package/src/web.ts +15 -5
- package/web/app.css +1 -0
- package/web/app.js +15 -2
- package/web/i18n.js +4 -2
package/README.md
CHANGED
|
@@ -25,8 +25,8 @@ Each mode can have its own model. Switch with `/researcher` and `/librarian`, or
|
|
|
25
25
|
Every library change works the same way in both modes:
|
|
26
26
|
|
|
27
27
|
- The Zotero server computes the change first. Sub-Sub shows it to you (added tags in green, removed tags in red) and applies it only when you select Yes. The check is in the program, so the model cannot skip it.
|
|
28
|
-
- Each change sends the item's version, so
|
|
29
|
-
- Every change is journaled. `/undo` reverts it.
|
|
28
|
+
- If you edit an item in Zotero while the preview is open, Sub-Sub shows you the new preview before it changes anything. Each change also sends the item's version, so a field you edit at the moment of writing is not overwritten.
|
|
29
|
+
- Every change to an item is journaled. `/undo` reverts it. (A new collection is not; remove it in Zotero if you do not want it.)
|
|
30
30
|
- Nothing is deleted: items go to the Zotero trash. Only tags from your tag list can be added.
|
|
31
31
|
|
|
32
32
|
## Profiles
|
package/dist/bridge.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8
8
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
9
|
+
import { refreshedArgs } from "./config.js";
|
|
9
10
|
function withTimeout(p, ms, what) {
|
|
10
11
|
return new Promise((resolve, reject) => {
|
|
11
12
|
const t = setTimeout(() => reject(new Error(`${what} did not answer within ${Math.round(ms / 1000)} s`)), ms);
|
|
@@ -56,13 +57,16 @@ export class Bridge {
|
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
catch (err) {
|
|
59
|
-
this.errors[spec.name] = `${err.message}${stderr ? `\n${stderr.trim()}` : ""}`;
|
|
60
60
|
try {
|
|
61
61
|
await client.close();
|
|
62
62
|
}
|
|
63
63
|
catch {
|
|
64
64
|
/* ignore */
|
|
65
65
|
}
|
|
66
|
+
const retry = refreshedArgs(spec.args, stderr);
|
|
67
|
+
if (retry)
|
|
68
|
+
return this.startOne({ ...spec, args: retry }, timeoutMs);
|
|
69
|
+
this.errors[spec.name] = `${err.message}${stderr ? `\n${stderr.trim()}` : ""}`;
|
|
66
70
|
}
|
|
67
71
|
}
|
|
68
72
|
has(fullName) {
|
package/dist/config.js
CHANGED
|
@@ -33,7 +33,7 @@ export const DEFAULT_MODELS = {
|
|
|
33
33
|
};
|
|
34
34
|
export const PROFILES = ["reader", "scholar", "author", "editor"];
|
|
35
35
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
36
|
-
export const SERVER_VERSION = "0.5.
|
|
36
|
+
export const SERVER_VERSION = "0.5.2";
|
|
37
37
|
/** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
|
|
38
38
|
export const STARBUCK_VERSION = "0.2.0";
|
|
39
39
|
export const STARBUCK_SOURCE = `starbuck==${STARBUCK_VERSION}`;
|
|
@@ -171,6 +171,20 @@ export function serverCommand(cfg, program, args = [], env = process.env) {
|
|
|
171
171
|
}
|
|
172
172
|
return { command: uv, args: ["tool", "run", "--quiet", "--from", `zotero-local-mcp==${SERVER_VERSION}`, program, ...args] };
|
|
173
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* uv caches the PyPI index; right after a release it may not know the pinned version yet
|
|
176
|
+
* ("there is no version of zotero-local-mcp==0.5.2"). Then the same command runs again with
|
|
177
|
+
* the index for that package refreshed. Returns undefined when a retry would not help.
|
|
178
|
+
*/
|
|
179
|
+
export function refreshedArgs(args, stderr) {
|
|
180
|
+
if (!/there is no version of|no solution found/i.test(stderr))
|
|
181
|
+
return undefined;
|
|
182
|
+
const i = args.indexOf("--from");
|
|
183
|
+
if (args[0] !== "tool" || args[1] !== "run" || i < 0 || args.includes("--refresh-package"))
|
|
184
|
+
return undefined;
|
|
185
|
+
const pkg = args[i + 1].split(/[=<>!~]/)[0];
|
|
186
|
+
return [...args.slice(0, 2), "--refresh-package", pkg, ...args.slice(2)];
|
|
187
|
+
}
|
|
174
188
|
export function starbuckEnabled(cfg) {
|
|
175
189
|
return (cfg.addons ?? []).includes("starbuck");
|
|
176
190
|
}
|
package/dist/doctor.js
CHANGED
|
@@ -10,7 +10,7 @@ import { spawnSync } from "node:child_process";
|
|
|
10
10
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
-
import { findUv, loadConfig, SERVER_VERSION, serverCommand, STARBUCK_SOURCE, starbuckCommand, starbuckEnabled, } from "./config.js";
|
|
13
|
+
import { findUv, loadConfig, SERVER_VERSION, serverCommand, STARBUCK_SOURCE, starbuckCommand, starbuckEnabled, refreshedArgs, } from "./config.js";
|
|
14
14
|
import { isObsidianVault, LEGACY_SETTINGS_DIR, SETTINGS_FILES, settingsFile } from "./layout.js";
|
|
15
15
|
import { profileSpec } from "./profiles.js";
|
|
16
16
|
export const defaultRunner = (command, args, env) => {
|
|
@@ -50,7 +50,11 @@ export function runChecks(cfg, env, deps = {}) {
|
|
|
50
50
|
out.push({ name: "uv", ok: true, detail: uvRun.stdout.trim() });
|
|
51
51
|
const cmd = serverCommand(cfg, "zotero-local-mcp", ["--check"], env);
|
|
52
52
|
const local = cmd.args.includes("--directory");
|
|
53
|
-
const
|
|
53
|
+
const serverEnv = { ...env, ...(cfg.envFile ? { ZOTERO_MCP_ENV: cfg.envFile } : {}) };
|
|
54
|
+
let res = run(cmd.command, cmd.args, serverEnv);
|
|
55
|
+
const retry = res.status !== 0 ? refreshedArgs(cmd.args, res.stderr) : undefined;
|
|
56
|
+
if (retry)
|
|
57
|
+
res = run(cmd.command, retry, serverEnv);
|
|
54
58
|
let st;
|
|
55
59
|
try {
|
|
56
60
|
st = JSON.parse(res.stdout);
|
|
@@ -106,7 +110,7 @@ export function runChecks(cfg, env, deps = {}) {
|
|
|
106
110
|
const agentDir = deps.agentDir ?? (env.SUBSUB_AGENT_DIR ? env.SUBSUB_AGENT_DIR : join(homedir(), ".subsub", "agent"));
|
|
107
111
|
out.push(hasLogin(agentDir)
|
|
108
112
|
? { name: "Model login", ok: true, detail: Object.keys(cfg.models).length ? Object.entries(cfg.models).map(([m, id]) => `${m} ${id}`).join(", ") : "pi's default model" }
|
|
109
|
-
: { name: "Model login", ok: false, detail: "no model provider yet", fix: "
|
|
113
|
+
: { name: "Model login", ok: false, detail: "no model provider yet", fix: "Open Sub-Sub (subsub web), select the model button and save an API key. In the terminal: type subsub, then /login." });
|
|
110
114
|
return out;
|
|
111
115
|
}
|
|
112
116
|
/** The Starbuck add-on: can uv start it? (`starbuck --version`, from the local folder or the pinned release) */
|
package/dist/init.js
CHANGED
|
@@ -107,6 +107,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
107
107
|
let notes = keepAsIs && answer === oldFolder ? answer : subsubFolder(answer);
|
|
108
108
|
if (notes !== answer)
|
|
109
109
|
io.say(`That is an Obsidian vault. Sub-Sub will use ${notes}.`);
|
|
110
|
+
else if (notes !== folderDefault)
|
|
111
|
+
io.say(`Sub-Sub folder: ${notes}`);
|
|
110
112
|
// ---- files of the old layout
|
|
111
113
|
const from = oldFolder && existsSync(oldFolder) ? oldFolder : notes;
|
|
112
114
|
const moves = planMoves(from, notes);
|
|
@@ -232,6 +234,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
232
234
|
if (fmup)
|
|
233
235
|
io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
|
|
234
236
|
io.say("Next: type subsub web to open Sub-Sub in your browser (or subsub for the terminal). Connect a model provider with the model button (or /login in the terminal). To check the setup, type subsub doctor.");
|
|
237
|
+
if (tags === "any-field")
|
|
238
|
+
io.say('The tag list has no topics yet. Before you tag, ask Sub-Sub: "propose topics for my tag list".');
|
|
235
239
|
return { configFile, envFile, notes, created, moved: move === "yes" ? moves.length : 0, zotero };
|
|
236
240
|
}
|
|
237
241
|
export function parseFlags(args) {
|
|
@@ -287,8 +291,10 @@ function terminalIO() {
|
|
|
287
291
|
}
|
|
288
292
|
export async function initMain(args, env = process.env) {
|
|
289
293
|
const flags = parseFlags(args);
|
|
290
|
-
if (!process.stdin.isTTY)
|
|
291
|
-
flags.yes
|
|
294
|
+
if (!process.stdin.isTTY && flags.yes === undefined) {
|
|
295
|
+
flags.yes = "true";
|
|
296
|
+
console.log("Not a terminal: Sub-Sub takes the default answers (as with --yes).");
|
|
297
|
+
}
|
|
292
298
|
const io = flags.yes ? defaultsIO((l) => console.log(l)) : terminalIO();
|
|
293
299
|
try {
|
|
294
300
|
await runInit(io, flags, env, { prepareStarbuck: (cfg) => {
|
package/dist/paths.js
CHANGED
|
@@ -46,10 +46,14 @@ export function realish(abs) {
|
|
|
46
46
|
}
|
|
47
47
|
return tail.length ? join(real, ...tail) : real;
|
|
48
48
|
}
|
|
49
|
+
/** macOS and Windows file systems ignore case: ~/.SSH is ~/.ssh. */
|
|
50
|
+
const FOLD_CASE = process.platform === "darwin" || process.platform === "win32";
|
|
49
51
|
export function within(child, parent) {
|
|
50
52
|
if (!parent)
|
|
51
53
|
return false;
|
|
52
|
-
|
|
54
|
+
// NFC: a name typed with combining accents is the same file as the precomposed one.
|
|
55
|
+
const [c, p] = [child.normalize("NFC"), parent.normalize("NFC")];
|
|
56
|
+
const rel = FOLD_CASE ? relative(p.toLowerCase(), c.toLowerCase()) : relative(p, c);
|
|
53
57
|
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
54
58
|
}
|
|
55
59
|
export function buildPolicy(opts) {
|
|
@@ -81,19 +85,22 @@ export function judgePath(target, policy) {
|
|
|
81
85
|
const hit = policy.protectedPaths.find((p) => within(target, p));
|
|
82
86
|
if (hit)
|
|
83
87
|
return { ok: false, why: `it changes a protected file or folder (${hit})` };
|
|
88
|
+
const root = policy.allowed.find((a) => within(target, a));
|
|
89
|
+
if (!root)
|
|
90
|
+
return { ok: false, why: "it is outside the vault and the working folder" };
|
|
84
91
|
// Notes never live in hidden folders; .git/hooks, .obsidian/plugins or .vscode can run code.
|
|
85
|
-
|
|
92
|
+
// Only the part inside the folder counts, so a vault under ~/.something still works.
|
|
93
|
+
const hidden = relative(root, target).split(/[\\/]/).find((part) => part.startsWith(".") && part !== "." && part !== "..");
|
|
86
94
|
if (hidden)
|
|
87
95
|
return { ok: false, why: `it is in a hidden folder or file (${hidden})` };
|
|
88
|
-
|
|
89
|
-
return { ok: true };
|
|
90
|
-
return { ok: false, why: "it is outside the vault and the working folder" };
|
|
96
|
+
return { ok: true };
|
|
91
97
|
}
|
|
92
98
|
/** Files with keys and passwords: the model may not read them, even when you ask. */
|
|
93
99
|
export function secretPaths() {
|
|
94
100
|
const home = realish(homedir());
|
|
95
101
|
return [
|
|
96
102
|
join(home, ".subsub", "agent", "auth.json"),
|
|
103
|
+
...[process.env.SUBSUB_AGENT_DIR, process.env.PI_CODING_AGENT_DIR].filter((d) => !!d).map((d) => join(realish(d.replace(/^~(?=$|[\\/])/, home)), "auth.json")),
|
|
97
104
|
join(home, ".pi", "agent", "auth.json"),
|
|
98
105
|
join(home, ".config", "zotero-local-mcp", "env"),
|
|
99
106
|
join(home, ".ssh"),
|
|
@@ -102,6 +109,6 @@ export function secretPaths() {
|
|
|
102
109
|
];
|
|
103
110
|
}
|
|
104
111
|
/** True if reading `target` would read a secret; for grep, also a folder that holds one. */
|
|
105
|
-
export function isSecret(target, recursive = false) {
|
|
106
|
-
return secretPaths().some((p) => within(target, p) || (recursive && within(p, target)));
|
|
112
|
+
export function isSecret(target, recursive = false, envFile) {
|
|
113
|
+
return [...secretPaths(), ...(envFile ? [realish(envFile)] : [])].some((p) => within(target, p) || (recursive && within(p, target)));
|
|
107
114
|
}
|
package/dist/preview.js
CHANGED
|
@@ -7,6 +7,11 @@ function s(v, n = 90) {
|
|
|
7
7
|
const t = typeof v === "string" ? v : JSON.stringify(v);
|
|
8
8
|
return t.length > n ? `${t.slice(0, n - 3)}...` : t;
|
|
9
9
|
}
|
|
10
|
+
/** What will be written: shown whole up to a large limit, and the rest is counted, never silently cut. */
|
|
11
|
+
function whole(v, n = 3000) {
|
|
12
|
+
const t = typeof v === "string" ? v : JSON.stringify(v);
|
|
13
|
+
return t.length > n ? `${t.slice(0, n)} [${t.length - n} more characters not shown]` : t;
|
|
14
|
+
}
|
|
10
15
|
function list(rows, total) {
|
|
11
16
|
const out = rows.slice(0, MAX_LINES);
|
|
12
17
|
const rest = (total ?? rows.length) - out.length;
|
|
@@ -25,7 +30,7 @@ function describeChange(c) {
|
|
|
25
30
|
continue;
|
|
26
31
|
if (v && typeof v === "object" && "after" in v) {
|
|
27
32
|
const b = v.before;
|
|
28
|
-
parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${
|
|
33
|
+
parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${whole(v.after)}`);
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || "(change)"}`;
|
|
@@ -69,7 +74,7 @@ export function formatPreview(tool, preview) {
|
|
|
69
74
|
if (p.item || p.parent)
|
|
70
75
|
lines.push(`Note on: ${s(p.item ?? p.parent, 90)}`);
|
|
71
76
|
if (p.summary || p.note_preview)
|
|
72
|
-
lines.push(
|
|
77
|
+
lines.push(whole(p.summary ?? p.note_preview));
|
|
73
78
|
if (p.link)
|
|
74
79
|
lines.push(`Link: ${p.link}`);
|
|
75
80
|
}
|
package/dist/subsub.js
CHANGED
|
@@ -245,14 +245,20 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
245
245
|
registerTools(b);
|
|
246
246
|
}
|
|
247
247
|
let restored;
|
|
248
|
-
|
|
248
|
+
// A resumed conversation keeps its model. pi records a model for every new session too,
|
|
249
|
+
// so only a session with messages counts (pi's own test for an existing session).
|
|
250
|
+
let hasModel = false;
|
|
251
|
+
let hasMessages = false;
|
|
249
252
|
for (const e of ctx.sessionManager.getBranch()) {
|
|
250
253
|
const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
|
|
251
254
|
if (m === "librarian" || m === "researcher")
|
|
252
255
|
restored = m;
|
|
253
256
|
if (e.type === "model_change")
|
|
254
|
-
|
|
257
|
+
hasModel = true;
|
|
258
|
+
if (e.type === "message")
|
|
259
|
+
hasMessages = true;
|
|
255
260
|
}
|
|
261
|
+
const modelChosen = hasModel && hasMessages;
|
|
256
262
|
// Judge light or dark before Sub-Sub changes the theme.
|
|
257
263
|
if (lookOn(ctx))
|
|
258
264
|
scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
@@ -265,6 +271,8 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
265
271
|
else if (webView) {
|
|
266
272
|
void refreshLibrary();
|
|
267
273
|
}
|
|
274
|
+
if (!ctx.model && !webView)
|
|
275
|
+
ctx.ui.notify("Sub-Sub: no model is connected yet. Type /login and select a provider (or use the model button in subsub web).", "warning");
|
|
268
276
|
for (const [name, err] of Object.entries(bridge.errors)) {
|
|
269
277
|
ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
|
|
270
278
|
}
|
|
@@ -326,7 +334,8 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
326
334
|
return { block: true, reason: why };
|
|
327
335
|
}
|
|
328
336
|
}
|
|
329
|
-
|
|
337
|
+
// grep, find and ls search the working folder when no path is given.
|
|
338
|
+
if (["read", "grep", "find", "ls"].includes(name) && isSecret(normalizeToolPath(typeof input.path === "string" ? input.path : ".", ctx.cwd), name === "grep", cfg.envFile)) {
|
|
330
339
|
return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
|
|
331
340
|
}
|
|
332
341
|
if (name.startsWith("verify_"))
|
|
@@ -353,6 +362,12 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
353
362
|
return { block: true, reason: `Library changes need ${user}'s approval, which needs an interactive session.` };
|
|
354
363
|
}
|
|
355
364
|
if (kind === "confirm") {
|
|
365
|
+
// A file the tool writes is judged like any file write: protected files stay protected.
|
|
366
|
+
if (typeof input.output_path === "string") {
|
|
367
|
+
const verdict = judgePath(normalizeToolPath(input.output_path, ctx.cwd), buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
|
|
368
|
+
if (!verdict.ok && /protected|hidden/.test(verdict.why))
|
|
369
|
+
return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
|
|
370
|
+
}
|
|
356
371
|
const ok = await ctx.ui.confirm(`Sub-Sub: run ${name}?`, describeArgs(input));
|
|
357
372
|
return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
|
|
358
373
|
}
|
|
@@ -368,8 +383,29 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
368
383
|
}
|
|
369
384
|
if (preview.isError)
|
|
370
385
|
return { block: true, reason: preview.text };
|
|
371
|
-
const
|
|
372
|
-
|
|
386
|
+
const title = `Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`;
|
|
387
|
+
let shown = formatPreview(name, preview.data ?? preview.text);
|
|
388
|
+
// The dialog may stay open for minutes. Before applying, compute the preview again: if the
|
|
389
|
+
// library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
|
|
390
|
+
for (let round = 0; round < 3; round++) {
|
|
391
|
+
const ok = await ctx.ui.confirm(round ? `${title} (the library changed; this is the new preview)` : title, paintFor(ctx)(shown));
|
|
392
|
+
if (!ok)
|
|
393
|
+
return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
|
|
394
|
+
let again;
|
|
395
|
+
try {
|
|
396
|
+
again = await current().call(name, { ...input, dry_run: true }, ctx.signal);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
return { block: true, reason: `Preview failed: ${err.message}` };
|
|
400
|
+
}
|
|
401
|
+
if (again.isError)
|
|
402
|
+
return { block: true, reason: again.text };
|
|
403
|
+
const now = formatPreview(name, again.data ?? again.text);
|
|
404
|
+
if (now === shown)
|
|
405
|
+
return undefined;
|
|
406
|
+
shown = now;
|
|
407
|
+
}
|
|
408
|
+
return { block: true, reason: "The library kept changing while the preview was open. Nothing was applied; try again." };
|
|
373
409
|
});
|
|
374
410
|
/** A literature note (front matter with a citekey) must state its evidence; see notes.ts. */
|
|
375
411
|
function literatureNoteProblem(name, target, input) {
|
package/dist/web.js
CHANGED
|
@@ -412,8 +412,12 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
412
412
|
for (const id of dialogs.keys())
|
|
413
413
|
broadcast({ type: "dialog_closed", id });
|
|
414
414
|
dialogs.clear();
|
|
415
|
-
|
|
416
|
-
|
|
415
|
+
try {
|
|
416
|
+
await agent.stop();
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
restarting = false;
|
|
420
|
+
}
|
|
417
421
|
busy = false;
|
|
418
422
|
startAgent();
|
|
419
423
|
broadcast(await snapshot());
|
|
@@ -520,7 +524,15 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
520
524
|
const message = String(body.message ?? "").trim();
|
|
521
525
|
if (!message)
|
|
522
526
|
return send(res, 400, { error: "empty message" });
|
|
523
|
-
|
|
527
|
+
try {
|
|
528
|
+
await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) });
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
// No provider yet: the page opens the model dialog instead of showing pi's text.
|
|
532
|
+
if (/no api key|no model|no models available/i.test(err.message))
|
|
533
|
+
return send(res, 409, { error: "nomodel" });
|
|
534
|
+
throw err;
|
|
535
|
+
}
|
|
524
536
|
return send(res, 200, { ok: true });
|
|
525
537
|
}
|
|
526
538
|
case "POST /api/abort":
|
|
@@ -585,9 +597,10 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
585
597
|
const models = { ...saved, [mode]: spec };
|
|
586
598
|
if (body.both)
|
|
587
599
|
models[mode === "librarian" ? "researcher" : "librarian"] = spec;
|
|
588
|
-
saveConfig({ models, model: undefined }, env);
|
|
589
600
|
return withRestart(res, async () => {
|
|
590
|
-
|
|
601
|
+
saveConfig({ models, model: undefined }, env);
|
|
602
|
+
// The open conversation records the new model, so it keeps it after the restart.
|
|
603
|
+
await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) }).catch(() => { });
|
|
591
604
|
});
|
|
592
605
|
}
|
|
593
606
|
case "POST /api/addons": {
|
package/package.json
CHANGED
package/src/bridge.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
9
9
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
10
|
+
import { refreshedArgs } from "./config.ts";
|
|
10
11
|
|
|
11
12
|
export interface ServerSpec {
|
|
12
13
|
name: string; // tool prefix: "zotero" or "scholar"
|
|
@@ -85,12 +86,14 @@ export class Bridge {
|
|
|
85
86
|
});
|
|
86
87
|
}
|
|
87
88
|
} catch (err) {
|
|
88
|
-
this.errors[spec.name] = `${(err as Error).message}${stderr ? `\n${stderr.trim()}` : ""}`;
|
|
89
89
|
try {
|
|
90
90
|
await client.close();
|
|
91
91
|
} catch {
|
|
92
92
|
/* ignore */
|
|
93
93
|
}
|
|
94
|
+
const retry = refreshedArgs(spec.args, stderr);
|
|
95
|
+
if (retry) return this.startOne({ ...spec, args: retry }, timeoutMs);
|
|
96
|
+
this.errors[spec.name] = `${(err as Error).message}${stderr ? `\n${stderr.trim()}` : ""}`;
|
|
94
97
|
}
|
|
95
98
|
}
|
|
96
99
|
|
package/src/config.ts
CHANGED
|
@@ -39,7 +39,7 @@ export type Profile = "reader" | "scholar" | "author" | "editor";
|
|
|
39
39
|
export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
|
|
40
40
|
|
|
41
41
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
42
|
-
export const SERVER_VERSION = "0.5.
|
|
42
|
+
export const SERVER_VERSION = "0.5.2";
|
|
43
43
|
|
|
44
44
|
/** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
|
|
45
45
|
export const STARBUCK_VERSION = "0.2.0";
|
|
@@ -225,6 +225,19 @@ export function serverCommand(
|
|
|
225
225
|
return { command: uv, args: ["tool", "run", "--quiet", "--from", `zotero-local-mcp==${SERVER_VERSION}`, program, ...args] };
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
/**
|
|
229
|
+
* uv caches the PyPI index; right after a release it may not know the pinned version yet
|
|
230
|
+
* ("there is no version of zotero-local-mcp==0.5.2"). Then the same command runs again with
|
|
231
|
+
* the index for that package refreshed. Returns undefined when a retry would not help.
|
|
232
|
+
*/
|
|
233
|
+
export function refreshedArgs(args: string[], stderr: string): string[] | undefined {
|
|
234
|
+
if (!/there is no version of|no solution found/i.test(stderr)) return undefined;
|
|
235
|
+
const i = args.indexOf("--from");
|
|
236
|
+
if (args[0] !== "tool" || args[1] !== "run" || i < 0 || args.includes("--refresh-package")) return undefined;
|
|
237
|
+
const pkg = args[i + 1].split(/[=<>!~]/)[0];
|
|
238
|
+
return [...args.slice(0, 2), "--refresh-package", pkg, ...args.slice(2)];
|
|
239
|
+
}
|
|
240
|
+
|
|
228
241
|
export function starbuckEnabled(cfg: Pick<SubsubConfig, "addons">): boolean {
|
|
229
242
|
return (cfg.addons ?? []).includes("starbuck");
|
|
230
243
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
starbuckCommand,
|
|
21
21
|
starbuckEnabled,
|
|
22
22
|
type SubsubConfig,
|
|
23
|
+
refreshedArgs,
|
|
23
24
|
} from "./config.ts";
|
|
24
25
|
import { isObsidianVault, LEGACY_SETTINGS_DIR, SETTINGS_FILES, settingsFile } from "./layout.ts";
|
|
25
26
|
import { profileSpec } from "./profiles.ts";
|
|
@@ -92,7 +93,10 @@ export function runChecks(
|
|
|
92
93
|
|
|
93
94
|
const cmd = serverCommand(cfg, "zotero-local-mcp", ["--check"], env);
|
|
94
95
|
const local = cmd.args.includes("--directory");
|
|
95
|
-
const
|
|
96
|
+
const serverEnv = { ...env, ...(cfg.envFile ? { ZOTERO_MCP_ENV: cfg.envFile } : {}) };
|
|
97
|
+
let res = run(cmd.command, cmd.args, serverEnv);
|
|
98
|
+
const retry = res.status !== 0 ? refreshedArgs(cmd.args, res.stderr) : undefined;
|
|
99
|
+
if (retry) res = run(cmd.command, retry, serverEnv);
|
|
96
100
|
let st: Record<string, any> | undefined;
|
|
97
101
|
try {
|
|
98
102
|
st = JSON.parse(res.stdout);
|
|
@@ -150,7 +154,7 @@ export function runChecks(
|
|
|
150
154
|
out.push(
|
|
151
155
|
hasLogin(agentDir)
|
|
152
156
|
? { name: "Model login", ok: true, detail: Object.keys(cfg.models).length ? Object.entries(cfg.models).map(([m, id]) => `${m} ${id}`).join(", ") : "pi's default model" }
|
|
153
|
-
: { name: "Model login", ok: false, detail: "no model provider yet", fix: "
|
|
157
|
+
: { name: "Model login", ok: false, detail: "no model provider yet", fix: "Open Sub-Sub (subsub web), select the model button and save an API key. In the terminal: type subsub, then /login." },
|
|
154
158
|
);
|
|
155
159
|
return out;
|
|
156
160
|
}
|
package/src/init.ts
CHANGED
|
@@ -155,6 +155,7 @@ export async function runInit(
|
|
|
155
155
|
const answer = expand(cleanPath(await io.ask("Sub-Sub folder (or an Obsidian vault)", folderDefault)) || folderDefault, home);
|
|
156
156
|
let notes = keepAsIs && answer === oldFolder ? answer : subsubFolder(answer);
|
|
157
157
|
if (notes !== answer) io.say(`That is an Obsidian vault. Sub-Sub will use ${notes}.`);
|
|
158
|
+
else if (notes !== folderDefault) io.say(`Sub-Sub folder: ${notes}`);
|
|
158
159
|
|
|
159
160
|
// ---- files of the old layout
|
|
160
161
|
const from = oldFolder && existsSync(oldFolder) ? oldFolder : notes;
|
|
@@ -301,6 +302,7 @@ export async function runInit(
|
|
|
301
302
|
if (starbuck === "on") io.say(starbuckNote || "Reference checks: on. The first start downloads Starbuck.");
|
|
302
303
|
if (fmup) io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
|
|
303
304
|
io.say("Next: type subsub web to open Sub-Sub in your browser (or subsub for the terminal). Connect a model provider with the model button (or /login in the terminal). To check the setup, type subsub doctor.");
|
|
305
|
+
if (tags === "any-field") io.say('The tag list has no topics yet. Before you tag, ask Sub-Sub: "propose topics for my tag list".');
|
|
304
306
|
return { configFile, envFile, notes, created, moved: move === "yes" ? moves.length : 0, zotero };
|
|
305
307
|
}
|
|
306
308
|
|
|
@@ -354,7 +356,10 @@ function terminalIO(): InitIO & { close(): void } {
|
|
|
354
356
|
|
|
355
357
|
export async function initMain(args: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
356
358
|
const flags = parseFlags(args);
|
|
357
|
-
if (!process.stdin.isTTY
|
|
359
|
+
if (!process.stdin.isTTY && flags.yes === undefined) {
|
|
360
|
+
flags.yes = "true";
|
|
361
|
+
console.log("Not a terminal: Sub-Sub takes the default answers (as with --yes).");
|
|
362
|
+
}
|
|
358
363
|
const io = flags.yes ? defaultsIO((l) => console.log(l)) : terminalIO();
|
|
359
364
|
try {
|
|
360
365
|
await runInit(io, flags, env, { prepareStarbuck: (cfg) => {
|
package/src/paths.ts
CHANGED
|
@@ -45,9 +45,14 @@ export function realish(abs: string): string {
|
|
|
45
45
|
return tail.length ? join(real, ...tail) : real;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** macOS and Windows file systems ignore case: ~/.SSH is ~/.ssh. */
|
|
49
|
+
const FOLD_CASE = process.platform === "darwin" || process.platform === "win32";
|
|
50
|
+
|
|
48
51
|
export function within(child: string, parent: string | undefined): boolean {
|
|
49
52
|
if (!parent) return false;
|
|
50
|
-
|
|
53
|
+
// NFC: a name typed with combining accents is the same file as the precomposed one.
|
|
54
|
+
const [c, p] = [child.normalize("NFC"), parent.normalize("NFC")];
|
|
55
|
+
const rel = FOLD_CASE ? relative(p.toLowerCase(), c.toLowerCase()) : relative(p, c);
|
|
51
56
|
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
52
57
|
}
|
|
53
58
|
|
|
@@ -92,11 +97,13 @@ export type PathVerdict = { ok: true } | { ok: false; why: string };
|
|
|
92
97
|
export function judgePath(target: string, policy: PathPolicy): PathVerdict {
|
|
93
98
|
const hit = policy.protectedPaths.find((p) => within(target, p));
|
|
94
99
|
if (hit) return { ok: false, why: `it changes a protected file or folder (${hit})` };
|
|
100
|
+
const root = policy.allowed.find((a) => within(target, a));
|
|
101
|
+
if (!root) return { ok: false, why: "it is outside the vault and the working folder" };
|
|
95
102
|
// Notes never live in hidden folders; .git/hooks, .obsidian/plugins or .vscode can run code.
|
|
96
|
-
|
|
103
|
+
// Only the part inside the folder counts, so a vault under ~/.something still works.
|
|
104
|
+
const hidden = relative(root, target).split(/[\\/]/).find((part) => part.startsWith(".") && part !== "." && part !== "..");
|
|
97
105
|
if (hidden) return { ok: false, why: `it is in a hidden folder or file (${hidden})` };
|
|
98
|
-
|
|
99
|
-
return { ok: false, why: "it is outside the vault and the working folder" };
|
|
106
|
+
return { ok: true };
|
|
100
107
|
}
|
|
101
108
|
|
|
102
109
|
/** Files with keys and passwords: the model may not read them, even when you ask. */
|
|
@@ -104,6 +111,7 @@ export function secretPaths(): string[] {
|
|
|
104
111
|
const home = realish(homedir());
|
|
105
112
|
return [
|
|
106
113
|
join(home, ".subsub", "agent", "auth.json"),
|
|
114
|
+
...[process.env.SUBSUB_AGENT_DIR, process.env.PI_CODING_AGENT_DIR].filter((d): d is string => !!d).map((d) => join(realish(d.replace(/^~(?=$|[\\/])/, home)), "auth.json")),
|
|
107
115
|
join(home, ".pi", "agent", "auth.json"),
|
|
108
116
|
join(home, ".config", "zotero-local-mcp", "env"),
|
|
109
117
|
join(home, ".ssh"),
|
|
@@ -113,6 +121,6 @@ export function secretPaths(): string[] {
|
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
/** True if reading `target` would read a secret; for grep, also a folder that holds one. */
|
|
116
|
-
export function isSecret(target: string, recursive = false): boolean {
|
|
117
|
-
return secretPaths().some((p) => within(target, p) || (recursive && within(p, target)));
|
|
124
|
+
export function isSecret(target: string, recursive = false, envFile?: string): boolean {
|
|
125
|
+
return [...secretPaths(), ...(envFile ? [realish(envFile)] : [])].some((p) => within(target, p) || (recursive && within(p, target)));
|
|
118
126
|
}
|
package/src/preview.ts
CHANGED
|
@@ -12,6 +12,12 @@ function s(v: unknown, n = 90): string {
|
|
|
12
12
|
return t.length > n ? `${t.slice(0, n - 3)}...` : t;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** What will be written: shown whole up to a large limit, and the rest is counted, never silently cut. */
|
|
16
|
+
function whole(v: unknown, n = 3000): string {
|
|
17
|
+
const t = typeof v === "string" ? v : JSON.stringify(v);
|
|
18
|
+
return t.length > n ? `${t.slice(0, n)} [${t.length - n} more characters not shown]` : t;
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
function list(rows: string[], total?: number): string[] {
|
|
16
22
|
const out = rows.slice(0, MAX_LINES);
|
|
17
23
|
const rest = (total ?? rows.length) - out.length;
|
|
@@ -27,7 +33,7 @@ function describeChange(c: Obj): string {
|
|
|
27
33
|
if (["key", "item", "added", "removed", "tags_after"].includes(k)) continue;
|
|
28
34
|
if (v && typeof v === "object" && "after" in (v as Obj)) {
|
|
29
35
|
const b = (v as Obj).before;
|
|
30
|
-
parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${
|
|
36
|
+
parts.push(`${k}: ${s(b || "(empty)", 40)} -> ${whole((v as Obj).after)}`);
|
|
31
37
|
}
|
|
32
38
|
}
|
|
33
39
|
return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || "(change)"}`;
|
|
@@ -83,7 +89,7 @@ export function formatPreview(tool: string, preview: unknown): string {
|
|
|
83
89
|
}
|
|
84
90
|
if (tool.endsWith("attach_note") || tool.endsWith("create_note")) {
|
|
85
91
|
if (p.item || p.parent) lines.push(`Note on: ${s(p.item ?? p.parent, 90)}`);
|
|
86
|
-
if (p.summary || p.note_preview) lines.push(
|
|
92
|
+
if (p.summary || p.note_preview) lines.push(whole(p.summary ?? p.note_preview));
|
|
87
93
|
if (p.link) lines.push(`Link: ${p.link}`);
|
|
88
94
|
}
|
|
89
95
|
const skipped = p.skipped as Obj | undefined;
|
package/src/subsub.ts
CHANGED
|
@@ -276,12 +276,17 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
276
276
|
registerTools(b);
|
|
277
277
|
}
|
|
278
278
|
let restored: Mode | undefined;
|
|
279
|
-
|
|
279
|
+
// A resumed conversation keeps its model. pi records a model for every new session too,
|
|
280
|
+
// so only a session with messages counts (pi's own test for an existing session).
|
|
281
|
+
let hasModel = false;
|
|
282
|
+
let hasMessages = false;
|
|
280
283
|
for (const e of ctx.sessionManager.getBranch() as Array<{ type?: string; customType?: string; data?: { mode?: unknown } }>) {
|
|
281
284
|
const m = e.customType === MODE_ENTRY ? e.data?.mode : undefined;
|
|
282
285
|
if (m === "librarian" || m === "researcher") restored = m;
|
|
283
|
-
if (e.type === "model_change")
|
|
286
|
+
if (e.type === "model_change") hasModel = true;
|
|
287
|
+
if (e.type === "message") hasMessages = true;
|
|
284
288
|
}
|
|
289
|
+
const modelChosen = hasModel && hasMessages;
|
|
285
290
|
// Judge light or dark before Sub-Sub changes the theme.
|
|
286
291
|
if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
287
292
|
applyTheme(ctx);
|
|
@@ -292,6 +297,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
292
297
|
} else if (webView) {
|
|
293
298
|
void refreshLibrary();
|
|
294
299
|
}
|
|
300
|
+
if (!ctx.model && !webView) ctx.ui.notify("Sub-Sub: no model is connected yet. Type /login and select a provider (or use the model button in subsub web).", "warning");
|
|
295
301
|
for (const [name, err] of Object.entries(bridge.errors)) {
|
|
296
302
|
ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
|
|
297
303
|
}
|
|
@@ -355,7 +361,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
355
361
|
return { block: true, reason: why };
|
|
356
362
|
}
|
|
357
363
|
}
|
|
358
|
-
|
|
364
|
+
// grep, find and ls search the working folder when no path is given.
|
|
365
|
+
if (["read", "grep", "find", "ls"].includes(name) && isSecret(normalizeToolPath(typeof input.path === "string" ? input.path : ".", ctx.cwd), name === "grep", cfg.envFile)) {
|
|
359
366
|
return { block: true, reason: "That file holds keys or passwords. Sub-Sub does not read it." };
|
|
360
367
|
}
|
|
361
368
|
if (name.startsWith("verify_")) return verifyGate(name, input, ctx);
|
|
@@ -376,6 +383,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
376
383
|
return { block: true, reason: `Library changes need ${user}'s approval, which needs an interactive session.` };
|
|
377
384
|
}
|
|
378
385
|
if (kind === "confirm") {
|
|
386
|
+
// A file the tool writes is judged like any file write: protected files stay protected.
|
|
387
|
+
if (typeof input.output_path === "string") {
|
|
388
|
+
const verdict = judgePath(normalizeToolPath(input.output_path, ctx.cwd), buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
|
|
389
|
+
if (!verdict.ok && /protected|hidden/.test(verdict.why)) return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
|
|
390
|
+
}
|
|
379
391
|
const ok = await ctx.ui.confirm(`Sub-Sub: run ${name}?`, describeArgs(input));
|
|
380
392
|
return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
|
|
381
393
|
}
|
|
@@ -389,11 +401,25 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
389
401
|
return { block: true, reason: `Preview failed: ${(err as Error).message}` };
|
|
390
402
|
}
|
|
391
403
|
if (preview.isError) return { block: true, reason: preview.text };
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
)
|
|
396
|
-
|
|
404
|
+
const title = `Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`;
|
|
405
|
+
let shown = formatPreview(name, preview.data ?? preview.text);
|
|
406
|
+
// The dialog may stay open for minutes. Before applying, compute the preview again: if the
|
|
407
|
+
// library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
|
|
408
|
+
for (let round = 0; round < 3; round++) {
|
|
409
|
+
const ok = await ctx.ui.confirm(round ? `${title} (the library changed; this is the new preview)` : title, paintFor(ctx)(shown));
|
|
410
|
+
if (!ok) return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
|
|
411
|
+
let again;
|
|
412
|
+
try {
|
|
413
|
+
again = await current().call(name, { ...input, dry_run: true }, ctx.signal);
|
|
414
|
+
} catch (err) {
|
|
415
|
+
return { block: true, reason: `Preview failed: ${(err as Error).message}` };
|
|
416
|
+
}
|
|
417
|
+
if (again.isError) return { block: true, reason: again.text };
|
|
418
|
+
const now = formatPreview(name, again.data ?? again.text);
|
|
419
|
+
if (now === shown) return undefined;
|
|
420
|
+
shown = now;
|
|
421
|
+
}
|
|
422
|
+
return { block: true, reason: "The library kept changing while the preview was open. Nothing was applied; try again." };
|
|
397
423
|
});
|
|
398
424
|
|
|
399
425
|
/** A literature note (front matter with a citekey) must state its evidence; see notes.ts. */
|
package/src/web.ts
CHANGED
|
@@ -444,8 +444,11 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
444
444
|
restarting = true;
|
|
445
445
|
for (const id of dialogs.keys()) broadcast({ type: "dialog_closed", id });
|
|
446
446
|
dialogs.clear();
|
|
447
|
-
|
|
448
|
-
|
|
447
|
+
try {
|
|
448
|
+
await agent.stop();
|
|
449
|
+
} finally {
|
|
450
|
+
restarting = false;
|
|
451
|
+
}
|
|
449
452
|
busy = false;
|
|
450
453
|
startAgent();
|
|
451
454
|
broadcast(await snapshot());
|
|
@@ -547,7 +550,13 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
547
550
|
const body = await readJson(req);
|
|
548
551
|
const message = String(body.message ?? "").trim();
|
|
549
552
|
if (!message) return send(res, 400, { error: "empty message" });
|
|
550
|
-
|
|
553
|
+
try {
|
|
554
|
+
await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) });
|
|
555
|
+
} catch (err) {
|
|
556
|
+
// No provider yet: the page opens the model dialog instead of showing pi's text.
|
|
557
|
+
if (/no api key|no model|no models available/i.test((err as Error).message)) return send(res, 409, { error: "nomodel" });
|
|
558
|
+
throw err;
|
|
559
|
+
}
|
|
551
560
|
return send(res, 200, { ok: true });
|
|
552
561
|
}
|
|
553
562
|
case "POST /api/abort":
|
|
@@ -601,9 +610,10 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
601
610
|
const saved = raw.models && typeof raw.models === "object" ? (raw.models as Record<string, string>) : typeof raw.model === "string" && raw.model ? { librarian: raw.model, researcher: raw.model } : {};
|
|
602
611
|
const models: Record<string, string> = { ...saved, [mode]: spec };
|
|
603
612
|
if (body.both) models[mode === "librarian" ? "researcher" : "librarian"] = spec;
|
|
604
|
-
saveConfig({ models, model: undefined }, env);
|
|
605
613
|
return withRestart(res, async () => {
|
|
606
|
-
|
|
614
|
+
saveConfig({ models, model: undefined }, env);
|
|
615
|
+
// The open conversation records the new model, so it keeps it after the restart.
|
|
616
|
+
await agent.request({ type: "set_model", provider: String(body.provider), modelId: String(body.id) }).catch(() => {});
|
|
607
617
|
});
|
|
608
618
|
}
|
|
609
619
|
case "POST /api/addons": {
|
package/web/app.css
CHANGED
|
@@ -158,6 +158,7 @@ button:disabled { opacity: 0.45; cursor: default; }
|
|
|
158
158
|
.icon-btn:hover { color: var(--text); background: var(--surface); }
|
|
159
159
|
.icon-btn[aria-pressed="true"] { color: var(--accent); background: var(--accent-soft); }
|
|
160
160
|
|
|
161
|
+
.model-btn.needs { color: var(--warn); border-color: var(--warn); font-family: var(--sans); }
|
|
161
162
|
.chip-btn {
|
|
162
163
|
max-width: 16rem;
|
|
163
164
|
overflow: hidden;
|
package/web/app.js
CHANGED
|
@@ -183,7 +183,9 @@ function renderHeader() {
|
|
|
183
183
|
$("starbuck-toggle").checked = Boolean(state?.starbuck);
|
|
184
184
|
$("starbuck-label").title = t("starbuckHint");
|
|
185
185
|
const m = state?.model ?? session?.model;
|
|
186
|
-
|
|
186
|
+
const none = !m || !m.id || m.id === "unknown";
|
|
187
|
+
$("model-btn").textContent = $("model-btn-side").textContent = none ? t("noModel") : m.id;
|
|
188
|
+
for (const b of [$("model-btn"), $("model-btn-side")]) b.classList.toggle("needs", none);
|
|
187
189
|
const lib = $("library");
|
|
188
190
|
const txt = lib.querySelector(".library-text") ?? lib;
|
|
189
191
|
const L = state?.library;
|
|
@@ -862,7 +864,8 @@ function nextDialog() {
|
|
|
862
864
|
dlg.onkeydown = (e) => {
|
|
863
865
|
if (d.method === "confirm") {
|
|
864
866
|
if (e.repeat || performance.now() - openedAt < 700) {
|
|
865
|
-
|
|
867
|
+
// Escape still declines (through oncancel); Enter and Space would press the focused button.
|
|
868
|
+
if (e.key !== "Escape" && e.key !== "Tab") e.preventDefault();
|
|
866
869
|
return;
|
|
867
870
|
}
|
|
868
871
|
if (yesKeys.includes(e.key) || (e.key === "Enter" && (e.metaKey || e.ctrlKey))) {
|
|
@@ -981,6 +984,16 @@ async function sendMessage(text) {
|
|
|
981
984
|
return true;
|
|
982
985
|
} catch (err) {
|
|
983
986
|
if (isTemplate) sentTemplates.pop();
|
|
987
|
+
if (err.message === "nomodel") {
|
|
988
|
+
// Keep what the user wrote, and show where to connect a provider.
|
|
989
|
+
if (!input.value.trim()) {
|
|
990
|
+
input.value = message;
|
|
991
|
+
autosize();
|
|
992
|
+
}
|
|
993
|
+
note(t("noModelSend"), "warning");
|
|
994
|
+
showModels();
|
|
995
|
+
return false;
|
|
996
|
+
}
|
|
984
997
|
note(err.message, "error");
|
|
985
998
|
return false;
|
|
986
999
|
}
|
package/web/i18n.js
CHANGED
|
@@ -5,7 +5,8 @@ export const STRINGS = {
|
|
|
5
5
|
en: {
|
|
6
6
|
profile: "Profile",
|
|
7
7
|
model: "Model",
|
|
8
|
-
noModel: "
|
|
8
|
+
noModel: "Connect a model",
|
|
9
|
+
noModelSend: "No model is connected yet. Choose a provider and save its key; your message is still in the box.",
|
|
9
10
|
newConversation: "New conversation",
|
|
10
11
|
actions: "Actions",
|
|
11
12
|
conversations: "Conversations",
|
|
@@ -97,7 +98,8 @@ export const STRINGS = {
|
|
|
97
98
|
pt: {
|
|
98
99
|
profile: "Perfil",
|
|
99
100
|
model: "Modelo",
|
|
100
|
-
noModel: "
|
|
101
|
+
noModel: "Ligar um modelo",
|
|
102
|
+
noModelSend: "Ainda não há um modelo ligado. Escolha um fornecedor e guarde a chave; a sua mensagem continua na caixa.",
|
|
101
103
|
newConversation: "Nova conversa",
|
|
102
104
|
actions: "Ações",
|
|
103
105
|
conversations: "Conversas",
|