@tiagojct/subsub 0.11.3 → 0.11.4
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/bench.js +7 -2
- package/dist/config.js +16 -0
- package/dist/init.js +27 -2
- package/dist/keys.js +55 -0
- package/dist/subsub.js +10 -0
- package/dist/web.js +4 -33
- package/package.json +1 -1
- package/src/bench.ts +7 -2
- package/src/config.ts +16 -0
- package/src/init.ts +26 -1
- package/src/keys.ts +55 -0
- package/src/subsub.ts +9 -0
- package/src/web.ts +4 -31
- package/web/app.css +13 -0
- package/web/app.js +86 -22
- package/web/i18n.js +32 -0
- package/web/index.html +2 -0
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ The [Profiles page](https://subsub.tiagojacinto.eu/profiles/) lists the tools an
|
|
|
46
46
|
|
|
47
47
|
- Zotero 10 or later, running, with Settings > Advanced > "Allow other applications on this computer to communicate with Zotero" on.
|
|
48
48
|
- Node.js 22.19 or later, and [uv](https://docs.astral.sh/uv/). The installers add both if they are missing.
|
|
49
|
-
- An account with a model provider that pi supports (for example OpenCode Go, OpenRouter, Anthropic, OpenAI, Mistral, Google), or a local model.
|
|
49
|
+
- An account with a model provider that pi supports (for example OpenCode Go, OpenRouter, Anthropic, OpenAI, Mistral, Google), or a local model. To try Sub-Sub without paying, a free Google AI Studio key is enough (see [Use Sub-Sub for free](https://subsub.tiagojacinto.eu/models/#use-sub-sub-for-free)).
|
|
50
50
|
- macOS, Linux or Windows.
|
|
51
51
|
|
|
52
52
|
## Install
|
|
@@ -133,7 +133,7 @@ subsub review apply 13-31
|
|
|
133
133
|
| `userName`, `about` | Your name and one line about you, for the replies | none |
|
|
134
134
|
| `language` | Language of replies and notes, or `auto` for the language you write in | `English` |
|
|
135
135
|
| `vault` | The Sub-Sub folder | from the server settings |
|
|
136
|
-
| `models` | The model for each mode, as provider/id: `{"librarian": "...", "researcher": "..."}`. `{}` means you choose in Sub-Sub | `opencode-go/mimo-v2.6-flash` and `opencode-go/mimo-v2.6-pro` |
|
|
136
|
+
| `models` | The model for each mode, as provider/id: `{"librarian": "...", "researcher": "..."}`. `{}` means you choose in Sub-Sub. The free choice in `subsub init` sets `google/gemini-3.1-flash-lite` for both | `opencode-go/mimo-v2.6-flash` and `opencode-go/mimo-v2.6-pro` |
|
|
137
137
|
| `defaultMode` | `researcher` or `librarian` | `researcher` |
|
|
138
138
|
| `addons` | `["starbuck"]` adds reference checks | `[]` |
|
|
139
139
|
| `serverDir` | A local copy of zotero-local-mcp, for development | the PyPI release |
|
package/dist/bench.js
CHANGED
|
@@ -80,7 +80,10 @@ export function promptFor(task, t, code, out) {
|
|
|
80
80
|
const KEEP = new Set(["tool_execution_start", "tool_execution_end", "message_end", "entry_appended", "agent_end", "agent_settled", "auto_retry_start", "extension_error"]);
|
|
81
81
|
export function runOne(opts) {
|
|
82
82
|
mkdirSync(opts.out, { recursive: true });
|
|
83
|
-
|
|
83
|
+
// "google/gemini-x" or "openrouter/vendor/model:free" name their provider; a bare name is OpenCode Go.
|
|
84
|
+
const slash = opts.model.indexOf("/");
|
|
85
|
+
const [provider, model] = slash > 0 && !opts.provider ? [opts.model.slice(0, slash), opts.model.slice(slash + 1)] : [opts.provider ?? "opencode-go", opts.model];
|
|
86
|
+
const args = ["--mode", "rpc", "--no-session", "--provider", provider, "--model", model];
|
|
84
87
|
if (opts.role === "librarian")
|
|
85
88
|
args.push("--librarian");
|
|
86
89
|
const env = { ...process.env, ...opts.extraEnv, SUBSUB_BENCH_OUT: opts.out, SUBSUB_CONFIG: opts.configFile };
|
|
@@ -558,7 +561,9 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
558
561
|
if (!prompt)
|
|
559
562
|
return;
|
|
560
563
|
const cf = join(cfgDir, `${j.code}.json`);
|
|
561
|
-
|
|
564
|
+
// A bare name is an OpenCode Go model; provider/id (google/gemini-..., openrouter/x/y:free) is any provider.
|
|
565
|
+
const spec = j.model.includes("/") ? j.model : `opencode-go/${j.model}`;
|
|
566
|
+
writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: spec, researcher: spec }, model: undefined, libraryChanges: undefined }));
|
|
562
567
|
const s = await runOne({ ...j, prompt, out, cwd: cfg.vault, configFile: cf, timeoutSec });
|
|
563
568
|
n++;
|
|
564
569
|
console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
|
package/dist/config.js
CHANGED
|
@@ -31,6 +31,22 @@ export const DEFAULT_MODELS = {
|
|
|
31
31
|
librarian: "opencode-go/mimo-v2.6-flash",
|
|
32
32
|
researcher: "opencode-go/mimo-v2.6-pro",
|
|
33
33
|
};
|
|
34
|
+
/** The tested free models (Google AI Studio free tier; see the Models page): enough to try Sub-Sub. */
|
|
35
|
+
export const FREE_MODELS = {
|
|
36
|
+
librarian: "google/gemini-3.1-flash-lite",
|
|
37
|
+
researcher: "google/gemini-3.1-flash-lite",
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Models marked in the model dialog, from the model tests (see the Models page). `why` is a
|
|
41
|
+
* key into the web view's strings: tested (a default), cheaper (tested, same quality, lower cost),
|
|
42
|
+
* free (tested, works on a free tier).
|
|
43
|
+
*/
|
|
44
|
+
export const RECOMMENDED_MODELS = [
|
|
45
|
+
{ spec: DEFAULT_MODELS.librarian, modes: ["librarian"], why: "tested" },
|
|
46
|
+
{ spec: DEFAULT_MODELS.researcher, modes: ["researcher"], why: "tested" },
|
|
47
|
+
{ spec: "opencode-go/qwen3.8-flash", modes: ["researcher"], why: "cheaper" },
|
|
48
|
+
{ spec: FREE_MODELS.librarian, modes: ["librarian", "researcher"], why: "free" },
|
|
49
|
+
];
|
|
34
50
|
export const PROFILES = ["reader", "scholar", "author", "editor"];
|
|
35
51
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
36
52
|
export const SERVER_VERSION = "0.5.3";
|
package/dist/init.js
CHANGED
|
@@ -19,16 +19,17 @@
|
|
|
19
19
|
* Flags (for scripts and tests): --yes (take every default), --setup
|
|
20
20
|
* standard|fmup, --name, --about, --language, --folder (or --notes), --move
|
|
21
21
|
* yes|no, --profile, --tags health-sciences|health-informatics|any-field,
|
|
22
|
-
* --email, --starbuck on|off, --models keep|later|opencode-go.
|
|
22
|
+
* --email, --starbuck on|off, --models keep|later|opencode-go|google-free.
|
|
23
23
|
*/
|
|
24
24
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
26
|
import { dirname, join, resolve } from "node:path";
|
|
27
27
|
import { createInterface } from "node:readline/promises";
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
29
|
-
import { configPath, DEFAULT_MODELS, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig, starbuckEnabled, withStarbuck, } from "./config.js";
|
|
29
|
+
import { configPath, DEFAULT_MODELS, FREE_MODELS, expand, isProfile, loadConfig, parseEnvFile, PROFILES, readConfigFile, saveConfig, starbuckEnabled, withStarbuck, } from "./config.js";
|
|
30
30
|
import { defaultRunner, starbuckCheck } from "./doctor.js";
|
|
31
31
|
import { applyMoves, FOLDER_NAME, leftBehind, NOTE_DIRS, planMoves, SETTINGS_DIR, SETTINGS_FILES, subsubFolder } from "./layout.js";
|
|
32
|
+
import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
|
|
32
33
|
import { PROFILE_SPECS } from "./profiles.js";
|
|
33
34
|
const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
|
|
34
35
|
export const STARTER_TAGS = {
|
|
@@ -166,10 +167,17 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
166
167
|
...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
|
|
167
168
|
{ value: "later", label: "Choose later in Sub-Sub" },
|
|
168
169
|
{ value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
|
|
170
|
+
{ value: "google-free", label: `Free, to try Sub-Sub: Google AI Studio, ${FREE_MODELS.researcher} in both modes (a free key, no card; limits per minute and per day; 18 or older)` },
|
|
169
171
|
];
|
|
170
172
|
const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
|
|
171
173
|
const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
|
|
172
174
|
const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
|
|
175
|
+
let googleKey = "";
|
|
176
|
+
if (models === "google-free" && !hasProvider(agentDir, "google")) {
|
|
177
|
+
const url = KEY_PROVIDERS.find((p) => p.id === "google")?.url;
|
|
178
|
+
explain("Free Google key", `Open ${url}, sign in with a Google account, and select "Create API key". Copy the key and paste it here. Sub-Sub saves it in ${join(agentDir, "auth.json")}, readable only by you. In the EU, the UK and Switzerland, Google does not use your prompts to improve its products; elsewhere, on the free tier, it may. When a limit is reached, Sub-Sub says so.`);
|
|
179
|
+
googleKey = (await io.ask("Google API key (Enter to add it later with the model button or /login)", "")).trim();
|
|
180
|
+
}
|
|
173
181
|
// ---- write
|
|
174
182
|
if (move === "yes")
|
|
175
183
|
applyMoves(moves);
|
|
@@ -215,7 +223,22 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
215
223
|
patch.models = {};
|
|
216
224
|
if (models === "opencode-go")
|
|
217
225
|
patch.models = TESTED_MODELS;
|
|
226
|
+
if (models === "google-free")
|
|
227
|
+
patch.models = FREE_MODELS;
|
|
218
228
|
saveConfig(patch, initEnv);
|
|
229
|
+
let keyNote = "";
|
|
230
|
+
if (googleKey) {
|
|
231
|
+
try {
|
|
232
|
+
saveApiKey(agentDir, "google", googleKey);
|
|
233
|
+
keyNote = `Saved: the Google key, in ${join(agentDir, "auth.json")}`;
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
keyNote = `The Google key was not saved (${err.message}). Add it in Sub-Sub with the model button or /login.`;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else if (models === "google-free" && !hasProvider(agentDir, "google")) {
|
|
240
|
+
keyNote = "No Google key yet. Add it in Sub-Sub with the model button (subsub web) or /login (in the terminal), with the provider Google Gemini.";
|
|
241
|
+
}
|
|
219
242
|
const zotero = await zoteroReachable(deps.fetch);
|
|
220
243
|
const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
|
|
221
244
|
io.say("");
|
|
@@ -223,6 +246,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
223
246
|
io.say(`Saved: ${envFile}`);
|
|
224
247
|
for (const c of created)
|
|
225
248
|
io.say(`Created: ${c}`);
|
|
249
|
+
if (keyNote)
|
|
250
|
+
io.say(keyNote);
|
|
226
251
|
if (move === "yes")
|
|
227
252
|
io.say(`Moved: ${moves.length} file(s) into ${notes}`);
|
|
228
253
|
for (const d of leftBehind(from, notes))
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Model providers that take an API key, and plain words for their common errors. */
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export const KEY_PROVIDERS = [
|
|
5
|
+
{ id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
|
|
6
|
+
{ id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
|
|
7
|
+
{ id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
|
|
8
|
+
{ id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
|
|
9
|
+
{ id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
|
|
10
|
+
{ id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
|
|
11
|
+
{ id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
|
|
12
|
+
];
|
|
13
|
+
/** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
|
|
14
|
+
export function saveApiKey(agentDir, provider, key) {
|
|
15
|
+
if (!KEY_PROVIDERS.some((p) => p.id === provider))
|
|
16
|
+
throw new Error(`Unknown provider: ${provider}`);
|
|
17
|
+
const clean = key.trim();
|
|
18
|
+
if (!clean || /\s/.test(clean) || clean.length > 400)
|
|
19
|
+
throw new Error("That does not look like an API key.");
|
|
20
|
+
mkdirSync(agentDir, { recursive: true });
|
|
21
|
+
const file = join(agentDir, "auth.json");
|
|
22
|
+
let data = {};
|
|
23
|
+
if (existsSync(file)) {
|
|
24
|
+
try {
|
|
25
|
+
data = JSON.parse(readFileSync(file, "utf8")) ?? {};
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
data[provider] = { type: "api_key", key: clean };
|
|
32
|
+
// Write through a symlink (a shared pi auth.json) rather than replacing it.
|
|
33
|
+
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
34
|
+
}
|
|
35
|
+
/** What a provider's error means for the user, or undefined when it is something else. */
|
|
36
|
+
export function providerTrouble(text) {
|
|
37
|
+
if (!text)
|
|
38
|
+
return undefined;
|
|
39
|
+
if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|usage limit/i.test(text))
|
|
40
|
+
return "quota";
|
|
41
|
+
if (/\b(503|529)\b|UNAVAILABLE|high demand|overloaded/i.test(text))
|
|
42
|
+
return "busy";
|
|
43
|
+
if (/RECITATION|SAFETY|PROHIBITED_CONTENT/.test(text))
|
|
44
|
+
return "blocked";
|
|
45
|
+
if (/\b(401|403)\b|API key not valid|invalid api key|PERMISSION_DENIED|UNAUTHENTICATED/i.test(text))
|
|
46
|
+
return "key";
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
/** The terminal's words for each kind (the web view has its own, in English and Portuguese). */
|
|
50
|
+
export const TROUBLE_TEXT = {
|
|
51
|
+
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 use another model (/model).",
|
|
52
|
+
busy: "The model provider is busy at the moment. Try again in a few minutes, or use another model (/model).",
|
|
53
|
+
blocked: "The model provider stopped the reply (for example, because it repeated a source word for word). Ask again in other words, or use another model (/model).",
|
|
54
|
+
key: "The model provider did not accept the API key. Check it, or enter a new one with /login.",
|
|
55
|
+
};
|
package/dist/subsub.js
CHANGED
|
@@ -25,6 +25,7 @@ import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.js";
|
|
|
25
25
|
import { applyEdits, checkLiteratureNote } from "./notes.js";
|
|
26
26
|
import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.js";
|
|
27
27
|
import { describeArgs, formatPreview } from "./preview.js";
|
|
28
|
+
import { providerTrouble, TROUBLE_TEXT } from "./keys.js";
|
|
28
29
|
import { headerLines, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
|
|
29
30
|
import { systemAddition } from "./prompt.js";
|
|
30
31
|
import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.js";
|
|
@@ -298,6 +299,15 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
298
299
|
await applyMode(ctx, m, false, false);
|
|
299
300
|
});
|
|
300
301
|
pi.on("model_select", async (_event, ctx) => status(ctx));
|
|
302
|
+
// A provider error in plain words (quota, busy, blocked, key); the web view words its own.
|
|
303
|
+
pi.on("message_end", async (event, ctx) => {
|
|
304
|
+
const m = event.message;
|
|
305
|
+
if (webView || m.role !== "assistant" || m.stopReason !== "error")
|
|
306
|
+
return;
|
|
307
|
+
const kind = providerTrouble(m.errorMessage);
|
|
308
|
+
if (kind)
|
|
309
|
+
ctx.ui.notify(`Sub-Sub: ${TROUBLE_TEXT[kind]}`, "warning");
|
|
310
|
+
});
|
|
301
311
|
// Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
|
|
302
312
|
// The input event comes before pi expands a prompt template.
|
|
303
313
|
pi.on("input", async (event, ctx) => {
|
package/dist/web.js
CHANGED
|
@@ -21,8 +21,9 @@ import { homedir } from "node:os";
|
|
|
21
21
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
22
22
|
import { StringDecoder } from "node:string_decoder";
|
|
23
23
|
import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.js";
|
|
24
|
-
import { configPath, expand, loadConfig, readConfigFile, saveConfig, withStarbuck } from "./config.js";
|
|
24
|
+
import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, withStarbuck } from "./config.js";
|
|
25
25
|
import { TESTED_MODELS } from "./init.js";
|
|
26
|
+
import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
|
|
26
27
|
import { obsidianRoot } from "./layout.js";
|
|
27
28
|
import { realish, within } from "./paths.js";
|
|
28
29
|
export function parseWebArgs(args) {
|
|
@@ -51,37 +52,7 @@ export function uiLanguage(language) {
|
|
|
51
52
|
return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
|
|
52
53
|
}
|
|
53
54
|
// ---------------------------------------------------------------- providers (API keys)
|
|
54
|
-
export
|
|
55
|
-
{ id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
|
|
56
|
-
{ id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
|
|
57
|
-
{ id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
|
|
58
|
-
{ id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
|
|
59
|
-
{ id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
|
|
60
|
-
{ id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
|
|
61
|
-
{ id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
|
|
62
|
-
];
|
|
63
|
-
/** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
|
|
64
|
-
export function saveApiKey(agentDir, provider, key) {
|
|
65
|
-
if (!KEY_PROVIDERS.some((p) => p.id === provider))
|
|
66
|
-
throw new Error(`Unknown provider: ${provider}`);
|
|
67
|
-
const clean = key.trim();
|
|
68
|
-
if (!clean || /\s/.test(clean) || clean.length > 400)
|
|
69
|
-
throw new Error("That does not look like an API key.");
|
|
70
|
-
mkdirSync(agentDir, { recursive: true });
|
|
71
|
-
const file = join(agentDir, "auth.json");
|
|
72
|
-
let data = {};
|
|
73
|
-
if (existsSync(file)) {
|
|
74
|
-
try {
|
|
75
|
-
data = JSON.parse(readFileSync(file, "utf8")) ?? {};
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
data[provider] = { type: "api_key", key: clean };
|
|
82
|
-
// Write through a symlink (a shared pi auth.json) rather than replacing it.
|
|
83
|
-
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
84
|
-
}
|
|
55
|
+
export { KEY_PROVIDERS, saveApiKey } from "./keys.js";
|
|
85
56
|
/** pi as a child process in RPC mode. Strict JSONL: records end at LF only. */
|
|
86
57
|
export class Agent {
|
|
87
58
|
proc;
|
|
@@ -582,7 +553,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
582
553
|
}
|
|
583
554
|
case "GET /api/models": {
|
|
584
555
|
const data = (await agent.request({ type: "get_available_models" }));
|
|
585
|
-
return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
|
|
556
|
+
return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []), recommended: RECOMMENDED_MODELS });
|
|
586
557
|
}
|
|
587
558
|
case "POST /api/model": {
|
|
588
559
|
if (busy || switching)
|
package/package.json
CHANGED
package/src/bench.ts
CHANGED
|
@@ -134,7 +134,10 @@ export function runOne(opts: {
|
|
|
134
134
|
provider?: string;
|
|
135
135
|
}): Promise<RunSummary> {
|
|
136
136
|
mkdirSync(opts.out, { recursive: true });
|
|
137
|
-
|
|
137
|
+
// "google/gemini-x" or "openrouter/vendor/model:free" name their provider; a bare name is OpenCode Go.
|
|
138
|
+
const slash = opts.model.indexOf("/");
|
|
139
|
+
const [provider, model] = slash > 0 && !opts.provider ? [opts.model.slice(0, slash), opts.model.slice(slash + 1)] : [opts.provider ?? "opencode-go", opts.model];
|
|
140
|
+
const args = ["--mode", "rpc", "--no-session", "--provider", provider, "--model", model];
|
|
138
141
|
if (opts.role === "librarian") args.push("--librarian");
|
|
139
142
|
const env = { ...process.env, ...opts.extraEnv, SUBSUB_BENCH_OUT: opts.out, SUBSUB_CONFIG: opts.configFile };
|
|
140
143
|
const proc = spawn(process.execPath, [opts.cli ?? join(PACKAGE_DIR, "bin", "subsub.js"), ...args], { cwd: opts.cwd, env });
|
|
@@ -608,7 +611,9 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
608
611
|
const prompt = promptFor(j.task, t, j.code, out);
|
|
609
612
|
if (!prompt) return;
|
|
610
613
|
const cf = join(cfgDir, `${j.code}.json`);
|
|
611
|
-
|
|
614
|
+
// A bare name is an OpenCode Go model; provider/id (google/gemini-..., openrouter/x/y:free) is any provider.
|
|
615
|
+
const spec = j.model.includes("/") ? j.model : `opencode-go/${j.model}`;
|
|
616
|
+
writeFileSync(cf, JSON.stringify({ ...userCfg, profile: "editor", models: { librarian: spec, researcher: spec }, model: undefined, libraryChanges: undefined }));
|
|
612
617
|
const s = await runOne({ ...j, prompt, out, cwd: cfg.vault!, configFile: cf, timeoutSec });
|
|
613
618
|
n++;
|
|
614
619
|
console.log(`${n}/${jobs.length} ${j.code} ${j.task} ${s.seconds}s ${s.error ?? (s.settled ? "done" : "?")}`);
|
package/src/config.ts
CHANGED
|
@@ -35,6 +35,22 @@ export const DEFAULT_MODELS: Record<Mode, string> = {
|
|
|
35
35
|
librarian: "opencode-go/mimo-v2.6-flash",
|
|
36
36
|
researcher: "opencode-go/mimo-v2.6-pro",
|
|
37
37
|
};
|
|
38
|
+
/** The tested free models (Google AI Studio free tier; see the Models page): enough to try Sub-Sub. */
|
|
39
|
+
export const FREE_MODELS: Record<Mode, string> = {
|
|
40
|
+
librarian: "google/gemini-3.1-flash-lite",
|
|
41
|
+
researcher: "google/gemini-3.1-flash-lite",
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Models marked in the model dialog, from the model tests (see the Models page). `why` is a
|
|
45
|
+
* key into the web view's strings: tested (a default), cheaper (tested, same quality, lower cost),
|
|
46
|
+
* free (tested, works on a free tier).
|
|
47
|
+
*/
|
|
48
|
+
export const RECOMMENDED_MODELS: Array<{ spec: string; modes: Mode[]; why: "tested" | "cheaper" | "free" }> = [
|
|
49
|
+
{ spec: DEFAULT_MODELS.librarian, modes: ["librarian"], why: "tested" },
|
|
50
|
+
{ spec: DEFAULT_MODELS.researcher, modes: ["researcher"], why: "tested" },
|
|
51
|
+
{ spec: "opencode-go/qwen3.8-flash", modes: ["researcher"], why: "cheaper" },
|
|
52
|
+
{ spec: FREE_MODELS.librarian, modes: ["librarian", "researcher"], why: "free" },
|
|
53
|
+
];
|
|
38
54
|
export type Profile = "reader" | "scholar" | "author" | "editor";
|
|
39
55
|
export const PROFILES: Profile[] = ["reader", "scholar", "author", "editor"];
|
|
40
56
|
|
package/src/init.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* Flags (for scripts and tests): --yes (take every default), --setup
|
|
20
20
|
* standard|fmup, --name, --about, --language, --folder (or --notes), --move
|
|
21
21
|
* yes|no, --profile, --tags health-sciences|health-informatics|any-field,
|
|
22
|
-
* --email, --starbuck on|off, --models keep|later|opencode-go.
|
|
22
|
+
* --email, --starbuck on|off, --models keep|later|opencode-go|google-free.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -30,6 +30,7 @@ import { fileURLToPath } from "node:url";
|
|
|
30
30
|
import {
|
|
31
31
|
configPath,
|
|
32
32
|
DEFAULT_MODELS,
|
|
33
|
+
FREE_MODELS,
|
|
33
34
|
expand,
|
|
34
35
|
isProfile,
|
|
35
36
|
loadConfig,
|
|
@@ -43,6 +44,7 @@ import {
|
|
|
43
44
|
} from "./config.ts";
|
|
44
45
|
import { defaultRunner, starbuckCheck } from "./doctor.ts";
|
|
45
46
|
import { applyMoves, FOLDER_NAME, leftBehind, NOTE_DIRS, planMoves, SETTINGS_DIR, SETTINGS_FILES, subsubFolder } from "./layout.ts";
|
|
47
|
+
import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
|
|
46
48
|
import { PROFILE_SPECS } from "./profiles.ts";
|
|
47
49
|
|
|
48
50
|
const TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
|
|
@@ -238,10 +240,20 @@ export async function runInit(
|
|
|
238
240
|
...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
|
|
239
241
|
{ value: "later", label: "Choose later in Sub-Sub" },
|
|
240
242
|
{ value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the Librarian, ${TESTED_MODELS.researcher} for the Researcher` },
|
|
243
|
+
{ value: "google-free", label: `Free, to try Sub-Sub: Google AI Studio, ${FREE_MODELS.researcher} in both modes (a free key, no card; limits per minute and per day; 18 or older)` },
|
|
241
244
|
];
|
|
242
245
|
const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
|
|
243
246
|
const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
|
|
244
247
|
const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
|
|
248
|
+
let googleKey = "";
|
|
249
|
+
if (models === "google-free" && !hasProvider(agentDir, "google")) {
|
|
250
|
+
const url = KEY_PROVIDERS.find((p) => p.id === "google")?.url;
|
|
251
|
+
explain(
|
|
252
|
+
"Free Google key",
|
|
253
|
+
`Open ${url}, sign in with a Google account, and select "Create API key". Copy the key and paste it here. Sub-Sub saves it in ${join(agentDir, "auth.json")}, readable only by you. In the EU, the UK and Switzerland, Google does not use your prompts to improve its products; elsewhere, on the free tier, it may. When a limit is reached, Sub-Sub says so.`,
|
|
254
|
+
);
|
|
255
|
+
googleKey = (await io.ask("Google API key (Enter to add it later with the model button or /login)", "")).trim();
|
|
256
|
+
}
|
|
245
257
|
|
|
246
258
|
// ---- write
|
|
247
259
|
if (move === "yes") applyMoves(moves);
|
|
@@ -285,7 +297,19 @@ export async function runInit(
|
|
|
285
297
|
if (models === "keep") patch.models = current.models;
|
|
286
298
|
if (models === "later") patch.models = {};
|
|
287
299
|
if (models === "opencode-go") patch.models = TESTED_MODELS;
|
|
300
|
+
if (models === "google-free") patch.models = FREE_MODELS;
|
|
288
301
|
saveConfig(patch, initEnv);
|
|
302
|
+
let keyNote = "";
|
|
303
|
+
if (googleKey) {
|
|
304
|
+
try {
|
|
305
|
+
saveApiKey(agentDir, "google", googleKey);
|
|
306
|
+
keyNote = `Saved: the Google key, in ${join(agentDir, "auth.json")}`;
|
|
307
|
+
} catch (err) {
|
|
308
|
+
keyNote = `The Google key was not saved (${(err as Error).message}). Add it in Sub-Sub with the model button or /login.`;
|
|
309
|
+
}
|
|
310
|
+
} else if (models === "google-free" && !hasProvider(agentDir, "google")) {
|
|
311
|
+
keyNote = "No Google key yet. Add it in Sub-Sub with the model button (subsub web) or /login (in the terminal), with the provider Google Gemini.";
|
|
312
|
+
}
|
|
289
313
|
|
|
290
314
|
const zotero = await zoteroReachable(deps.fetch);
|
|
291
315
|
const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
|
|
@@ -293,6 +317,7 @@ export async function runInit(
|
|
|
293
317
|
io.say(`Saved: ${configFile}`);
|
|
294
318
|
io.say(`Saved: ${envFile}`);
|
|
295
319
|
for (const c of created) io.say(`Created: ${c}`);
|
|
320
|
+
if (keyNote) io.say(keyNote);
|
|
296
321
|
if (move === "yes") io.say(`Moved: ${moves.length} file(s) into ${notes}`);
|
|
297
322
|
for (const d of leftBehind(from, notes)) io.say(`Not moved: ${d}. If Sub-Sub wrote these notes, move them into ${notes} yourself.`);
|
|
298
323
|
io.say(
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Model providers that take an API key, and plain words for their common errors. */
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
export const KEY_PROVIDERS: Array<{ id: string; label: string; url: string }> = [
|
|
7
|
+
{ id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
|
|
8
|
+
{ id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
|
|
9
|
+
{ id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
|
|
10
|
+
{ id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
|
|
11
|
+
{ id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
|
|
12
|
+
{ id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
|
|
13
|
+
{ id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
|
|
17
|
+
export function saveApiKey(agentDir: string, provider: string, key: string): void {
|
|
18
|
+
if (!KEY_PROVIDERS.some((p) => p.id === provider)) throw new Error(`Unknown provider: ${provider}`);
|
|
19
|
+
const clean = key.trim();
|
|
20
|
+
if (!clean || /\s/.test(clean) || clean.length > 400) throw new Error("That does not look like an API key.");
|
|
21
|
+
mkdirSync(agentDir, { recursive: true });
|
|
22
|
+
const file = join(agentDir, "auth.json");
|
|
23
|
+
let data: Record<string, unknown> = {};
|
|
24
|
+
if (existsSync(file)) {
|
|
25
|
+
try {
|
|
26
|
+
data = JSON.parse(readFileSync(file, "utf8")) ?? {};
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
data[provider] = { type: "api_key", key: clean };
|
|
32
|
+
// Write through a symlink (a shared pi auth.json) rather than replacing it.
|
|
33
|
+
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
export type Trouble = "quota" | "busy" | "blocked" | "key";
|
|
38
|
+
|
|
39
|
+
/** What a provider's error means for the user, or undefined when it is something else. */
|
|
40
|
+
export function providerTrouble(text: string | undefined): Trouble | undefined {
|
|
41
|
+
if (!text) return undefined;
|
|
42
|
+
if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|usage limit/i.test(text)) return "quota";
|
|
43
|
+
if (/\b(503|529)\b|UNAVAILABLE|high demand|overloaded/i.test(text)) return "busy";
|
|
44
|
+
if (/RECITATION|SAFETY|PROHIBITED_CONTENT/.test(text)) return "blocked";
|
|
45
|
+
if (/\b(401|403)\b|API key not valid|invalid api key|PERMISSION_DENIED|UNAUTHENTICATED/i.test(text)) return "key";
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The terminal's words for each kind (the web view has its own, in English and Portuguese). */
|
|
50
|
+
export const TROUBLE_TEXT: Record<Trouble, string> = {
|
|
51
|
+
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 use another model (/model).",
|
|
52
|
+
busy: "The model provider is busy at the moment. Try again in a few minutes, or use another model (/model).",
|
|
53
|
+
blocked: "The model provider stopped the reply (for example, because it repeated a source word for word). Ask again in other words, or use another model (/model).",
|
|
54
|
+
key: "The model provider did not accept the API key. Check it, or enter a new one with /login.",
|
|
55
|
+
};
|
package/src/subsub.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.ts";
|
|
|
41
41
|
import { applyEdits, checkLiteratureNote } from "./notes.ts";
|
|
42
42
|
import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
|
|
43
43
|
import { describeArgs, formatPreview } from "./preview.ts";
|
|
44
|
+
import { providerTrouble, TROUBLE_TEXT } from "./keys.ts";
|
|
44
45
|
import { headerLines, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
|
|
45
46
|
import { systemAddition } from "./prompt.ts";
|
|
46
47
|
import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.ts";
|
|
@@ -324,6 +325,14 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
324
325
|
});
|
|
325
326
|
|
|
326
327
|
pi.on("model_select", async (_event, ctx) => status(ctx));
|
|
328
|
+
// A provider error in plain words (quota, busy, blocked, key); the web view words its own.
|
|
329
|
+
pi.on("message_end", async (event, ctx) => {
|
|
330
|
+
const m = event.message as { role?: string; stopReason?: string; errorMessage?: string };
|
|
331
|
+
if (webView || m.role !== "assistant" || m.stopReason !== "error") return;
|
|
332
|
+
const kind = providerTrouble(m.errorMessage);
|
|
333
|
+
if (kind) ctx.ui.notify(`Sub-Sub: ${TROUBLE_TEXT[kind]}`, "warning");
|
|
334
|
+
});
|
|
335
|
+
|
|
327
336
|
|
|
328
337
|
// Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
|
|
329
338
|
// The input event comes before pi expands a prompt template.
|
package/src/web.ts
CHANGED
|
@@ -22,8 +22,9 @@ import { homedir } from "node:os";
|
|
|
22
22
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
23
23
|
import { StringDecoder } from "node:string_decoder";
|
|
24
24
|
import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
|
|
25
|
-
import { configPath, expand, loadConfig, readConfigFile, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
|
|
25
|
+
import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
|
|
26
26
|
import { TESTED_MODELS } from "./init.ts";
|
|
27
|
+
import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
|
|
27
28
|
import { obsidianRoot } from "./layout.ts";
|
|
28
29
|
import { realish, within } from "./paths.ts";
|
|
29
30
|
|
|
@@ -62,35 +63,7 @@ export function uiLanguage(language: string | undefined): "pt" | "en" {
|
|
|
62
63
|
|
|
63
64
|
// ---------------------------------------------------------------- providers (API keys)
|
|
64
65
|
|
|
65
|
-
export
|
|
66
|
-
{ id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
|
|
67
|
-
{ id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
|
|
68
|
-
{ id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
|
|
69
|
-
{ id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
|
|
70
|
-
{ id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
|
|
71
|
-
{ id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
|
|
72
|
-
{ id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
|
|
73
|
-
];
|
|
74
|
-
|
|
75
|
-
/** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
|
|
76
|
-
export function saveApiKey(agentDir: string, provider: string, key: string): void {
|
|
77
|
-
if (!KEY_PROVIDERS.some((p) => p.id === provider)) throw new Error(`Unknown provider: ${provider}`);
|
|
78
|
-
const clean = key.trim();
|
|
79
|
-
if (!clean || /\s/.test(clean) || clean.length > 400) throw new Error("That does not look like an API key.");
|
|
80
|
-
mkdirSync(agentDir, { recursive: true });
|
|
81
|
-
const file = join(agentDir, "auth.json");
|
|
82
|
-
let data: Record<string, unknown> = {};
|
|
83
|
-
if (existsSync(file)) {
|
|
84
|
-
try {
|
|
85
|
-
data = JSON.parse(readFileSync(file, "utf8")) ?? {};
|
|
86
|
-
} catch {
|
|
87
|
-
throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
data[provider] = { type: "api_key", key: clean };
|
|
91
|
-
// Write through a symlink (a shared pi auth.json) rather than replacing it.
|
|
92
|
-
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
93
|
-
}
|
|
66
|
+
export { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
|
|
94
67
|
|
|
95
68
|
// ---------------------------------------------------------------- pi in RPC mode
|
|
96
69
|
|
|
@@ -597,7 +570,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
597
570
|
}
|
|
598
571
|
case "GET /api/models": {
|
|
599
572
|
const data = (await agent.request({ type: "get_available_models" })) as { models?: unknown[] } | unknown[];
|
|
600
|
-
return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
|
|
573
|
+
return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []), recommended: RECOMMENDED_MODELS });
|
|
601
574
|
}
|
|
602
575
|
case "POST /api/model": {
|
|
603
576
|
if (busy || switching) return send(res, 409, { error: "busy" });
|
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
|
|
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 });
|
|
@@ -596,7 +611,7 @@ function renderAssistantFinal(message, msg = startAssistant()) {
|
|
|
596
611
|
);
|
|
597
612
|
msg.box.append(actions);
|
|
598
613
|
}
|
|
599
|
-
if (message.stopReason === "error" && message.errorMessage) msg.box.append(
|
|
614
|
+
if (message.stopReason === "error" && message.errorMessage) msg.box.append(errorNote(message.errorMessage));
|
|
600
615
|
if (message.stopReason === "aborted") msg.box.append(el("div", { class: "note", text: t("stopped") }));
|
|
601
616
|
if (!msg.box.children.length) msg.box.remove();
|
|
602
617
|
scrollDown();
|
|
@@ -907,44 +922,93 @@ async function openModels() {
|
|
|
907
922
|
const setLink = () => {
|
|
908
923
|
const p = providers.find((x) => x.id === sel.value);
|
|
909
924
|
link.href = p?.url ?? "#";
|
|
925
|
+
const free = { google: "freeGoogle", openrouter: "freeOpenRouter" }[sel.value];
|
|
926
|
+
$("login-free").hidden = !free;
|
|
927
|
+
$("login-free").textContent = free ? t(free) : "";
|
|
910
928
|
};
|
|
911
929
|
sel.onchange = setLink;
|
|
912
930
|
setLink();
|
|
913
931
|
if (!dlg.open) dlg.showModal();
|
|
914
932
|
let models = [];
|
|
933
|
+
let recommended = [];
|
|
915
934
|
try {
|
|
916
|
-
|
|
935
|
+
const data = await api("/api/models");
|
|
936
|
+
models = data.models ?? [];
|
|
937
|
+
recommended = data.recommended ?? [];
|
|
917
938
|
} catch (err) {
|
|
918
939
|
note(err.message, "error");
|
|
919
940
|
}
|
|
920
|
-
ul.replaceChildren();
|
|
921
|
-
if (!models.length) ul.append(el("li", { class: "empty-models", text: t("noModels") }));
|
|
922
941
|
const cur = state?.model ?? session?.model;
|
|
942
|
+
const specOf = (m) => `${m.provider}/${m.id}`;
|
|
923
943
|
const isCurrent = (m) => cur && cur.provider === m.provider && cur.id === m.id;
|
|
924
|
-
|
|
925
|
-
for (
|
|
926
|
-
|
|
927
|
-
|
|
944
|
+
const available = new Set(models.map(specOf));
|
|
945
|
+
// Recommended for this mode first (also when the provider has no key yet), then the rest.
|
|
946
|
+
const mine = recommended.filter((r) => r.modes.includes(mode()));
|
|
947
|
+
const recSpecs = new Set(mine.map((r) => r.spec));
|
|
948
|
+
const others = models
|
|
949
|
+
.filter((m) => !recSpecs.has(specOf(m)))
|
|
950
|
+
.sort((a, b) => Number(isCurrent(b)) - Number(isCurrent(a)) || specOf(a).localeCompare(specOf(b)));
|
|
951
|
+
const choose = async (m) => {
|
|
952
|
+
if (busy) return note(t("busy"), "warning");
|
|
953
|
+
try {
|
|
954
|
+
await api("/api/model", { provider: m.provider, id: m.id, both: $("model-both").checked });
|
|
955
|
+
dlg.close();
|
|
956
|
+
} catch (err) {
|
|
957
|
+
note(err.message, "error");
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
const row = (m, rec) => {
|
|
961
|
+
const spec = specOf(m);
|
|
962
|
+
const can = available.has(spec);
|
|
963
|
+
const label = providers.find((p) => p.id === m.provider)?.label ?? m.provider;
|
|
964
|
+
return el(
|
|
965
|
+
"li",
|
|
966
|
+
{ "data-spec": spec.toLowerCase() },
|
|
928
967
|
el(
|
|
929
|
-
"
|
|
930
|
-
{
|
|
931
|
-
el("button", {
|
|
968
|
+
"button",
|
|
969
|
+
{
|
|
932
970
|
type: "button",
|
|
933
|
-
class:
|
|
934
|
-
|
|
935
|
-
onclick:
|
|
936
|
-
if (
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
} catch (err) {
|
|
941
|
-
note(err.message, "error");
|
|
971
|
+
class: [isCurrent(m) ? "current" : "", rec ? "rec" : "", can ? "" : "needs-key"].join(" ").trim(),
|
|
972
|
+
// Without a key, the click goes to the form below: that provider, and the key field.
|
|
973
|
+
onclick: () => {
|
|
974
|
+
if (can) return choose(m);
|
|
975
|
+
if (providers.some((p) => p.id === m.provider)) {
|
|
976
|
+
sel.value = m.provider;
|
|
977
|
+
setLink();
|
|
942
978
|
}
|
|
979
|
+
$("login-key").focus();
|
|
943
980
|
},
|
|
944
|
-
}
|
|
981
|
+
},
|
|
982
|
+
el("span", { class: "model-name", text: spec }),
|
|
983
|
+
rec ? el("span", { class: "model-tag", text: t(`rec_${rec.why}`) }) : null,
|
|
984
|
+
rec && !can ? el("span", { class: "model-why", text: t("needsKey", { x: label }) }) : null,
|
|
945
985
|
),
|
|
946
986
|
);
|
|
947
|
-
}
|
|
987
|
+
};
|
|
988
|
+
const search = $("model-search");
|
|
989
|
+
const render = () => {
|
|
990
|
+
const q = search.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
991
|
+
const hit = (spec) => q.every((w) => spec.toLowerCase().includes(w));
|
|
992
|
+
const recRows = mine.filter((r) => hit(r.spec)).map((r) => {
|
|
993
|
+
const i = r.spec.indexOf("/");
|
|
994
|
+
return row({ provider: r.spec.slice(0, i), id: r.spec.slice(i + 1) }, r);
|
|
995
|
+
});
|
|
996
|
+
const rest = others.filter((m) => hit(specOf(m))).map((m) => row(m, null));
|
|
997
|
+
ul.replaceChildren();
|
|
998
|
+
if (recRows.length) ul.append(el("li", { class: "models-group", text: t("recommended") }), ...recRows);
|
|
999
|
+
if (rest.length) ul.append(el("li", { class: "models-group", text: t(recRows.length ? "otherModels" : "allModels") }), ...rest);
|
|
1000
|
+
if (!recRows.length && !rest.length) ul.append(el("li", { class: "empty-models", text: models.length ? t("noMatch") : t("noModels") }));
|
|
1001
|
+
};
|
|
1002
|
+
search.value = "";
|
|
1003
|
+
search.oninput = render;
|
|
1004
|
+
search.onkeydown = (e) => {
|
|
1005
|
+
if (e.key === "Enter") {
|
|
1006
|
+
e.preventDefault();
|
|
1007
|
+
ul.querySelector("button:not(.needs-key)")?.click();
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
render();
|
|
1011
|
+
search.focus();
|
|
948
1012
|
}
|
|
949
1013
|
|
|
950
1014
|
$("login-form")?.addEventListener("submit", async (e) => {
|
package/web/i18n.js
CHANGED
|
@@ -45,6 +45,15 @@ 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
|
+
needsKey: "needs a key from {x}",
|
|
48
57
|
noModels: "No models yet. Connect a provider below.",
|
|
49
58
|
connectProvider: "Connect a provider",
|
|
50
59
|
provider: "Provider",
|
|
@@ -52,6 +61,13 @@ export const STRINGS = {
|
|
|
52
61
|
save: "Save",
|
|
53
62
|
getKey: "Get a key",
|
|
54
63
|
keyNote: "The key is saved on this computer, in Sub-Sub's settings folder, and is sent only to that provider.",
|
|
64
|
+
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.",
|
|
65
|
+
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.",
|
|
66
|
+
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.",
|
|
67
|
+
trouble_busy: "The model provider is busy at the moment. Try again in a few minutes, or choose another model with the model button.",
|
|
68
|
+
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.",
|
|
69
|
+
trouble_key: "The model provider did not accept the API key. Enter it again with the model button.",
|
|
70
|
+
details: "Details",
|
|
55
71
|
oauthNote: "To sign in through a provider's website (for example with a subscription), open a terminal, type subsub, then /login.",
|
|
56
72
|
saving: "Saving",
|
|
57
73
|
saved: "Saved.",
|
|
@@ -140,6 +156,15 @@ export const STRINGS = {
|
|
|
140
156
|
modelTitleResearcher: "Modelo do Investigador",
|
|
141
157
|
modelTitleLibrarian: "Modelo do Bibliotecário",
|
|
142
158
|
chooseModel: "Escolha um modelo",
|
|
159
|
+
searchModels: "Pesquisar, por exemplo flash ou openrouter free",
|
|
160
|
+
recommended: "Recomendados para este modo",
|
|
161
|
+
otherModels: "Outros modelos",
|
|
162
|
+
allModels: "Todos os modelos",
|
|
163
|
+
noMatch: "Nenhum modelo corresponde.",
|
|
164
|
+
rec_tested: "testado, por omissão",
|
|
165
|
+
rec_cheaper: "testado, mais barato",
|
|
166
|
+
rec_free: "testado, gratuito",
|
|
167
|
+
needsKey: "precisa de uma chave de {x}",
|
|
143
168
|
noModels: "Ainda não há modelos. Ligue um fornecedor abaixo.",
|
|
144
169
|
connectProvider: "Ligar um fornecedor",
|
|
145
170
|
provider: "Fornecedor",
|
|
@@ -147,6 +172,13 @@ export const STRINGS = {
|
|
|
147
172
|
save: "Guardar",
|
|
148
173
|
getKey: "Obter uma chave",
|
|
149
174
|
keyNote: "A chave fica guardada neste computador, na pasta de definições do Sub-Sub, e só é enviada a esse fornecedor.",
|
|
175
|
+
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.",
|
|
176
|
+
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.",
|
|
177
|
+
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.",
|
|
178
|
+
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.",
|
|
179
|
+
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.",
|
|
180
|
+
trouble_key: "O fornecedor do modelo não aceitou a chave da API. Volte a introduzi-la no botão do modelo.",
|
|
181
|
+
details: "Detalhes",
|
|
150
182
|
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
183
|
saving: "A guardar",
|
|
152
184
|
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,7 @@
|
|
|
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 free-note" id="login-free" hidden></p>
|
|
124
126
|
<p class="small muted" data-i18n="keyNote"></p>
|
|
125
127
|
<div class="modal-buttons"><button type="submit" class="primary" id="login-save" data-i18n="save">Save</button></div>
|
|
126
128
|
<p class="small muted" data-i18n="oauthNote"></p>
|