@tiagojct/subsub 0.11.2 → 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 +17 -1
- package/dist/init.js +34 -8
- 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 +17 -1
- package/src/init.ts +38 -12
- 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 +90 -24
- package/web/i18n.js +38 -2
- 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,9 +31,25 @@ 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
|
-
export const SERVER_VERSION = "0.5.
|
|
52
|
+
export const SERVER_VERSION = "0.5.3";
|
|
37
53
|
/** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
|
|
38
54
|
export const STARBUCK_VERSION = "0.2.0";
|
|
39
55
|
export const STARBUCK_SOURCE = `starbuck==${STARBUCK_VERSION}`;
|
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 = {
|
|
@@ -88,12 +89,6 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
88
89
|
io.say("");
|
|
89
90
|
io.say(`${title}. ${text}`);
|
|
90
91
|
};
|
|
91
|
-
explain("Setup", "Standard works for any field. FMUP / U.Porto starts with health-sciences tags and the Reader profile.");
|
|
92
|
-
const setup = await io.choose("Setup", [
|
|
93
|
-
{ value: "standard", label: "Standard" },
|
|
94
|
-
{ value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
|
|
95
|
-
], flags.setup ?? existing.setup ?? "standard");
|
|
96
|
-
const fmup = setup === "fmup";
|
|
97
92
|
explain("About you", "Sub-Sub uses your name and one line about you in its replies. Both are optional and stay on this computer, except in the messages to the model provider.");
|
|
98
93
|
const userName = (await io.ask("Your name (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
|
|
99
94
|
const about = (await io.ask('One line about you, e.g. "a master\'s student in health informatics" (optional)', flags.about ?? existing.about ?? "")).trim();
|
|
@@ -130,6 +125,13 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
130
125
|
io.say(`Sub-Sub keeps using ${notes}.`);
|
|
131
126
|
}
|
|
132
127
|
}
|
|
128
|
+
// Asked here, not first: it only changes the defaults of the next two questions.
|
|
129
|
+
explain("Faculty of Medicine, Porto", "Sub-Sub has a set-up for the Faculty of Medicine of the University of Porto (FMUP): health-sciences tags and the Reader profile. Everyone else keeps the standard set-up.");
|
|
130
|
+
const setup = await io.choose("Set-up", [
|
|
131
|
+
{ value: "standard", label: "Standard (any field, any institution)" },
|
|
132
|
+
{ value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
|
|
133
|
+
], flags.setup ?? existing.setup ?? "standard");
|
|
134
|
+
const fmup = setup === "fmup";
|
|
133
135
|
explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
|
|
134
136
|
const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
|
|
135
137
|
const profile = await io.choose("Profile", PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })), isProfile(profileDefault) ? profileDefault : "scholar");
|
|
@@ -165,10 +167,17 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
165
167
|
...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
|
|
166
168
|
{ value: "later", label: "Choose later in Sub-Sub" },
|
|
167
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)` },
|
|
168
171
|
];
|
|
169
172
|
const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
|
|
170
173
|
const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
|
|
171
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
|
+
}
|
|
172
181
|
// ---- write
|
|
173
182
|
if (move === "yes")
|
|
174
183
|
applyMoves(moves);
|
|
@@ -214,7 +223,22 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
214
223
|
patch.models = {};
|
|
215
224
|
if (models === "opencode-go")
|
|
216
225
|
patch.models = TESTED_MODELS;
|
|
226
|
+
if (models === "google-free")
|
|
227
|
+
patch.models = FREE_MODELS;
|
|
217
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
|
+
}
|
|
218
242
|
const zotero = await zoteroReachable(deps.fetch);
|
|
219
243
|
const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
|
|
220
244
|
io.say("");
|
|
@@ -222,6 +246,8 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
222
246
|
io.say(`Saved: ${envFile}`);
|
|
223
247
|
for (const c of created)
|
|
224
248
|
io.say(`Created: ${c}`);
|
|
249
|
+
if (keyNote)
|
|
250
|
+
io.say(keyNote);
|
|
225
251
|
if (move === "yes")
|
|
226
252
|
io.say(`Moved: ${moves.length} file(s) into ${notes}`);
|
|
227
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,11 +35,27 @@ 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
|
|
|
41
57
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
42
|
-
export const SERVER_VERSION = "0.5.
|
|
58
|
+
export const SERVER_VERSION = "0.5.3";
|
|
43
59
|
|
|
44
60
|
/** The Starbuck release (PyPI) that this Sub-Sub version runs when there is no local Starbuck folder. */
|
|
45
61
|
export const STARBUCK_VERSION = "0.2.0";
|
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");
|
|
@@ -128,17 +130,6 @@ export async function runInit(
|
|
|
128
130
|
io.say(`${title}. ${text}`);
|
|
129
131
|
};
|
|
130
132
|
|
|
131
|
-
explain("Setup", "Standard works for any field. FMUP / U.Porto starts with health-sciences tags and the Reader profile.");
|
|
132
|
-
const setup = await io.choose(
|
|
133
|
-
"Setup",
|
|
134
|
-
[
|
|
135
|
-
{ value: "standard", label: "Standard" },
|
|
136
|
-
{ value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
|
|
137
|
-
],
|
|
138
|
-
flags.setup ?? existing.setup ?? "standard",
|
|
139
|
-
);
|
|
140
|
-
const fmup = setup === "fmup";
|
|
141
|
-
|
|
142
133
|
explain("About you", "Sub-Sub uses your name and one line about you in its replies. Both are optional and stay on this computer, except in the messages to the model provider.");
|
|
143
134
|
const userName = (await io.ask("Your name (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
|
|
144
135
|
const about = (await io.ask('One line about you, e.g. "a master\'s student in health informatics" (optional)', flags.about ?? existing.about ?? "")).trim();
|
|
@@ -184,6 +175,18 @@ export async function runInit(
|
|
|
184
175
|
}
|
|
185
176
|
}
|
|
186
177
|
|
|
178
|
+
// Asked here, not first: it only changes the defaults of the next two questions.
|
|
179
|
+
explain("Faculty of Medicine, Porto", "Sub-Sub has a set-up for the Faculty of Medicine of the University of Porto (FMUP): health-sciences tags and the Reader profile. Everyone else keeps the standard set-up.");
|
|
180
|
+
const setup = await io.choose(
|
|
181
|
+
"Set-up",
|
|
182
|
+
[
|
|
183
|
+
{ value: "standard", label: "Standard (any field, any institution)" },
|
|
184
|
+
{ value: "fmup", label: "FMUP / U.Porto: health-sciences tags, Reader profile (the FMUP model service is coming soon)" },
|
|
185
|
+
],
|
|
186
|
+
flags.setup ?? existing.setup ?? "standard",
|
|
187
|
+
);
|
|
188
|
+
const fmup = setup === "fmup";
|
|
189
|
+
|
|
187
190
|
explain("Profile", "The profile sets how much Sub-Sub writes for you and which tools it offers. Change it at any time with /profile.");
|
|
188
191
|
const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
|
|
189
192
|
const profile = await io.choose(
|
|
@@ -237,10 +240,20 @@ export async function runInit(
|
|
|
237
240
|
...(hadModel ? [{ value: "keep", label: `Keep: ${show(current.models)}` }] : []),
|
|
238
241
|
{ value: "later", label: "Choose later in Sub-Sub" },
|
|
239
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)` },
|
|
240
244
|
];
|
|
241
245
|
const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
|
|
242
246
|
const modelDefault = hadModel ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
|
|
243
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
|
+
}
|
|
244
257
|
|
|
245
258
|
// ---- write
|
|
246
259
|
if (move === "yes") applyMoves(moves);
|
|
@@ -284,7 +297,19 @@ export async function runInit(
|
|
|
284
297
|
if (models === "keep") patch.models = current.models;
|
|
285
298
|
if (models === "later") patch.models = {};
|
|
286
299
|
if (models === "opencode-go") patch.models = TESTED_MODELS;
|
|
300
|
+
if (models === "google-free") patch.models = FREE_MODELS;
|
|
287
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
|
+
}
|
|
288
313
|
|
|
289
314
|
const zotero = await zoteroReachable(deps.fetch);
|
|
290
315
|
const starbuckNote = starbuck === "on" && deps.prepareStarbuck ? deps.prepareStarbuck(loadConfig(initEnv)) : "";
|
|
@@ -292,6 +317,7 @@ export async function runInit(
|
|
|
292
317
|
io.say(`Saved: ${configFile}`);
|
|
293
318
|
io.say(`Saved: ${envFile}`);
|
|
294
319
|
for (const c of created) io.say(`Created: ${c}`);
|
|
320
|
+
if (keyNote) io.say(keyNote);
|
|
295
321
|
if (move === "yes") io.say(`Moved: ${moves.length} file(s) into ${notes}`);
|
|
296
322
|
for (const d of leftBehind(from, notes)) io.say(`Not moved: ${d}. If Sub-Sub wrote these notes, move them into ${notes} yourself.`);
|
|
297
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
|
@@ -207,8 +207,10 @@ function renderHeader() {
|
|
|
207
207
|
txt.textContent += ` · ${t("serversDown", { x: state.down.join(", ") })}`;
|
|
208
208
|
lib.classList.add("down");
|
|
209
209
|
}
|
|
210
|
-
lib.title = txt.textContent;
|
|
211
|
-
|
|
210
|
+
lib.title = L?.toReview ? `${txt.textContent}\n${t("toReviewHint")}` : txt.textContent;
|
|
211
|
+
// On a phone the long hint does not fit in the box.
|
|
212
|
+
const narrow = window.matchMedia("(max-width: 560px)").matches;
|
|
213
|
+
$("input").placeholder = narrow ? t("placeholderShort") : mode() === "librarian" ? t("placeholderLibrarian") : t("placeholderResearcher");
|
|
212
214
|
if (log.querySelector(".empty")) {
|
|
213
215
|
clearEmpty();
|
|
214
216
|
renderEmpty();
|
|
@@ -366,6 +368,21 @@ function scrollDown(force = false) {
|
|
|
366
368
|
if (force || near) log.scrollTop = log.scrollHeight;
|
|
367
369
|
}
|
|
368
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
|
+
|
|
369
386
|
function note(text, kind = "info", mono = false) {
|
|
370
387
|
clearEmpty();
|
|
371
388
|
const n = el("div", { class: `note ${kind}${mono ? " mono" : ""}`, text });
|
|
@@ -594,7 +611,7 @@ function renderAssistantFinal(message, msg = startAssistant()) {
|
|
|
594
611
|
);
|
|
595
612
|
msg.box.append(actions);
|
|
596
613
|
}
|
|
597
|
-
if (message.stopReason === "error" && message.errorMessage) msg.box.append(
|
|
614
|
+
if (message.stopReason === "error" && message.errorMessage) msg.box.append(errorNote(message.errorMessage));
|
|
598
615
|
if (message.stopReason === "aborted") msg.box.append(el("div", { class: "note", text: t("stopped") }));
|
|
599
616
|
if (!msg.box.children.length) msg.box.remove();
|
|
600
617
|
scrollDown();
|
|
@@ -905,44 +922,93 @@ async function openModels() {
|
|
|
905
922
|
const setLink = () => {
|
|
906
923
|
const p = providers.find((x) => x.id === sel.value);
|
|
907
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) : "";
|
|
908
928
|
};
|
|
909
929
|
sel.onchange = setLink;
|
|
910
930
|
setLink();
|
|
911
931
|
if (!dlg.open) dlg.showModal();
|
|
912
932
|
let models = [];
|
|
933
|
+
let recommended = [];
|
|
913
934
|
try {
|
|
914
|
-
|
|
935
|
+
const data = await api("/api/models");
|
|
936
|
+
models = data.models ?? [];
|
|
937
|
+
recommended = data.recommended ?? [];
|
|
915
938
|
} catch (err) {
|
|
916
939
|
note(err.message, "error");
|
|
917
940
|
}
|
|
918
|
-
ul.replaceChildren();
|
|
919
|
-
if (!models.length) ul.append(el("li", { class: "empty-models", text: t("noModels") }));
|
|
920
941
|
const cur = state?.model ?? session?.model;
|
|
942
|
+
const specOf = (m) => `${m.provider}/${m.id}`;
|
|
921
943
|
const isCurrent = (m) => cur && cur.provider === m.provider && cur.id === m.id;
|
|
922
|
-
|
|
923
|
-
for (
|
|
924
|
-
|
|
925
|
-
|
|
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() },
|
|
926
967
|
el(
|
|
927
|
-
"
|
|
928
|
-
{
|
|
929
|
-
el("button", {
|
|
968
|
+
"button",
|
|
969
|
+
{
|
|
930
970
|
type: "button",
|
|
931
|
-
class:
|
|
932
|
-
|
|
933
|
-
onclick:
|
|
934
|
-
if (
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
} catch (err) {
|
|
939
|
-
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();
|
|
940
978
|
}
|
|
979
|
+
$("login-key").focus();
|
|
941
980
|
},
|
|
942
|
-
}
|
|
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,
|
|
943
985
|
),
|
|
944
986
|
);
|
|
945
|
-
}
|
|
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();
|
|
946
1012
|
}
|
|
947
1013
|
|
|
948
1014
|
$("login-form")?.addEventListener("submit", async (e) => {
|
package/web/i18n.js
CHANGED
|
@@ -27,7 +27,8 @@ export const STRINGS = {
|
|
|
27
27
|
zoteroChecking: "Zotero: checking",
|
|
28
28
|
zoteroDown: "Zotero is not running",
|
|
29
29
|
items: "{n} items",
|
|
30
|
-
toReview: "{n} to
|
|
30
|
+
toReview: "{n} to check",
|
|
31
|
+
toReviewHint: "Items that Sub-Sub tagged and that wait for your check (they carry the _agent tag). Select Library overview, or ask Sub-Sub to list them.",
|
|
31
32
|
serversDown: "Not running: {x}",
|
|
32
33
|
working: "Working",
|
|
33
34
|
using: "Using: {x}",
|
|
@@ -44,6 +45,15 @@ export const STRINGS = {
|
|
|
44
45
|
modelTitleResearcher: "Model for the Researcher",
|
|
45
46
|
modelTitleLibrarian: "Model for the Librarian",
|
|
46
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}",
|
|
47
57
|
noModels: "No models yet. Connect a provider below.",
|
|
48
58
|
connectProvider: "Connect a provider",
|
|
49
59
|
provider: "Provider",
|
|
@@ -51,6 +61,13 @@ export const STRINGS = {
|
|
|
51
61
|
save: "Save",
|
|
52
62
|
getKey: "Get a key",
|
|
53
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",
|
|
54
71
|
oauthNote: "To sign in through a provider's website (for example with a subscription), open a terminal, type subsub, then /login.",
|
|
55
72
|
saving: "Saving",
|
|
56
73
|
saved: "Saved.",
|
|
@@ -89,6 +106,7 @@ export const STRINGS = {
|
|
|
89
106
|
librarianHint: "Manages the library only: tags, imports, metadata, PDFs. No literature search.",
|
|
90
107
|
placeholderResearcher: "Ask about your library or the literature, or type / for commands",
|
|
91
108
|
placeholderLibrarian: "Ask for a change to your library, or type / for commands",
|
|
109
|
+
placeholderShort: "Ask, or type / for commands",
|
|
92
110
|
emptyResearcher: "The Researcher reads your library, searches PubMed, Europe PMC and OpenAlex and writes notes. It also imports, tags and repairs items: every change shows a preview, and nothing changes until you approve it.",
|
|
93
111
|
emptyLibrarian: "The Librarian manages your library: tags, imports, metadata and PDFs. Every change shows a preview, and nothing changes until you approve it. For searches and notes, switch to the Researcher.",
|
|
94
112
|
forBoth: "Use it in both modes",
|
|
@@ -120,7 +138,8 @@ export const STRINGS = {
|
|
|
120
138
|
zoteroChecking: "Zotero: a verificar",
|
|
121
139
|
zoteroDown: "O Zotero não está aberto",
|
|
122
140
|
items: "{n} itens",
|
|
123
|
-
toReview: "{n} por
|
|
141
|
+
toReview: "{n} por verificar",
|
|
142
|
+
toReviewHint: "Itens que o Sub-Sub etiquetou e que esperam a sua verificação (têm a etiqueta _agent). Selecione Resumo da biblioteca, ou peça ao Sub-Sub para os listar.",
|
|
124
143
|
serversDown: "Parados: {x}",
|
|
125
144
|
working: "A trabalhar",
|
|
126
145
|
using: "A usar: {x}",
|
|
@@ -137,6 +156,15 @@ export const STRINGS = {
|
|
|
137
156
|
modelTitleResearcher: "Modelo do Investigador",
|
|
138
157
|
modelTitleLibrarian: "Modelo do Bibliotecário",
|
|
139
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}",
|
|
140
168
|
noModels: "Ainda não há modelos. Ligue um fornecedor abaixo.",
|
|
141
169
|
connectProvider: "Ligar um fornecedor",
|
|
142
170
|
provider: "Fornecedor",
|
|
@@ -144,6 +172,13 @@ export const STRINGS = {
|
|
|
144
172
|
save: "Guardar",
|
|
145
173
|
getKey: "Obter uma chave",
|
|
146
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",
|
|
147
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.",
|
|
148
183
|
saving: "A guardar",
|
|
149
184
|
saved: "Guardado.",
|
|
@@ -182,6 +217,7 @@ export const STRINGS = {
|
|
|
182
217
|
librarianHint: "Só gere a biblioteca: etiquetas, importações, metadados, PDF. Não pesquisa a literatura.",
|
|
183
218
|
placeholderResearcher: "Pergunte sobre a sua biblioteca ou a literatura, ou escreva / para ver os comandos",
|
|
184
219
|
placeholderLibrarian: "Peça uma alteração à biblioteca, ou escreva / para ver os comandos",
|
|
220
|
+
placeholderShort: "Pergunte, ou escreva / para os comandos",
|
|
185
221
|
emptyResearcher: "O Investigador lê a sua biblioteca, pesquisa na PubMed, Europe PMC e OpenAlex e escreve notas. Também importa, etiqueta e corrige itens: cada alteração mostra uma pré-visualização, e nada muda até a aprovar.",
|
|
186
222
|
emptyLibrarian: "O Bibliotecário gere a sua biblioteca: etiquetas, importações, metadados e PDF. Cada alteração mostra uma pré-visualização, e nada muda até a aprovar. Para pesquisar e escrever notas, mude para o Investigador.",
|
|
187
223
|
forBoth: "Usar nos dois modos",
|
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>
|