@tiagojct/subsub 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/config.js +22 -17
- package/dist/init.js +9 -8
- package/docs/Subsub.md +4 -2
- package/package.json +1 -1
- package/src/config.ts +22 -17
- package/src/init.ts +9 -7
- package/web/app.css +65 -12
- package/web/app.js +193 -19
- package/web/glauca.css +26 -2
- package/web/i18n.js +24 -0
- package/web/index.html +12 -2
package/README.md
CHANGED
|
@@ -55,7 +55,8 @@ The Zotero servers (zotero-local-mcp) run from PyPI through uv. To run them from
|
|
|
55
55
|
- pi runs in RPC mode with the Sub-Sub extension. Sub-Sub's approval dialog (the server preview) appears in the page, and the change runs only after you select Yes.
|
|
56
56
|
- The server listens only on 127.0.0.1. The address that opens the page carries a random key, which becomes an HttpOnly, SameSite=Strict cookie; other requests need that cookie, the right Host header and a same-origin JSON request. The page has a strict content security policy and shows model output without raw HTML.
|
|
57
57
|
- One server per user: a second `subsub web` opens the running one. Without an open page for 10 minutes (and with nothing waiting for approval), the server stops. `--stay` keeps it running; `--idle N` sets the minutes; `--no-open` does not open a browser; `--librarian` starts in the librarian mode.
|
|
58
|
-
- Labels are in European Portuguese when the language setting is Portuguese, otherwise in English.
|
|
58
|
+
- Labels are in European Portuguese when the language setting is Portuguese, otherwise in English. A theme button in the header toggles light, dark, and system themes.
|
|
59
|
+
- Fast approval shortcuts (`y` to apply, `n` to decline), one-click copy buttons on code blocks and notes, conversation search, and export to Markdown.
|
|
59
60
|
- The model button lists the models you can use and saves an API key for a provider in the same file as `/login`. Sign-ins through a provider's website stay in the terminal (`/login`).
|
|
60
61
|
- A log is kept in `~/.subsub/web.log`.
|
|
61
62
|
|
package/dist/config.js
CHANGED
|
@@ -19,8 +19,9 @@ import { dirname, join, resolve } from "node:path";
|
|
|
19
19
|
export const PROFILES = ["reader", "scholar", "author", "editor"];
|
|
20
20
|
/** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
|
|
21
21
|
export const SERVER_VERSION = "0.4.1";
|
|
22
|
-
export function expand(p) {
|
|
23
|
-
|
|
22
|
+
export function expand(p, home) {
|
|
23
|
+
const base = home ?? process.env.HOME ?? homedir();
|
|
24
|
+
return resolve(p.replace(/^~(?=$|\/)/, base));
|
|
24
25
|
}
|
|
25
26
|
export function parseEnvFile(path) {
|
|
26
27
|
const out = {};
|
|
@@ -41,7 +42,7 @@ export function parseEnvFile(path) {
|
|
|
41
42
|
return out;
|
|
42
43
|
}
|
|
43
44
|
export function configPath(env = process.env) {
|
|
44
|
-
return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json");
|
|
45
|
+
return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json", env.HOME);
|
|
45
46
|
}
|
|
46
47
|
export function readConfigFile(file) {
|
|
47
48
|
if (!existsSync(file))
|
|
@@ -68,6 +69,7 @@ export function isProfile(x) {
|
|
|
68
69
|
return typeof x === "string" && PROFILES.includes(x);
|
|
69
70
|
}
|
|
70
71
|
export function loadConfig(env = process.env) {
|
|
72
|
+
const home = env.HOME;
|
|
71
73
|
const file = configPath(env);
|
|
72
74
|
let user = {};
|
|
73
75
|
if (existsSync(file)) {
|
|
@@ -78,16 +80,18 @@ export function loadConfig(env = process.env) {
|
|
|
78
80
|
throw new Error(`Sub-Sub: cannot read ${file}: ${err.message}`);
|
|
79
81
|
}
|
|
80
82
|
}
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
const explicitEnv = user.envFile ?? env.ZOTERO_MCP_ENV;
|
|
84
|
+
const envFile = explicitEnv
|
|
85
|
+
? expand(explicitEnv, home)
|
|
86
|
+
: [
|
|
87
|
+
"~/.config/zotero-local-mcp/env",
|
|
88
|
+
"~/.config/opencode/zotero.env",
|
|
89
|
+
]
|
|
90
|
+
.map((p) => expand(p, home))
|
|
91
|
+
.find((p) => existsSync(p));
|
|
92
|
+
const envValues = envFile && existsSync(envFile) ? parseEnvFile(envFile) : {};
|
|
89
93
|
const vaultRaw = user.vault ?? env.ZOTERO_VAULT ?? envValues.ZOTERO_VAULT;
|
|
90
|
-
const vault = vaultRaw ? expand(vaultRaw) : undefined;
|
|
94
|
+
const vault = vaultRaw ? expand(vaultRaw, home) : undefined;
|
|
91
95
|
const mode = user.defaultMode === "librarian" ? "librarian" : "researcher";
|
|
92
96
|
return {
|
|
93
97
|
configFile: file,
|
|
@@ -96,7 +100,7 @@ export function loadConfig(env = process.env) {
|
|
|
96
100
|
about: typeof user.about === "string" && user.about.trim() ? user.about.trim() : undefined,
|
|
97
101
|
language: typeof user.language === "string" && user.language.trim() ? user.language.trim() : "English",
|
|
98
102
|
setup: user.setup,
|
|
99
|
-
serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp"),
|
|
103
|
+
serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp", home),
|
|
100
104
|
envFile,
|
|
101
105
|
vault,
|
|
102
106
|
models: user.models ?? {
|
|
@@ -104,14 +108,14 @@ export function loadConfig(env = process.env) {
|
|
|
104
108
|
researcher: "opencode-go/mimo-v2.6-pro",
|
|
105
109
|
},
|
|
106
110
|
defaultMode: mode,
|
|
107
|
-
vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map(expand),
|
|
111
|
+
vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map((p) => expand(p, home)),
|
|
108
112
|
sharedRules: user.sharedRules
|
|
109
|
-
? expand(user.sharedRules)
|
|
113
|
+
? expand(user.sharedRules, home)
|
|
110
114
|
: vault
|
|
111
115
|
? join(vault, "Systems", "Zotero agent.md")
|
|
112
116
|
: undefined,
|
|
113
117
|
startTimeout: user.startTimeout ?? 45,
|
|
114
|
-
uvPath: user.uvPath ? expand(user.uvPath) : undefined,
|
|
118
|
+
uvPath: user.uvPath ? expand(user.uvPath, home) : undefined,
|
|
115
119
|
look: user.look,
|
|
116
120
|
quotes: user.quotes,
|
|
117
121
|
themes: user.themes,
|
|
@@ -119,11 +123,12 @@ export function loadConfig(env = process.env) {
|
|
|
119
123
|
}
|
|
120
124
|
/** uv is often missing from PATH when pi is started outside a login shell. */
|
|
121
125
|
export function findUv(cfg, env = process.env, platform = process.platform) {
|
|
126
|
+
const home = env.HOME;
|
|
122
127
|
const unix = ["~/.local/bin/uv", "/opt/homebrew/bin/uv", "/usr/local/bin/uv", "~/.cargo/bin/uv"];
|
|
123
128
|
const windows = ["~/.local/bin/uv.exe", "~/.cargo/bin/uv.exe", env.LOCALAPPDATA ? join(env.LOCALAPPDATA, "uv", "uv.exe") : ""];
|
|
124
129
|
const candidates = [cfg.uvPath, env.SUBSUB_UV, ...(platform === "win32" ? windows : unix)]
|
|
125
130
|
.filter((x) => Boolean(x))
|
|
126
|
-
.map(expand);
|
|
131
|
+
.map((p) => expand(p, home));
|
|
127
132
|
return candidates.find((p) => existsSync(p)) ?? "uv";
|
|
128
133
|
}
|
|
129
134
|
/**
|
package/dist/init.js
CHANGED
|
@@ -63,11 +63,12 @@ export async function zoteroReachable(fetchFn = fetch) {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
66
|
-
const home = deps.home ?? homedir();
|
|
67
|
-
const
|
|
66
|
+
const home = deps.home ?? env.HOME ?? homedir();
|
|
67
|
+
const initEnv = { ...env, HOME: home };
|
|
68
|
+
const configFile = configPath(initEnv);
|
|
68
69
|
const existing = readConfigFile(configFile);
|
|
69
|
-
const current = loadConfig(
|
|
70
|
-
const envFile = current.envFile ?? expand(
|
|
70
|
+
const current = loadConfig(initEnv);
|
|
71
|
+
const envFile = current.envFile ?? expand(initEnv.ZOTERO_MCP_ENV ?? join(home, ".config", "zotero-local-mcp", "env"), home);
|
|
71
72
|
const envValues = existsSync(envFile) ? parseEnvFile(envFile) : {};
|
|
72
73
|
const created = [];
|
|
73
74
|
io.say(existsSync(configFile) ? `Sub-Sub settings (${configFile}). Press Enter to keep the value in brackets.` : "Sub-Sub setup. Press Enter to take the value in brackets.");
|
|
@@ -79,10 +80,10 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
79
80
|
const userName = (await io.ask("Your name, for Sub-Sub's replies (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
|
|
80
81
|
const about = (await io.ask('One line about you, e.g. "a master\'s student in health informatics" (optional)', flags.about ?? existing.about ?? "")).trim();
|
|
81
82
|
const language = (await io.ask("Language for replies and notes (a language, or auto for the language you write in)", flags.language ?? existing.language ?? "English")).trim() || "English";
|
|
82
|
-
const notes = expand((await io.ask("Notes folder (Markdown files; an Obsidian vault works)", flags.notes ?? current.vault ?? envValues.ZOTERO_VAULT ?? join(home, "Documents", "Sub-Sub"))).trim());
|
|
83
|
+
const notes = expand((await io.ask("Notes folder (Markdown files; an Obsidian vault works)", flags.notes ?? current.vault ?? envValues.ZOTERO_VAULT ?? join(home, "Documents", "Sub-Sub"))).trim(), home);
|
|
83
84
|
const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
|
|
84
85
|
const profile = await io.choose("Profile (change it later with /profile)", PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })), isProfile(profileDefault) ? profileDefault : "scholar");
|
|
85
|
-
const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB) : join(notes, "Systems", "Zotero tags.md");
|
|
86
|
+
const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB, home) : join(notes, "Systems", "Zotero tags.md");
|
|
86
87
|
let tags = "keep";
|
|
87
88
|
if (existsSync(vocab)) {
|
|
88
89
|
io.say(`Tag list: keeping ${vocab}.`);
|
|
@@ -96,7 +97,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
96
97
|
{ value: "later", label: "Choose later in Sub-Sub (/login, then /model)" },
|
|
97
98
|
{ value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the librarian, ${TESTED_MODELS.researcher} for the researcher` },
|
|
98
99
|
];
|
|
99
|
-
const agentDir =
|
|
100
|
+
const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
|
|
100
101
|
const modelDefault = existing.models ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
|
|
101
102
|
const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
|
|
102
103
|
// ---- write
|
|
@@ -132,7 +133,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
|
|
|
132
133
|
patch.models = {};
|
|
133
134
|
if (models === "opencode-go")
|
|
134
135
|
patch.models = TESTED_MODELS;
|
|
135
|
-
saveConfig(patch,
|
|
136
|
+
saveConfig(patch, initEnv);
|
|
136
137
|
const zotero = await zoteroReachable(deps.fetch);
|
|
137
138
|
io.say("");
|
|
138
139
|
io.say(`Saved: ${configFile}`);
|
package/docs/Subsub.md
CHANGED
|
@@ -58,8 +58,10 @@ Templates (type the name, then the argument):
|
|
|
58
58
|
2. To add a shortcut (Applications on macOS), type `subsub shortcut`. To remove it, type `subsub shortcut --remove`.
|
|
59
59
|
3. The server listens only on 127.0.0.1 and needs the random key in the address that `subsub web` opens. A second `subsub web` opens the running one.
|
|
60
60
|
4. The server stops 10 minutes after the last page closes. `subsub web --stay` keeps it running.
|
|
61
|
-
5. The labels are in European Portuguese when the language setting is Portuguese.
|
|
62
|
-
6.
|
|
61
|
+
5. The labels are in European Portuguese when the language setting is Portuguese. A theme toggle button in the header switches between light, dark and system themes.
|
|
62
|
+
6. Faster review: in approval dialogs, press `y` to apply or `n` to cancel without using the mouse.
|
|
63
|
+
7. One-click copy buttons appear on every code block and assistant note. The sidebar includes real-time conversation search and an Export button to download the discussion as Markdown.
|
|
64
|
+
8. The model button lists the models and saves an API key for a provider (the same auth.json as `/login`).
|
|
63
65
|
|
|
64
66
|
The log is in ~/.subsub/web.log.
|
|
65
67
|
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -59,8 +59,9 @@ export interface SubsubConfig {
|
|
|
59
59
|
themes?: Partial<Record<Mode, string>> | false;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
export function expand(p: string): string {
|
|
63
|
-
|
|
62
|
+
export function expand(p: string, home?: string): string {
|
|
63
|
+
const base = home ?? process.env.HOME ?? homedir();
|
|
64
|
+
return resolve(p.replace(/^~(?=$|\/)/, base));
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
export function parseEnvFile(path: string): Record<string, string> {
|
|
@@ -81,7 +82,7 @@ export function parseEnvFile(path: string): Record<string, string> {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
export function configPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
84
|
-
return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json");
|
|
85
|
+
return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json", env.HOME);
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
export function readConfigFile(file: string): Record<string, unknown> {
|
|
@@ -108,6 +109,7 @@ export function isProfile(x: unknown): x is Profile {
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
112
|
+
const home = env.HOME;
|
|
111
113
|
const file = configPath(env);
|
|
112
114
|
let user: Partial<SubsubConfig> = {};
|
|
113
115
|
if (existsSync(file)) {
|
|
@@ -117,16 +119,18 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
117
119
|
throw new Error(`Sub-Sub: cannot read ${file}: ${(err as Error).message}`);
|
|
118
120
|
}
|
|
119
121
|
}
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
122
|
+
const explicitEnv = user.envFile ?? env.ZOTERO_MCP_ENV;
|
|
123
|
+
const envFile = explicitEnv
|
|
124
|
+
? expand(explicitEnv, home)
|
|
125
|
+
: [
|
|
126
|
+
"~/.config/zotero-local-mcp/env",
|
|
127
|
+
"~/.config/opencode/zotero.env",
|
|
128
|
+
]
|
|
129
|
+
.map((p) => expand(p, home))
|
|
130
|
+
.find((p) => existsSync(p));
|
|
131
|
+
const envValues = envFile && existsSync(envFile) ? parseEnvFile(envFile) : {};
|
|
128
132
|
const vaultRaw = user.vault ?? env.ZOTERO_VAULT ?? envValues.ZOTERO_VAULT;
|
|
129
|
-
const vault = vaultRaw ? expand(vaultRaw) : undefined;
|
|
133
|
+
const vault = vaultRaw ? expand(vaultRaw, home) : undefined;
|
|
130
134
|
const mode = user.defaultMode === "librarian" ? "librarian" : "researcher";
|
|
131
135
|
return {
|
|
132
136
|
configFile: file,
|
|
@@ -135,7 +139,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
135
139
|
about: typeof user.about === "string" && user.about.trim() ? user.about.trim() : undefined,
|
|
136
140
|
language: typeof user.language === "string" && user.language.trim() ? user.language.trim() : "English",
|
|
137
141
|
setup: user.setup,
|
|
138
|
-
serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp"),
|
|
142
|
+
serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp", home),
|
|
139
143
|
envFile,
|
|
140
144
|
vault,
|
|
141
145
|
models: user.models ?? {
|
|
@@ -143,14 +147,14 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
143
147
|
researcher: "opencode-go/mimo-v2.6-pro",
|
|
144
148
|
},
|
|
145
149
|
defaultMode: mode,
|
|
146
|
-
vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map(expand),
|
|
150
|
+
vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map((p) => expand(p, home)),
|
|
147
151
|
sharedRules: user.sharedRules
|
|
148
|
-
? expand(user.sharedRules)
|
|
152
|
+
? expand(user.sharedRules, home)
|
|
149
153
|
: vault
|
|
150
154
|
? join(vault, "Systems", "Zotero agent.md")
|
|
151
155
|
: undefined,
|
|
152
156
|
startTimeout: user.startTimeout ?? 45,
|
|
153
|
-
uvPath: user.uvPath ? expand(user.uvPath) : undefined,
|
|
157
|
+
uvPath: user.uvPath ? expand(user.uvPath, home) : undefined,
|
|
154
158
|
look: user.look,
|
|
155
159
|
quotes: user.quotes,
|
|
156
160
|
themes: user.themes,
|
|
@@ -159,11 +163,12 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
159
163
|
|
|
160
164
|
/** uv is often missing from PATH when pi is started outside a login shell. */
|
|
161
165
|
export function findUv(cfg: Pick<SubsubConfig, "uvPath">, env: NodeJS.ProcessEnv = process.env, platform: string = process.platform): string {
|
|
166
|
+
const home = env.HOME;
|
|
162
167
|
const unix = ["~/.local/bin/uv", "/opt/homebrew/bin/uv", "/usr/local/bin/uv", "~/.cargo/bin/uv"];
|
|
163
168
|
const windows = ["~/.local/bin/uv.exe", "~/.cargo/bin/uv.exe", env.LOCALAPPDATA ? join(env.LOCALAPPDATA, "uv", "uv.exe") : ""];
|
|
164
169
|
const candidates = [cfg.uvPath, env.SUBSUB_UV, ...(platform === "win32" ? windows : unix)]
|
|
165
170
|
.filter((x): x is string => Boolean(x))
|
|
166
|
-
.map(expand);
|
|
171
|
+
.map((p) => expand(p, home));
|
|
167
172
|
return candidates.find((p) => existsSync(p)) ?? "uv";
|
|
168
173
|
}
|
|
169
174
|
|
package/src/init.ts
CHANGED
|
@@ -85,11 +85,12 @@ export async function runInit(
|
|
|
85
85
|
env: NodeJS.ProcessEnv = process.env,
|
|
86
86
|
deps: { fetch?: typeof fetch; home?: string } = {},
|
|
87
87
|
): Promise<InitResult> {
|
|
88
|
-
const home = deps.home ?? homedir();
|
|
89
|
-
const
|
|
88
|
+
const home = deps.home ?? env.HOME ?? homedir();
|
|
89
|
+
const initEnv: NodeJS.ProcessEnv = { ...env, HOME: home };
|
|
90
|
+
const configFile = configPath(initEnv);
|
|
90
91
|
const existing = readConfigFile(configFile) as Record<string, any>;
|
|
91
|
-
const current = loadConfig(
|
|
92
|
-
const envFile = current.envFile ?? expand(
|
|
92
|
+
const current = loadConfig(initEnv);
|
|
93
|
+
const envFile = current.envFile ?? expand(initEnv.ZOTERO_MCP_ENV ?? join(home, ".config", "zotero-local-mcp", "env"), home);
|
|
93
94
|
const envValues = existsSync(envFile) ? parseEnvFile(envFile) : {};
|
|
94
95
|
const created: string[] = [];
|
|
95
96
|
|
|
@@ -109,6 +110,7 @@ export async function runInit(
|
|
|
109
110
|
const language = (await io.ask("Language for replies and notes (a language, or auto for the language you write in)", flags.language ?? existing.language ?? "English")).trim() || "English";
|
|
110
111
|
const notes = expand(
|
|
111
112
|
(await io.ask("Notes folder (Markdown files; an Obsidian vault works)", flags.notes ?? current.vault ?? envValues.ZOTERO_VAULT ?? join(home, "Documents", "Sub-Sub"))).trim(),
|
|
113
|
+
home,
|
|
112
114
|
);
|
|
113
115
|
const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
|
|
114
116
|
const profile = await io.choose(
|
|
@@ -117,7 +119,7 @@ export async function runInit(
|
|
|
117
119
|
isProfile(profileDefault) ? profileDefault : "scholar",
|
|
118
120
|
);
|
|
119
121
|
|
|
120
|
-
const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB) : join(notes, "Systems", "Zotero tags.md");
|
|
122
|
+
const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB, home) : join(notes, "Systems", "Zotero tags.md");
|
|
121
123
|
let tags = "keep";
|
|
122
124
|
if (existsSync(vocab)) {
|
|
123
125
|
io.say(`Tag list: keeping ${vocab}.`);
|
|
@@ -136,7 +138,7 @@ export async function runInit(
|
|
|
136
138
|
{ value: "later", label: "Choose later in Sub-Sub (/login, then /model)" },
|
|
137
139
|
{ value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the librarian, ${TESTED_MODELS.researcher} for the researcher` },
|
|
138
140
|
];
|
|
139
|
-
const agentDir =
|
|
141
|
+
const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
|
|
140
142
|
const modelDefault = existing.models ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
|
|
141
143
|
const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
|
|
142
144
|
|
|
@@ -172,7 +174,7 @@ export async function runInit(
|
|
|
172
174
|
};
|
|
173
175
|
if (models === "later") patch.models = {};
|
|
174
176
|
if (models === "opencode-go") patch.models = TESTED_MODELS;
|
|
175
|
-
saveConfig(patch,
|
|
177
|
+
saveConfig(patch, initEnv);
|
|
176
178
|
|
|
177
179
|
const zotero = await zoteroReachable(deps.fetch);
|
|
178
180
|
io.say("");
|
package/web/app.css
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
--side-w: 17rem;
|
|
15
15
|
}
|
|
16
16
|
@media (prefers-color-scheme: dark) {
|
|
17
|
-
:root {
|
|
17
|
+
:root:not([data-theme="light"]) {
|
|
18
18
|
--gl-bg: #10161c;
|
|
19
19
|
--gl-surface: #171f26;
|
|
20
20
|
--gl-surface-raised: #1f2932;
|
|
@@ -31,9 +31,28 @@
|
|
|
31
31
|
--warn: #e3b35c;
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
[data-theme="dark"], html[data-theme="dark"] {
|
|
35
|
+
--gl-bg: #10161c;
|
|
36
|
+
--gl-surface: #171f26;
|
|
37
|
+
--gl-surface-raised: #1f2932;
|
|
38
|
+
--gl-text: #e8eef2;
|
|
39
|
+
--gl-text-muted: #9aa4ab;
|
|
40
|
+
--gl-border: #2a3540;
|
|
41
|
+
--gl-accent: #3d97ff;
|
|
42
|
+
--gl-accent-bright: #6cb2ff;
|
|
43
|
+
--gl-accent-deep: #007aff;
|
|
44
|
+
--gl-on-accent: #0b1218;
|
|
45
|
+
--gl-tint-pale: #93b7c9;
|
|
46
|
+
--add: #8fcf9a;
|
|
47
|
+
--rem: #f0a39c;
|
|
48
|
+
--warn: #e3b35c;
|
|
49
|
+
}
|
|
34
50
|
body[data-mode="librarian"] { --mode: #a8520f; --mode-deep: #7f3d09; --on-mode: #fff8f0; }
|
|
35
51
|
@media (prefers-color-scheme: dark) {
|
|
36
|
-
body[data-mode="librarian"] { --mode: #e0832a; --mode-deep: #f09a48; --on-mode: #140c04; }
|
|
52
|
+
:root:not([data-theme="light"]) body[data-mode="librarian"] { --mode: #e0832a; --mode-deep: #f09a48; --on-mode: #140c04; }
|
|
53
|
+
}
|
|
54
|
+
[data-theme="dark"] body[data-mode="librarian"], html[data-theme="dark"] body[data-mode="librarian"] {
|
|
55
|
+
--mode: #e0832a; --mode-deep: #f09a48; --on-mode: #140c04;
|
|
37
56
|
}
|
|
38
57
|
|
|
39
58
|
*, *::before, *::after { box-sizing: border-box; }
|
|
@@ -41,6 +60,7 @@ html, body { height: 100%; }
|
|
|
41
60
|
body {
|
|
42
61
|
margin: 0; background: var(--gl-bg); color: var(--gl-text);
|
|
43
62
|
font-family: var(--gl-font-sans); font-size: 1rem; line-height: 1.55;
|
|
63
|
+
transition: background-color 0.2s ease, color 0.2s ease;
|
|
44
64
|
}
|
|
45
65
|
button, input, select, textarea { font: inherit; color: inherit; }
|
|
46
66
|
a { color: var(--gl-accent); }
|
|
@@ -74,23 +94,34 @@ button:disabled { opacity: 0.5; cursor: default; }
|
|
|
74
94
|
.modes button { border-radius: var(--gl-radius-pill); padding: 0.3rem 0.9rem; color: var(--gl-text-muted); }
|
|
75
95
|
.modes button[aria-pressed="true"] { background: var(--mode); color: var(--on-mode); font-weight: 600; }
|
|
76
96
|
.status { margin-left: auto; display: flex; align-items: center; gap: 0.5rem 0.9rem; flex-wrap: wrap; font-size: 0.88rem; }
|
|
77
|
-
.library { color: var(--gl-text-muted); }
|
|
97
|
+
.library { display: inline-flex; align-items: center; gap: 0.45rem; color: var(--gl-text-muted); }
|
|
98
|
+
.library-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--gl-border); flex: none; transition: background 0.2s ease, box-shadow 0.2s ease; }
|
|
99
|
+
.library.reachable .library-dot { background: var(--add); box-shadow: 0 0 6px var(--add); }
|
|
100
|
+
.library.down .library-dot { background: var(--rem); box-shadow: 0 0 6px var(--rem); }
|
|
78
101
|
.library.down { color: var(--rem); font-weight: 600; }
|
|
102
|
+
.library.checking .library-dot { background: var(--warn); animation: pulse 1.2s ease-in-out infinite; }
|
|
79
103
|
.status select { border: 1px solid var(--gl-border); border-radius: var(--gl-radius-md); background: var(--gl-surface-raised); padding: 0.3rem 0.5rem; }
|
|
80
|
-
.model-btn { font-family: var(--gl-font-mono); font-size: 0.8rem; border: 1px solid var(--gl-border); }
|
|
104
|
+
.model-btn, .theme-btn { font-family: var(--gl-font-mono); font-size: 0.8rem; border: 1px solid var(--gl-border); }
|
|
105
|
+
.theme-btn { display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.6rem; }
|
|
81
106
|
|
|
82
107
|
.side { border-right: 1px solid var(--gl-border); background: var(--gl-surface); padding: 1rem; display: flex; flex-direction: column; gap: 1.25rem; overflow-y: auto; }
|
|
83
108
|
.side h2 { font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--gl-text-muted); margin: 0 0 0.5rem; font-weight: 600; }
|
|
84
|
-
.side .grow { flex: 1; }
|
|
109
|
+
.side .grow { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
|
110
|
+
.side-subhead { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4rem; }
|
|
111
|
+
.side-subhead h2 { margin: 0; }
|
|
112
|
+
.search-wrap { margin-bottom: 0.6rem; }
|
|
113
|
+
.search-wrap input { width: 100%; font-size: 0.8rem; padding: 0.35rem 0.55rem; border: 1px solid var(--gl-border); border-radius: var(--gl-radius-md); background: var(--gl-surface-raised); color: var(--gl-text); }
|
|
114
|
+
.search-wrap input:focus { outline: none; border-color: var(--mode); }
|
|
85
115
|
.actions { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
86
116
|
.actions button { text-align: left; padding: 0.35rem 0.6rem; border-radius: var(--gl-radius-md); }
|
|
87
117
|
.actions button:hover { background: var(--gl-bg); }
|
|
88
118
|
.actions hr { border: 0; border-top: 1px solid var(--gl-border); margin: 0.35rem 0; width: 100%; }
|
|
89
|
-
.sessions { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.15rem; }
|
|
90
|
-
.sessions button { width: 100%; text-align: left; padding: 0.4rem 0.6rem; display: block; }
|
|
119
|
+
.sessions { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.15rem; overflow-y: auto; flex: 1; }
|
|
120
|
+
.sessions button { width: 100%; text-align: left; padding: 0.4rem 0.6rem; display: block; border-radius: var(--gl-radius-md); }
|
|
91
121
|
.sessions button:hover { background: var(--gl-bg); }
|
|
92
122
|
.sessions .current button { background: var(--gl-bg); border-color: var(--gl-border); }
|
|
93
|
-
.sessions .when { display:
|
|
123
|
+
.sessions .when { display: flex; justify-content: space-between; font-size: 0.75rem; color: var(--gl-text-muted); margin-top: 0.15rem; }
|
|
124
|
+
.sessions .badge { font-family: var(--gl-font-mono); font-size: 0.7rem; background: var(--gl-surface-raised); border: 1px solid var(--gl-border); border-radius: var(--gl-radius-sm); padding: 0 0.3rem; }
|
|
94
125
|
.sessions .what { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 0.88rem; }
|
|
95
126
|
.side-foot { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
|
|
96
127
|
.version { font-size: 0.75rem; color: var(--gl-text-muted); font-family: var(--gl-font-mono); }
|
|
@@ -112,6 +143,27 @@ button:disabled { opacity: 0.5; cursor: default; }
|
|
|
112
143
|
.md code { font-family: var(--gl-font-mono); font-size: 0.86em; background: var(--gl-surface); border: 1px solid var(--gl-border); border-radius: var(--gl-radius-sm); padding: 0.05em 0.3em; }
|
|
113
144
|
.md pre { background: var(--gl-surface); border: 1px solid var(--gl-border); border-radius: var(--gl-radius-md); padding: 0.75rem; overflow-x: auto; font-size: 0.85rem; }
|
|
114
145
|
.md pre code { background: none; border: 0; padding: 0; }
|
|
146
|
+
.pre-wrap { position: relative; margin: 0.6rem 0; }
|
|
147
|
+
.pre-wrap pre { margin: 0; }
|
|
148
|
+
.copy-btn {
|
|
149
|
+
position: absolute; top: 0.4rem; right: 0.4rem;
|
|
150
|
+
font-family: var(--gl-font-mono); font-size: 0.72rem;
|
|
151
|
+
background: var(--gl-surface-raised); border: 1px solid var(--gl-border);
|
|
152
|
+
border-radius: var(--gl-radius-sm); padding: 0.15rem 0.5rem;
|
|
153
|
+
color: var(--gl-text-muted); cursor: pointer; opacity: 0;
|
|
154
|
+
transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
|
155
|
+
z-index: 2;
|
|
156
|
+
}
|
|
157
|
+
.pre-wrap:hover .copy-btn, .copy-btn:focus-visible { opacity: 1; }
|
|
158
|
+
.copy-btn:hover { color: var(--gl-text); border-color: var(--mode); }
|
|
159
|
+
.copy-btn.copied { opacity: 1; color: var(--add); border-color: var(--add); }
|
|
160
|
+
|
|
161
|
+
.msg-actions { display: flex; gap: 0.4rem; margin-top: 0.45rem; opacity: 0; transition: opacity 0.15s ease; }
|
|
162
|
+
.msg.assistant:hover .msg-actions, .msg-actions:focus-within { opacity: 1; }
|
|
163
|
+
.msg-action-btn { font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: var(--gl-radius-sm); color: var(--gl-text-muted); border: 1px solid transparent; background: none; }
|
|
164
|
+
.msg-action-btn:hover { color: var(--gl-text); background: var(--gl-surface-raised); border-color: var(--gl-border); }
|
|
165
|
+
.msg-action-btn.copied { color: var(--add); border-color: var(--add); }
|
|
166
|
+
|
|
115
167
|
.md blockquote { margin: 0.6rem 0; padding-left: 0.9rem; border-left: 3px solid var(--gl-tint-pale); color: var(--gl-text-muted); }
|
|
116
168
|
.md table { border-collapse: collapse; display: block; overflow-x: auto; font-size: 0.9rem; }
|
|
117
169
|
.md th, .md td { border-bottom: 1px solid var(--gl-border); padding: 0.3rem 0.6rem; text-align: left; vertical-align: top; }
|
|
@@ -158,14 +210,15 @@ button:disabled { opacity: 0.5; cursor: default; }
|
|
|
158
210
|
/* ---- dialogs */
|
|
159
211
|
.modal { border: 1px solid var(--gl-border); border-radius: var(--gl-radius-lg); background: var(--gl-surface-raised); color: var(--gl-text); padding: 1.25rem 1.4rem; width: min(40rem, calc(100vw - 2rem)); max-height: calc(100dvh - 2rem); overflow-y: auto; box-shadow: 0 24px 60px -20px rgba(0,0,0,0.5); }
|
|
160
212
|
.modal.wide { width: min(46rem, calc(100vw - 2rem)); }
|
|
161
|
-
.modal::backdrop { background: rgba(16, 22, 28, 0.55); }
|
|
213
|
+
.modal::backdrop { background: rgba(16, 22, 28, 0.55); backdrop-filter: blur(2px); }
|
|
162
214
|
.modal h2 { font-family: var(--gl-font-serif); font-size: 1.25rem; margin: 0 0 0.25rem; }
|
|
163
215
|
.modal h3 { font-size: 0.95rem; margin: 1.25rem 0 0.5rem; }
|
|
164
216
|
.modal .hint { color: var(--gl-text-muted); margin: 0 0 0.75rem; font-size: 0.9rem; }
|
|
165
217
|
.preview { font-family: var(--gl-font-mono); font-size: 0.82rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--gl-surface); border: 1px solid var(--gl-border); border-radius: var(--gl-radius-md); padding: 0.75rem; max-height: 55vh; overflow-y: auto; margin: 0 0 1rem; }
|
|
166
|
-
.preview .add { color: var(--add); font-weight: 600; }
|
|
167
|
-
.preview .rem { color: var(--rem); font-weight: 600; }
|
|
168
|
-
.modal-buttons { display: flex; justify-content: flex-end; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; }
|
|
218
|
+
.preview .add { color: var(--add); background: rgba(29, 122, 58, 0.12); padding: 0.1em 0.35em; border-radius: var(--gl-radius-sm); font-weight: 600; }
|
|
219
|
+
.preview .rem { color: var(--rem); background: rgba(179, 38, 30, 0.12); padding: 0.1em 0.35em; border-radius: var(--gl-radius-sm); font-weight: 600; }
|
|
220
|
+
.modal-buttons { display: flex; justify-content: flex-end; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; }
|
|
221
|
+
.modal-shortcut-hint { font-size: 0.76rem; color: var(--gl-text-muted); font-family: var(--gl-font-mono); margin-right: auto; }
|
|
169
222
|
.modal-head { display: flex; justify-content: space-between; align-items: center; }
|
|
170
223
|
.modal input[type="text"], .modal input[type="password"], .modal textarea, .modal select { width: 100%; padding: 0.5rem 0.6rem; border: 1px solid var(--gl-border); border-radius: var(--gl-radius-md); background: var(--gl-bg); }
|
|
171
224
|
.modal textarea { min-height: 12rem; font-family: var(--gl-font-mono); font-size: 0.85rem; }
|
package/web/app.js
CHANGED
|
@@ -77,11 +77,49 @@ function md(text) {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
// ----------------------------------------------------------------
|
|
80
|
+
// ---------------------------------------------------------------- theme & language
|
|
81
|
+
|
|
82
|
+
let currentTheme = localStorage.getItem("subsub-theme") || "auto";
|
|
83
|
+
|
|
84
|
+
function themeLabel(theme) {
|
|
85
|
+
if (theme === "light") return t("themeLight");
|
|
86
|
+
if (theme === "dark") return t("themeDark");
|
|
87
|
+
return t("themeAuto");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function updateThemeUI() {
|
|
91
|
+
const icon = currentTheme === "light" ? "☀️" : currentTheme === "dark" ? "🌙" : "💻";
|
|
92
|
+
const label = `${icon} ${themeLabel(currentTheme)}`;
|
|
93
|
+
const btn = $("theme-btn");
|
|
94
|
+
if (btn) {
|
|
95
|
+
btn.textContent = label;
|
|
96
|
+
btn.title = t("themeToggle");
|
|
97
|
+
}
|
|
98
|
+
const sideBtn = $("theme-btn-side");
|
|
99
|
+
if (sideBtn) sideBtn.textContent = label;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function setTheme(theme) {
|
|
103
|
+
currentTheme = theme;
|
|
104
|
+
localStorage.setItem("subsub-theme", theme);
|
|
105
|
+
if (theme === "auto") {
|
|
106
|
+
delete document.documentElement.dataset.theme;
|
|
107
|
+
} else {
|
|
108
|
+
document.documentElement.dataset.theme = theme;
|
|
109
|
+
}
|
|
110
|
+
updateThemeUI();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function toggleTheme() {
|
|
114
|
+
if (currentTheme === "auto") setTheme("light");
|
|
115
|
+
else if (currentTheme === "light") setTheme("dark");
|
|
116
|
+
else setTheme("auto");
|
|
117
|
+
}
|
|
81
118
|
|
|
82
119
|
function applyLang() {
|
|
83
120
|
document.documentElement.lang = lang === "pt" ? "pt-PT" : "en";
|
|
84
121
|
for (const e of document.querySelectorAll("[data-i18n]")) e.textContent = t(e.dataset.i18n);
|
|
122
|
+
updateThemeUI();
|
|
85
123
|
}
|
|
86
124
|
|
|
87
125
|
function mode() {
|
|
@@ -95,24 +133,29 @@ function renderHeader() {
|
|
|
95
133
|
const m = state?.model ?? session?.model;
|
|
96
134
|
$("model-btn").textContent = $("model-btn-side").textContent = m ? m.id : t("noModel");
|
|
97
135
|
const lib = $("library");
|
|
136
|
+
const txt = lib.querySelector(".library-text") ?? lib;
|
|
98
137
|
const L = state?.library;
|
|
99
|
-
lib.classList.remove("down");
|
|
100
|
-
if (!L || L.zotero === "checking")
|
|
101
|
-
|
|
102
|
-
lib.
|
|
138
|
+
lib.classList.remove("down", "reachable", "checking");
|
|
139
|
+
if (!L || L.zotero === "checking") {
|
|
140
|
+
txt.textContent = t("zoteroChecking");
|
|
141
|
+
lib.classList.add("checking");
|
|
142
|
+
} else if (L.zotero === "down") {
|
|
143
|
+
txt.textContent = t("zoteroDown");
|
|
103
144
|
lib.classList.add("down");
|
|
104
145
|
} else {
|
|
105
146
|
const parts = [t("items", { n: L.items ?? "?" })];
|
|
106
147
|
if (L.toReview) parts.push(t("toReview", { n: L.toReview }));
|
|
107
|
-
|
|
148
|
+
txt.textContent = `Zotero: ${parts.join(", ")}`;
|
|
149
|
+
lib.classList.add("reachable");
|
|
108
150
|
}
|
|
109
151
|
if (state?.down?.length) {
|
|
110
|
-
|
|
152
|
+
txt.textContent += ` | ${t("serversDown", { x: state.down.join(", ") })}`;
|
|
111
153
|
lib.classList.add("down");
|
|
112
154
|
}
|
|
113
155
|
$("input").placeholder = mode() === "librarian" ? t("placeholderLibrarian") : t("placeholderResearcher");
|
|
114
156
|
renderActions();
|
|
115
157
|
renderBanner();
|
|
158
|
+
updateThemeUI();
|
|
116
159
|
}
|
|
117
160
|
|
|
118
161
|
function hasCommand(name) {
|
|
@@ -156,21 +199,21 @@ function renderBanner() {
|
|
|
156
199
|
} else b.hidden = true;
|
|
157
200
|
}
|
|
158
201
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
list = (await api("/api/sessions")).sessions ?? [];
|
|
163
|
-
} catch {
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
202
|
+
let loadedSessions = [];
|
|
203
|
+
|
|
204
|
+
function renderSessionsList(filterText = "") {
|
|
166
205
|
const ul = $("sessions");
|
|
167
206
|
ul.replaceChildren();
|
|
168
|
-
|
|
207
|
+
const q = filterText.trim().toLowerCase();
|
|
208
|
+
const filtered = q
|
|
209
|
+
? loadedSessions.filter((s) => (s.name || s.first || "").toLowerCase().includes(q))
|
|
210
|
+
: loadedSessions;
|
|
211
|
+
if (!filtered.length) {
|
|
169
212
|
ul.append(el("li", { class: "muted small", text: t("noConversations") }));
|
|
170
213
|
return;
|
|
171
214
|
}
|
|
172
215
|
const fmt = new Intl.DateTimeFormat(lang === "pt" ? "pt-PT" : "en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
|
173
|
-
for (const s of
|
|
216
|
+
for (const s of filtered) {
|
|
174
217
|
const current = session?.sessionFile === s.path;
|
|
175
218
|
ul.append(
|
|
176
219
|
el(
|
|
@@ -192,13 +235,57 @@ async function loadSessions() {
|
|
|
192
235
|
},
|
|
193
236
|
},
|
|
194
237
|
el("span", { class: "what", text: s.name || s.first || "…" }),
|
|
195
|
-
el(
|
|
238
|
+
el(
|
|
239
|
+
"span",
|
|
240
|
+
{ class: "when" },
|
|
241
|
+
el("span", { text: fmt.format(new Date(s.modified)) }),
|
|
242
|
+
s.messages ? el("span", { class: "badge", text: `${s.messages}` }) : null,
|
|
243
|
+
),
|
|
196
244
|
),
|
|
197
245
|
),
|
|
198
246
|
);
|
|
199
247
|
}
|
|
200
248
|
}
|
|
201
249
|
|
|
250
|
+
async function loadSessions() {
|
|
251
|
+
try {
|
|
252
|
+
loadedSessions = (await api("/api/sessions")).sessions ?? [];
|
|
253
|
+
} catch {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
renderSessionsList($("session-search")?.value ?? "");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function exportConversation() {
|
|
260
|
+
const msgs = [];
|
|
261
|
+
const items = log.querySelectorAll(".msg");
|
|
262
|
+
if (!items.length) {
|
|
263
|
+
note(t("noConversations"), "warning");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
for (const m of items) {
|
|
267
|
+
if (m.classList.contains("user")) {
|
|
268
|
+
const text = m.querySelector(".bubble")?.innerText ?? "";
|
|
269
|
+
if (text && text !== "/subsub-refresh") msgs.push(`### User\n\n${text}\n`);
|
|
270
|
+
} else if (m.classList.contains("assistant")) {
|
|
271
|
+
const text = Array.from(m.querySelectorAll(".md")).map((d) => d.innerText).join("\n\n");
|
|
272
|
+
if (text) msgs.push(`### Sub-Sub (${mode()})\n\n${text}\n`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (!msgs.length) return;
|
|
276
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
277
|
+
const content = `# Sub-Sub Research Session (${date})\n\n**Mode**: ${mode()} \n**Profile**: ${state?.profile ?? "scholar"} \n\n---\n\n${msgs.join("\n---\n\n")}`;
|
|
278
|
+
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
|
|
279
|
+
const url = URL.createObjectURL(blob);
|
|
280
|
+
const a = document.createElement("a");
|
|
281
|
+
a.href = url;
|
|
282
|
+
a.download = `Sub-Sub-${mode()}-${date}.md`;
|
|
283
|
+
document.body.append(a);
|
|
284
|
+
a.click();
|
|
285
|
+
a.remove();
|
|
286
|
+
URL.revokeObjectURL(url);
|
|
287
|
+
}
|
|
288
|
+
|
|
202
289
|
function closeSide() {
|
|
203
290
|
$("side").classList.remove("open");
|
|
204
291
|
$("menu-btn").setAttribute("aria-expanded", "false");
|
|
@@ -261,6 +348,7 @@ function textOf(content) {
|
|
|
261
348
|
const sentTemplates = [];
|
|
262
349
|
|
|
263
350
|
function userMessage(text) {
|
|
351
|
+
if (String(text).trim() === "/subsub-refresh") return;
|
|
264
352
|
clearEmpty();
|
|
265
353
|
let bubble;
|
|
266
354
|
if (text.length > 700) {
|
|
@@ -332,6 +420,35 @@ function finishTool(id, result, isError) {
|
|
|
332
420
|
if (isError) row.querySelector(".label").textContent = `${toolLabel(row.dataset.name)} (${t("failed")})`;
|
|
333
421
|
}
|
|
334
422
|
|
|
423
|
+
function attachCopyButtons(root) {
|
|
424
|
+
for (const pre of root.querySelectorAll("pre")) {
|
|
425
|
+
if (pre.parentElement?.classList.contains("pre-wrap")) continue;
|
|
426
|
+
const wrap = el("div", { class: "pre-wrap" });
|
|
427
|
+
pre.parentNode.insertBefore(wrap, pre);
|
|
428
|
+
wrap.append(pre);
|
|
429
|
+
const btn = el("button", {
|
|
430
|
+
type: "button",
|
|
431
|
+
class: "copy-btn",
|
|
432
|
+
text: t("copy"),
|
|
433
|
+
onclick: async () => {
|
|
434
|
+
const code = pre.querySelector("code")?.innerText ?? pre.innerText;
|
|
435
|
+
try {
|
|
436
|
+
await navigator.clipboard.writeText(code);
|
|
437
|
+
btn.textContent = t("copied");
|
|
438
|
+
btn.classList.add("copied");
|
|
439
|
+
setTimeout(() => {
|
|
440
|
+
btn.textContent = t("copy");
|
|
441
|
+
btn.classList.remove("copied");
|
|
442
|
+
}, 2000);
|
|
443
|
+
} catch {
|
|
444
|
+
btn.textContent = t("error");
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
wrap.append(btn);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
335
452
|
/** A container for one assistant message; blocks are keyed by content index. */
|
|
336
453
|
function startAssistant() {
|
|
337
454
|
clearEmpty();
|
|
@@ -357,6 +474,7 @@ function paint(msg) {
|
|
|
357
474
|
msg.raf = 0;
|
|
358
475
|
for (const b of msg.blocks.values()) if (b.kind === "text" && b.dirty) {
|
|
359
476
|
b.node.innerHTML = md(b.text);
|
|
477
|
+
attachCopyButtons(b.node);
|
|
360
478
|
b.dirty = false;
|
|
361
479
|
}
|
|
362
480
|
scrollDown();
|
|
@@ -366,10 +484,13 @@ function paint(msg) {
|
|
|
366
484
|
function renderAssistantFinal(message, msg = startAssistant()) {
|
|
367
485
|
msg.box.replaceChildren();
|
|
368
486
|
msg.blocks.clear();
|
|
487
|
+
let fullText = "";
|
|
369
488
|
for (const c of message.content ?? []) {
|
|
370
489
|
if (c.type === "text" && c.text?.trim()) {
|
|
490
|
+
fullText += (fullText ? "\n\n" : "") + c.text;
|
|
371
491
|
const node = el("div", { class: "md" });
|
|
372
492
|
node.innerHTML = md(c.text);
|
|
493
|
+
attachCopyButtons(node);
|
|
373
494
|
msg.box.append(node);
|
|
374
495
|
} else if (c.type === "toolCall") {
|
|
375
496
|
const row = toolRow(c.id, c.name, c.arguments);
|
|
@@ -377,6 +498,32 @@ function renderAssistantFinal(message, msg = startAssistant()) {
|
|
|
377
498
|
msg.box.append(row);
|
|
378
499
|
}
|
|
379
500
|
}
|
|
501
|
+
if (fullText.trim()) {
|
|
502
|
+
const actions = el(
|
|
503
|
+
"div",
|
|
504
|
+
{ class: "msg-actions" },
|
|
505
|
+
el("button", {
|
|
506
|
+
type: "button",
|
|
507
|
+
class: "msg-action-btn",
|
|
508
|
+
text: t("copyNote"),
|
|
509
|
+
onclick: async (e) => {
|
|
510
|
+
const btn = e.currentTarget;
|
|
511
|
+
try {
|
|
512
|
+
await navigator.clipboard.writeText(fullText);
|
|
513
|
+
btn.textContent = t("copied");
|
|
514
|
+
btn.classList.add("copied");
|
|
515
|
+
setTimeout(() => {
|
|
516
|
+
btn.textContent = t("copyNote");
|
|
517
|
+
btn.classList.remove("copied");
|
|
518
|
+
}, 2000);
|
|
519
|
+
} catch {
|
|
520
|
+
btn.textContent = t("error");
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
525
|
+
msg.box.append(actions);
|
|
526
|
+
}
|
|
380
527
|
if (message.stopReason === "error" && message.errorMessage) msg.box.append(el("div", { class: "note error", text: message.errorMessage }));
|
|
381
528
|
if (message.stopReason === "aborted") msg.box.append(el("div", { class: "note", text: t("stopped") }));
|
|
382
529
|
if (!msg.box.children.length) msg.box.remove();
|
|
@@ -388,7 +535,11 @@ function renderHistory(messages) {
|
|
|
388
535
|
toolRows.clear();
|
|
389
536
|
currentMsg = null;
|
|
390
537
|
for (const m of messages ?? []) {
|
|
391
|
-
if (m.role === "user")
|
|
538
|
+
if (m.role === "user") {
|
|
539
|
+
const txt = textOf(m.content).trim();
|
|
540
|
+
if (txt === "/subsub-refresh") continue;
|
|
541
|
+
userMessage(txt);
|
|
542
|
+
}
|
|
392
543
|
else if (m.role === "assistant") renderAssistantFinal(m);
|
|
393
544
|
else if (m.role === "toolResult") finishTool(m.toolCallId, m, m.isError);
|
|
394
545
|
else if (m.role === "custom" && m.display) note(textOf(m.content));
|
|
@@ -447,7 +598,10 @@ function onEvent(ev) {
|
|
|
447
598
|
refreshSession();
|
|
448
599
|
break;
|
|
449
600
|
case "message_start":
|
|
450
|
-
if (ev.message?.role === "user")
|
|
601
|
+
if (ev.message?.role === "user") {
|
|
602
|
+
const txt = (sentTemplates.shift() ?? textOf(ev.message.content)).trim();
|
|
603
|
+
if (txt !== "/subsub-refresh") userMessage(txt);
|
|
604
|
+
}
|
|
451
605
|
else if (ev.message?.role === "assistant") startAssistant();
|
|
452
606
|
else if (ev.message?.role === "custom" && ev.message.display) note(textOf(ev.message.content));
|
|
453
607
|
if (working) log.append(working);
|
|
@@ -599,6 +753,7 @@ function nextDialog() {
|
|
|
599
753
|
};
|
|
600
754
|
if (d.method === "confirm") {
|
|
601
755
|
buttons.append(
|
|
756
|
+
el("span", { class: "modal-shortcut-hint", text: t("confirmShortcutHint") }),
|
|
602
757
|
el("button", { type: "button", class: "secondary", text: t("no"), onclick: () => answer({ confirmed: false }) }),
|
|
603
758
|
el("button", { type: "button", class: "primary", text: t("yes"), onclick: () => answer({ confirmed: true }) }),
|
|
604
759
|
);
|
|
@@ -614,6 +769,17 @@ function nextDialog() {
|
|
|
614
769
|
el("button", { type: "button", class: "primary", text: t("ok"), onclick: () => answer({ value: field.value }) }),
|
|
615
770
|
);
|
|
616
771
|
}
|
|
772
|
+
dlg.onkeydown = (e) => {
|
|
773
|
+
if (d.method === "confirm") {
|
|
774
|
+
if (e.key === "y" || e.key === "Y" || (e.key === "Enter" && (e.metaKey || e.ctrlKey))) {
|
|
775
|
+
e.preventDefault();
|
|
776
|
+
answer({ confirmed: true });
|
|
777
|
+
} else if (e.key === "n" || e.key === "N") {
|
|
778
|
+
e.preventDefault();
|
|
779
|
+
answer({ confirmed: false });
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
};
|
|
617
783
|
dlg.oncancel = (e) => {
|
|
618
784
|
e.preventDefault();
|
|
619
785
|
answer(d.method === "confirm" ? { confirmed: false } : { cancelled: true });
|
|
@@ -823,6 +989,13 @@ $("menu-btn").addEventListener("click", (e) => {
|
|
|
823
989
|
side.classList.toggle("open");
|
|
824
990
|
$("menu-btn").setAttribute("aria-expanded", String(side.classList.contains("open")));
|
|
825
991
|
});
|
|
992
|
+
$("theme-btn")?.addEventListener("click", toggleTheme);
|
|
993
|
+
$("theme-btn-side")?.addEventListener("click", () => {
|
|
994
|
+
closeSide();
|
|
995
|
+
toggleTheme();
|
|
996
|
+
});
|
|
997
|
+
$("session-search")?.addEventListener("input", (e) => renderSessionsList(e.target.value));
|
|
998
|
+
$("export-btn")?.addEventListener("click", exportConversation);
|
|
826
999
|
$("quit-btn").addEventListener("click", async () => {
|
|
827
1000
|
try {
|
|
828
1001
|
await api("/api/quit", {});
|
|
@@ -855,5 +1028,6 @@ function connect() {
|
|
|
855
1028
|
};
|
|
856
1029
|
}
|
|
857
1030
|
|
|
1031
|
+
setTheme(currentTheme);
|
|
858
1032
|
applyLang();
|
|
859
1033
|
connect();
|
package/web/glauca.css
CHANGED
|
@@ -45,7 +45,9 @@
|
|
|
45
45
|
|
|
46
46
|
/* Pruina is the light default; Profundum is dark. */
|
|
47
47
|
:root,
|
|
48
|
-
[data-mode="light"]
|
|
48
|
+
[data-mode="light"],
|
|
49
|
+
[data-theme="light"],
|
|
50
|
+
html[data-theme="light"] {
|
|
49
51
|
--gl-bg: #f0f4f6;
|
|
50
52
|
--gl-surface: #f7fafb;
|
|
51
53
|
--gl-surface-raised: #ffffff;
|
|
@@ -63,7 +65,9 @@
|
|
|
63
65
|
--gl-on-tint: #f2f7fb;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
[data-mode="dark"]
|
|
68
|
+
[data-mode="dark"],
|
|
69
|
+
[data-theme="dark"],
|
|
70
|
+
html[data-theme="dark"] {
|
|
67
71
|
--gl-bg: #10161c;
|
|
68
72
|
--gl-surface: #171f26;
|
|
69
73
|
--gl-surface-raised: #1f2932;
|
|
@@ -80,3 +84,23 @@
|
|
|
80
84
|
--gl-tint-pale: #93b7c9;
|
|
81
85
|
--gl-on-tint: #0d1720;
|
|
82
86
|
}
|
|
87
|
+
|
|
88
|
+
@media (prefers-color-scheme: dark) {
|
|
89
|
+
:root:not([data-theme="light"]) {
|
|
90
|
+
--gl-bg: #10161c;
|
|
91
|
+
--gl-surface: #171f26;
|
|
92
|
+
--gl-surface-raised: #1f2932;
|
|
93
|
+
--gl-text: #e8eef2;
|
|
94
|
+
--gl-text-muted: #8c8c8c;
|
|
95
|
+
--gl-border: #2a3540;
|
|
96
|
+
--gl-accent: #3d97ff;
|
|
97
|
+
--gl-accent-bright: #6cb2ff;
|
|
98
|
+
--gl-accent-deep: #007aff;
|
|
99
|
+
--gl-on-accent: #0b1218;
|
|
100
|
+
--gl-tint-deep: #142430;
|
|
101
|
+
--gl-tint: #2b4356;
|
|
102
|
+
--gl-tint-bright: #4d7391;
|
|
103
|
+
--gl-tint-pale: #93b7c9;
|
|
104
|
+
--gl-on-tint: #0d1720;
|
|
105
|
+
}
|
|
106
|
+
}
|
package/web/i18n.js
CHANGED
|
@@ -63,6 +63,18 @@ export const STRINGS = {
|
|
|
63
63
|
menu: "Menu",
|
|
64
64
|
commands: "Commands",
|
|
65
65
|
error: "Error",
|
|
66
|
+
theme: "Theme",
|
|
67
|
+
themeAuto: "System",
|
|
68
|
+
themeLight: "Light",
|
|
69
|
+
themeDark: "Dark",
|
|
70
|
+
themeToggle: "Toggle theme",
|
|
71
|
+
copy: "Copy",
|
|
72
|
+
copied: "Copied!",
|
|
73
|
+
copyNote: "Copy text",
|
|
74
|
+
searchConversations: "Search conversations...",
|
|
75
|
+
export: "Export",
|
|
76
|
+
exportTitle: "Export conversation",
|
|
77
|
+
confirmShortcutHint: "Y to approve, N to decline",
|
|
66
78
|
},
|
|
67
79
|
pt: {
|
|
68
80
|
researcher: "Investigador",
|
|
@@ -125,6 +137,18 @@ export const STRINGS = {
|
|
|
125
137
|
menu: "Menu",
|
|
126
138
|
commands: "Comandos",
|
|
127
139
|
error: "Erro",
|
|
140
|
+
theme: "Tema",
|
|
141
|
+
themeAuto: "Sistema",
|
|
142
|
+
themeLight: "Claro",
|
|
143
|
+
themeDark: "Escuro",
|
|
144
|
+
themeToggle: "Mudar tema",
|
|
145
|
+
copy: "Copiar",
|
|
146
|
+
copied: "Copiado!",
|
|
147
|
+
copyNote: "Copiar texto",
|
|
148
|
+
searchConversations: "Procurar conversas...",
|
|
149
|
+
export: "Exportar",
|
|
150
|
+
exportTitle: "Exportar conversa",
|
|
151
|
+
confirmShortcutHint: "Y para aprovar, N para recusar",
|
|
128
152
|
},
|
|
129
153
|
};
|
|
130
154
|
|
package/web/index.html
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<button type="button" data-mode="librarian" aria-pressed="false" data-i18n="librarian">Librarian</button>
|
|
21
21
|
</div>
|
|
22
22
|
<div class="status">
|
|
23
|
-
<span class="library" id="library"></span>
|
|
23
|
+
<span class="library" id="library"><span class="library-dot" aria-hidden="true"></span><span class="library-text"></span></span>
|
|
24
24
|
<label class="profile"><span class="visually-hidden" data-i18n="profile">Profile</span>
|
|
25
25
|
<select id="profile">
|
|
26
26
|
<option value="reader">Reader</option>
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
</select>
|
|
31
31
|
</label>
|
|
32
32
|
<button type="button" class="ghost model-btn" id="model-btn"></button>
|
|
33
|
+
<button type="button" class="ghost theme-btn" id="theme-btn" aria-label="Toggle theme" title="Toggle theme"></button>
|
|
33
34
|
</div>
|
|
34
35
|
</header>
|
|
35
36
|
|
|
@@ -47,13 +48,22 @@
|
|
|
47
48
|
<label><span data-i18n="model">Model</span>
|
|
48
49
|
<button type="button" class="secondary model-btn" id="model-btn-side"></button>
|
|
49
50
|
</label>
|
|
51
|
+
<label><span data-i18n="theme">Theme</span>
|
|
52
|
+
<button type="button" class="secondary theme-btn" id="theme-btn-side"></button>
|
|
53
|
+
</label>
|
|
50
54
|
</section>
|
|
51
55
|
<section>
|
|
52
56
|
<h2 data-i18n="actions">Actions</h2>
|
|
53
57
|
<div class="actions" id="actions"></div>
|
|
54
58
|
</section>
|
|
55
59
|
<section class="grow">
|
|
56
|
-
<
|
|
60
|
+
<div class="side-subhead">
|
|
61
|
+
<h2 data-i18n="conversations">Conversations</h2>
|
|
62
|
+
<button type="button" class="ghost small" id="export-btn" title="Export conversation as Markdown" data-i18n="export">Export</button>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="search-wrap">
|
|
65
|
+
<input type="search" id="session-search" placeholder="Search conversations..." autocomplete="off">
|
|
66
|
+
</div>
|
|
57
67
|
<ul class="sessions" id="sessions"></ul>
|
|
58
68
|
</section>
|
|
59
69
|
<footer class="side-foot">
|