privateboard 0.1.0
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/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/cli.js +10502 -0
- package/dist/cli.js.map +1 -0
- package/package.json +63 -0
- package/public/adjourn-overlay.css +253 -0
- package/public/agent-overlay.css +444 -0
- package/public/agent-overlay.js +604 -0
- package/public/agent-profile.css +3230 -0
- package/public/agent-profile.js +3329 -0
- package/public/app.js +6629 -0
- package/public/auto-hide-scroll.js +90 -0
- package/public/avatar-skill.js +793 -0
- package/public/avatars/chair.svg +98 -0
- package/public/avatars/first-principles.svg +122 -0
- package/public/avatars/long-horizon.svg +147 -0
- package/public/avatars/open_ai.png +0 -0
- package/public/avatars/phenomenologist.svg +130 -0
- package/public/avatars/socrates.svg +187 -0
- package/public/avatars/user-empathy.svg +117 -0
- package/public/avatars/value-investor.svg +117 -0
- package/public/favicon.svg +10 -0
- package/public/fonts/agent-Italic.woff2 +0 -0
- package/public/fonts/human-sans.woff2 +0 -0
- package/public/icons.css +103 -0
- package/public/models-cache.js +57 -0
- package/public/new-agent.css +1359 -0
- package/public/new-agent.js +675 -0
- package/public/onboarding.css +628 -0
- package/public/onboarding.js +782 -0
- package/public/prototype-dashboard.html +7596 -0
- package/public/report/spines/a16z-thesis.css +1055 -0
- package/public/report/spines/anthropic-essay.css +556 -0
- package/public/report/spines/boardroom-dark.css +1082 -0
- package/public/report/spines/gartner-note.css +538 -0
- package/public/report/spines/mckinsey-deck.css +523 -0
- package/public/report/spines/openai-paper.css +516 -0
- package/public/report.html +1417 -0
- package/public/room-settings.css +895 -0
- package/public/room-settings.js +1039 -0
- package/public/themes.css +338 -0
- package/public/user-settings.css +1236 -0
- package/public/user-settings.js +1291 -0
|
@@ -0,0 +1,1291 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════
|
|
2
|
+
USER SETTINGS OVERLAY · 2-column layout
|
|
3
|
+
═══════════════════════════════════════════
|
|
4
|
+
Left rail: User · Theme · API Key
|
|
5
|
+
Right panel: section content for the selected rail item
|
|
6
|
+
Triggered by any element with [data-user-settings-trigger].
|
|
7
|
+
|
|
8
|
+
Persistence:
|
|
9
|
+
theme → localStorage (UI-only preference, no need to round-trip)
|
|
10
|
+
user (name/intro/avatarSeed) → /api/prefs (SQLite-backed)
|
|
11
|
+
api keys → localStorage (will move to /api/keys in M2/M3)
|
|
12
|
+
|
|
13
|
+
The keys object is exposed on window.boardroomKeys() so other modules
|
|
14
|
+
(new-agent.js) can show provider configuration status next to model rows.
|
|
15
|
+
*/
|
|
16
|
+
(function () {
|
|
17
|
+
const THEME_KEY = "boardroom.theme";
|
|
18
|
+
|
|
19
|
+
// /api/prefs is async; cache the latest value at module bootstrap so the
|
|
20
|
+
// synchronous render code below stays simple. saveUser writes through.
|
|
21
|
+
let _prefsCache = { name: "You", intro: "", avatarSeed: null };
|
|
22
|
+
|
|
23
|
+
const THEMES = [
|
|
24
|
+
{ slug: "regent", name: "Regent", desc: "warm gold on dark · default · the boardroom premium",
|
|
25
|
+
swatches: ["#0A0A0A","#131312","#C9A46B","#9A7B40","#A57843","#B5706A","#6A9B97","#C8C5BE"] },
|
|
26
|
+
{ slug: "eastwood", name: "Eastwood", desc: "calm forest green",
|
|
27
|
+
swatches: ["#0A0A0A","#131312","#6FB572","#427A48","#B59560","#B5706A","#6A9B97","#C8C5BE"] },
|
|
28
|
+
{ slug: "atrium", name: "Atrium", desc: "warm paper · light · the only daylight theme",
|
|
29
|
+
swatches: ["#FBFBF7","#F4F2EC","#2E7D32","#1B5E20","#A86C2A","#A8403D","#2E7D7A","#1F1E1A"] },
|
|
30
|
+
{ slug: "pinterest", name: "Pinterest", desc: "clean white · Pinterest red · light",
|
|
31
|
+
swatches: ["#FFFFFF","#FAFAFA","#E60023","#AD081B","#F4A100","#E60023","#2E7D7A","#111111"] },
|
|
32
|
+
{ slug: "apple", name: "Apple", desc: "pure white · system blue · Apple.com aesthetic · light",
|
|
33
|
+
swatches: ["#FFFFFF","#F5F5F7","#0071E3","#0051A8","#FF9500","#FF3B30","#5AC8FA","#1D1D1F"] },
|
|
34
|
+
{ slug: "alanpeabody", name: "Alan Peabody", desc: "cool blue · git-green accents",
|
|
35
|
+
swatches: ["#0E1419","#131A21","#6BAFE0","#3F7AAA","#C8A463","#D67373","#6FB5A8","#C8D0DA"] },
|
|
36
|
+
{ slug: "amuse", name: "Amuse", desc: "magenta + cyan · playful",
|
|
37
|
+
swatches: ["#1A0E14","#21121A","#D67BC0","#9C4884","#DCBE5D","#E07F84","#6FBFC2","#DECBD2"] },
|
|
38
|
+
{ slug: "jtriley", name: "JTriley", desc: "bright lime + yellow · punchy",
|
|
39
|
+
swatches: ["#0A0F0A","#131914","#B5DA40","#6E8E27","#F0CC4E","#D67762","#6FBE9A","#C8D6BE"] },
|
|
40
|
+
{ slug: "nebirhos", name: "Nebirhos", desc: "teal · warm orange highlights",
|
|
41
|
+
swatches: ["#0A1414","#11201F","#5EB1A6","#357770","#DD9258","#D87060","#6FBEC2","#B8D4D0"] },
|
|
42
|
+
{ slug: "wedisagree", name: "We Disagree", desc: "argumentative orange · subtle green",
|
|
43
|
+
swatches: ["#14110E","#1F1A14","#DD7B40","#A8521E","#E6B872","#E26060","#6FB28A","#D8CBBC"] }
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const PROVIDERS = [
|
|
47
|
+
{ id: "openrouter", label: "OpenRouter", hint: "default · routes any model · sk-or-…", placeholder: "sk-or-v1-…", group: "llm" },
|
|
48
|
+
{ id: "anthropic", label: "Anthropic", hint: "Claude · Sonnet 4.6, Opus 4.7, Haiku 4.5", placeholder: "sk-ant-…", group: "llm" },
|
|
49
|
+
{ id: "openai", label: "OpenAI", hint: "GPT · gpt-5, gpt-5 mini, gpt-4o", placeholder: "sk-…", group: "llm" },
|
|
50
|
+
{ id: "google", label: "Google", hint: "Gemini · 2.5 Pro, 2.5 Flash", placeholder: "AIza…", group: "llm" },
|
|
51
|
+
{ id: "xai", label: "xAI", hint: "Grok · grok-4.3, grok-4.1 fast", placeholder: "xai-…", group: "llm" },
|
|
52
|
+
// ── Skill Services (not LLM providers, but the same encrypted key store) ──
|
|
53
|
+
{ id: "brave", label: "Brave Search", hint: "powers the Web Search system skill · ≈ $5 / 1000 queries · privacy-respecting",
|
|
54
|
+
placeholder: "BSA…", group: "skill" },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
function escape(s) {
|
|
58
|
+
return String(s).replace(/[&<>"']/g, (c) => ({
|
|
59
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
|
60
|
+
}[c]));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ── Storage helpers ───────────────────────────────────────── */
|
|
64
|
+
function getTheme() { try { return localStorage.getItem(THEME_KEY) || "regent"; } catch (e) { return "regent"; } }
|
|
65
|
+
function setTheme(slug) {
|
|
66
|
+
const theme = slug || "regent";
|
|
67
|
+
document.documentElement.setAttribute("data-theme", theme);
|
|
68
|
+
try { localStorage.setItem(THEME_KEY, theme); } catch (e) {}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function fetchPrefs() {
|
|
72
|
+
try {
|
|
73
|
+
const r = await fetch("/api/prefs");
|
|
74
|
+
if (!r.ok) return;
|
|
75
|
+
const data = await r.json();
|
|
76
|
+
_prefsCache = {
|
|
77
|
+
name: typeof data.name === "string" ? data.name : "You",
|
|
78
|
+
intro: typeof data.intro === "string" ? data.intro : "",
|
|
79
|
+
avatarSeed: data.avatarSeed ?? null
|
|
80
|
+
};
|
|
81
|
+
} catch (e) {
|
|
82
|
+
// Network or server hiccup — fall back to whatever's already cached.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Sync read from cache (populated at bootstrap below).
|
|
87
|
+
function getUser() { return _prefsCache; }
|
|
88
|
+
|
|
89
|
+
// Write-through: update cache immediately, persist to server in background.
|
|
90
|
+
// We don't await — the UI doesn't block on the round-trip.
|
|
91
|
+
function saveUser(u) {
|
|
92
|
+
_prefsCache = { ...(_prefsCache || {}), ...u };
|
|
93
|
+
fetch("/api/prefs", {
|
|
94
|
+
method: "PUT",
|
|
95
|
+
headers: { "content-type": "application/json" },
|
|
96
|
+
body: JSON.stringify({
|
|
97
|
+
name: u.name,
|
|
98
|
+
intro: u.intro,
|
|
99
|
+
avatarSeed: u.avatarSeed
|
|
100
|
+
})
|
|
101
|
+
}).catch(() => { /* offline → cache stays, retry on next edit */ });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Provider keys live in the SQLite-backed /api/keys. Plaintext is never
|
|
105
|
+
// returned by the server — only meta. We cache the meta map locally so
|
|
106
|
+
// sync code (and window.boardroomKeys) keeps working.
|
|
107
|
+
// _keysMeta: { openrouter: { configured: true, updatedAt }, anthropic: {...}, ... }
|
|
108
|
+
let _keysMeta = {};
|
|
109
|
+
|
|
110
|
+
async function fetchKeyMeta() {
|
|
111
|
+
try {
|
|
112
|
+
const r = await fetch("/api/keys");
|
|
113
|
+
if (!r.ok) return;
|
|
114
|
+
const data = await r.json();
|
|
115
|
+
const next = {};
|
|
116
|
+
for (const row of (data.keys || [])) next[row.provider] = row;
|
|
117
|
+
_keysMeta = next;
|
|
118
|
+
} catch (e) { /* keep last cache on offline */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Sync read used by new-agent.js: returns a map { provider: truthy } where
|
|
122
|
+
// 'truthy' is the meta object so existence-check (`if (keys[p])`) still works.
|
|
123
|
+
function getKeys() {
|
|
124
|
+
const out = {};
|
|
125
|
+
for (const [p, m] of Object.entries(_keysMeta)) {
|
|
126
|
+
if (m && m.configured) out[p] = m;
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Available-models snapshot · shape from /api/models. We don't keep
|
|
132
|
+
// a private copy any more — the singleton in models-cache.js owns
|
|
133
|
+
// the canonical state. Local read goes through the global, so all
|
|
134
|
+
// pickers (composer / agent profile / new-agent / this default
|
|
135
|
+
// selector) stay in sync after a key write.
|
|
136
|
+
function modelsSnapshot() {
|
|
137
|
+
return (typeof window.boardroomModels === "function") ? window.boardroomModels() : null;
|
|
138
|
+
}
|
|
139
|
+
function refreshModels() {
|
|
140
|
+
if (typeof window.boardroomModelsRefresh === "function") return window.boardroomModelsRefresh();
|
|
141
|
+
return Promise.resolve(null);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function saveDefaultModel(modelV) {
|
|
145
|
+
try {
|
|
146
|
+
await fetch("/api/prefs", {
|
|
147
|
+
method: "PUT",
|
|
148
|
+
headers: { "content-type": "application/json" },
|
|
149
|
+
body: JSON.stringify({ defaultModelV: modelV }),
|
|
150
|
+
});
|
|
151
|
+
// Patch the shared cache in place so the next read sees the
|
|
152
|
+
// user's choice without round-tripping. Other tabs / pickers
|
|
153
|
+
// pick it up on their next refresh.
|
|
154
|
+
const snap = modelsSnapshot();
|
|
155
|
+
if (snap) snap.defaultModelV = modelV;
|
|
156
|
+
} catch (e) { /* swallow · UI is optimistic */ }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Pick a provider's "primary" model · used when the user clicks
|
|
160
|
+
* "Set as default" on a provider row. We prefer the user's already-
|
|
161
|
+
* picked model for that provider (if reachable), otherwise we fall
|
|
162
|
+
* back to a curated primary, otherwise the first reachable model
|
|
163
|
+
* from that provider in the registry. */
|
|
164
|
+
const PRIMARY_BY_PROVIDER = {
|
|
165
|
+
anthropic: "opus-4-7",
|
|
166
|
+
openai: "gpt-5-5",
|
|
167
|
+
google: "gemini-3-flash",
|
|
168
|
+
xai: "grok-4-3",
|
|
169
|
+
deepseek: "deepseek-v4-pro",
|
|
170
|
+
openrouter: "opus-4-7",
|
|
171
|
+
};
|
|
172
|
+
function primaryModelForProvider(provider) {
|
|
173
|
+
const snap = modelsSnapshot();
|
|
174
|
+
const reachable = (snap && snap.reachable) || [];
|
|
175
|
+
// 1. Curated primary, if reachable.
|
|
176
|
+
const curated = PRIMARY_BY_PROVIDER[provider];
|
|
177
|
+
if (curated && reachable.find((m) => m.modelV === curated && m.provider === provider)) {
|
|
178
|
+
return curated;
|
|
179
|
+
}
|
|
180
|
+
// 2. First reachable model from that provider.
|
|
181
|
+
const first = reachable.find((m) => m.provider === provider);
|
|
182
|
+
return first ? first.modelV : null;
|
|
183
|
+
}
|
|
184
|
+
/** Look up which provider currently owns the saved default model. */
|
|
185
|
+
function currentDefaultProvider() {
|
|
186
|
+
const snap = modelsSnapshot();
|
|
187
|
+
if (!snap || !snap.defaultModelV) return null;
|
|
188
|
+
const m = (snap.reachable || []).find((x) => x.modelV === snap.defaultModelV);
|
|
189
|
+
return m ? m.provider : null;
|
|
190
|
+
}
|
|
191
|
+
/** Click handler for the per-row "Set as default" button. Picks the
|
|
192
|
+
* provider's primary model + persists it as defaultModelV. The row
|
|
193
|
+
* re-renders so the badge moves. */
|
|
194
|
+
async function setProviderAsDefault(provider) {
|
|
195
|
+
const modelV = primaryModelForProvider(provider);
|
|
196
|
+
if (!modelV) return;
|
|
197
|
+
await saveDefaultModel(modelV);
|
|
198
|
+
// Re-render the keys section so every LLM row updates its badge.
|
|
199
|
+
if (currentSection === "keys") renderSection("keys");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Set / clear a single provider key. The server applies the trim+empty-=delete
|
|
203
|
+
// semantic; we mirror the resulting meta into our local cache.
|
|
204
|
+
async function setProviderKey(provider, value) {
|
|
205
|
+
try {
|
|
206
|
+
const trimmed = (value || "").trim();
|
|
207
|
+
const r = await fetch("/api/keys/" + encodeURIComponent(provider), {
|
|
208
|
+
method: trimmed ? "PUT" : "DELETE",
|
|
209
|
+
headers: trimmed ? { "content-type": "application/json" } : undefined,
|
|
210
|
+
body: trimmed ? JSON.stringify({ key: trimmed }) : undefined
|
|
211
|
+
});
|
|
212
|
+
if (!r.ok) return null;
|
|
213
|
+
const meta = await r.json();
|
|
214
|
+
_keysMeta[provider] = meta;
|
|
215
|
+
return meta;
|
|
216
|
+
} catch (e) { return null; }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Public — other modules read provider configuration via this
|
|
220
|
+
window.boardroomKeys = getKeys;
|
|
221
|
+
|
|
222
|
+
// Apply theme on script init (FOUC fallback)
|
|
223
|
+
setTheme(getTheme());
|
|
224
|
+
|
|
225
|
+
/* ── Section content renderers ────────────────────────────── */
|
|
226
|
+
function userSectionHTML() {
|
|
227
|
+
const u = getUser();
|
|
228
|
+
return `
|
|
229
|
+
<div class="us-pane-head">
|
|
230
|
+
<div class="us-pane-tag">▸ User</div>
|
|
231
|
+
<div class="us-pane-deck">how the boardroom addresses you and what it knows about your context.</div>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<div class="us-pane-body">
|
|
235
|
+
<div class="us-row">
|
|
236
|
+
<div class="us-row-label">Avatar</div>
|
|
237
|
+
<div class="us-row-field us-avatar-row">
|
|
238
|
+
<div class="us-avatar-frame" data-us-avatar></div>
|
|
239
|
+
<button type="button" class="us-mini-btn" data-us-regen-avatar>
|
|
240
|
+
<span class="us-mini-btn-mark">◆</span>
|
|
241
|
+
<span>generate 8-bit avatar</span>
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<div class="us-row">
|
|
247
|
+
<div class="us-row-label">Name</div>
|
|
248
|
+
<div class="us-row-field">
|
|
249
|
+
<div class="us-input-wrap">
|
|
250
|
+
<input type="text" class="us-input" data-us-name placeholder="Kay" maxlength="32" value="${escape(u.name || "")}">
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<div class="us-row">
|
|
256
|
+
<div class="us-row-label">About you</div>
|
|
257
|
+
<div class="us-row-field">
|
|
258
|
+
<div class="us-input-wrap tall">
|
|
259
|
+
<textarea class="us-input" data-us-intro maxlength="320" placeholder="A line or two about your role, what you tend to think about, what you're working on. Directors will keep this in mind across rooms.">${escape(u.intro || "")}</textarea>
|
|
260
|
+
</div>
|
|
261
|
+
<div class="us-row-meta"><span data-us-intro-count>0</span> / 320 chars</div>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function themeSectionHTML() {
|
|
269
|
+
const current = getTheme();
|
|
270
|
+
const swatchSpans = (cs) => cs.map((c) => `<span style="background:${c}"></span>`).join("");
|
|
271
|
+
return `
|
|
272
|
+
<div class="us-pane-head">
|
|
273
|
+
<div class="us-pane-tag">▸ Theme</div>
|
|
274
|
+
<div class="us-pane-deck">global · zsh-inspired palettes. Applied instantly across all rooms.</div>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
<div class="us-pane-body">
|
|
278
|
+
<div class="us-theme-grid">
|
|
279
|
+
${THEMES.map((t) => `
|
|
280
|
+
<a href="#" class="us-theme${t.slug === current ? " active" : ""}" data-theme-slug="${t.slug}">
|
|
281
|
+
<span class="us-theme-swatch">${swatchSpans(t.swatches)}</span>
|
|
282
|
+
<span class="us-theme-info">
|
|
283
|
+
<span class="us-theme-name">${escape(t.name)}</span>
|
|
284
|
+
<span class="us-theme-desc">${escape(t.desc)}</span>
|
|
285
|
+
</span>
|
|
286
|
+
<span class="us-theme-check"></span>
|
|
287
|
+
</a>
|
|
288
|
+
`).join("")}
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* ── Usage section ────────────────────────────────────────── */
|
|
295
|
+
// Provider → CSS-variable color slot. Lets each model bar/swatch
|
|
296
|
+
// pick up the right accent from the active theme without baking
|
|
297
|
+
// color values here.
|
|
298
|
+
const PROVIDER_COLOR_VAR = {
|
|
299
|
+
anthropic: "--lime",
|
|
300
|
+
openai: "--cyan",
|
|
301
|
+
google: "--amber",
|
|
302
|
+
xai: "--magenta",
|
|
303
|
+
deepseek: "--red",
|
|
304
|
+
unknown: "--text-soft",
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
function fmtTokens(n) {
|
|
308
|
+
if (!Number.isFinite(n) || n <= 0) return "0";
|
|
309
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 1 : 2) + "M";
|
|
310
|
+
if (n >= 1_000) return (n / 1_000).toFixed(n >= 10_000 ? 0 : 1) + "k";
|
|
311
|
+
return String(Math.round(n));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function usageSectionHTML() {
|
|
315
|
+
return `
|
|
316
|
+
<div class="us-pane-head">
|
|
317
|
+
<div class="us-pane-tag">▸ Usage</div>
|
|
318
|
+
<div class="us-pane-deck">cumulative LLM-call accounting · billed across every director / chair turn since this boardroom was opened.</div>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
<div class="us-pane-body">
|
|
322
|
+
<div class="us-usage" data-usage-pane>
|
|
323
|
+
<div class="us-usage-loading">measuring…</div>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function renderUsagePane(s) {
|
|
330
|
+
const pane = paneEl.querySelector("[data-usage-pane]");
|
|
331
|
+
if (!pane) return;
|
|
332
|
+
if (!s || s.totalTokens === 0) {
|
|
333
|
+
pane.innerHTML = `
|
|
334
|
+
<div class="us-usage-empty">
|
|
335
|
+
<div class="us-usage-empty-num">0</div>
|
|
336
|
+
<div class="us-usage-empty-text">no LLM calls billed yet · open a room and let the directors speak.</div>
|
|
337
|
+
</div>
|
|
338
|
+
`;
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const total = s.totalTokens;
|
|
343
|
+
const segments = s.byModel.map((m) => {
|
|
344
|
+
const pct = (m.tokens / total) * 100;
|
|
345
|
+
const color = PROVIDER_COLOR_VAR[m.provider] || PROVIDER_COLOR_VAR.unknown;
|
|
346
|
+
return `<span class="us-usage-seg" style="width:${pct.toFixed(2)}%;background:var(${color})" title="${escape(m.displayName)} · ${fmtTokens(m.tokens)}"></span>`;
|
|
347
|
+
}).join("");
|
|
348
|
+
|
|
349
|
+
const modelRows = s.byModel.map((m) => {
|
|
350
|
+
const pct = (m.tokens / total) * 100;
|
|
351
|
+
const color = PROVIDER_COLOR_VAR[m.provider] || PROVIDER_COLOR_VAR.unknown;
|
|
352
|
+
return `
|
|
353
|
+
<div class="us-model-row">
|
|
354
|
+
<div class="us-model-info">
|
|
355
|
+
<span class="us-model-dot" style="background:var(${color})"></span>
|
|
356
|
+
<span class="us-model-name">${escape(m.displayName)}</span>
|
|
357
|
+
<span class="us-model-provider">${escape(m.provider)}</span>
|
|
358
|
+
</div>
|
|
359
|
+
<div class="us-model-bar">
|
|
360
|
+
<span style="width:${pct.toFixed(2)}%;background:var(${color})"></span>
|
|
361
|
+
</div>
|
|
362
|
+
<div class="us-model-stats">
|
|
363
|
+
<span class="us-model-tokens">${fmtTokens(m.tokens)}</span>
|
|
364
|
+
<span class="us-model-pct">${pct.toFixed(1)}%</span>
|
|
365
|
+
<span class="us-model-agents">${m.agents} agent${m.agents === 1 ? "" : "s"}</span>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
`;
|
|
369
|
+
}).join("");
|
|
370
|
+
|
|
371
|
+
// Top consumers (top 6 by tokens, skip silent agents).
|
|
372
|
+
const topAgents = s.byAgent.filter((a) => a.tokens > 0).slice(0, 6);
|
|
373
|
+
const agentRows = topAgents.map((a) => {
|
|
374
|
+
const pct = (a.tokens / total) * 100;
|
|
375
|
+
const color = PROVIDER_COLOR_VAR[a.provider] || PROVIDER_COLOR_VAR.unknown;
|
|
376
|
+
const role = a.roleKind === "moderator" ? "chair" : "director";
|
|
377
|
+
return `
|
|
378
|
+
<div class="us-agent-row">
|
|
379
|
+
<div class="us-agent-name-col">
|
|
380
|
+
<span class="us-agent-name">${escape(a.name)}</span>
|
|
381
|
+
<span class="us-agent-role">${role}</span>
|
|
382
|
+
</div>
|
|
383
|
+
<div class="us-agent-model" style="color:var(${color})">${escape(a.displayName)}</div>
|
|
384
|
+
<div class="us-agent-bar">
|
|
385
|
+
<span style="width:${Math.max(pct, 1).toFixed(2)}%;background:var(${color})"></span>
|
|
386
|
+
</div>
|
|
387
|
+
<div class="us-agent-tokens">${fmtTokens(a.tokens)}</div>
|
|
388
|
+
</div>
|
|
389
|
+
`;
|
|
390
|
+
}).join("");
|
|
391
|
+
|
|
392
|
+
const silentCount = s.byAgent.length - topAgents.length;
|
|
393
|
+
const silentNote = silentCount > 0
|
|
394
|
+
? `<div class="us-agent-silent">+ ${silentCount} agent${silentCount === 1 ? "" : "s"} not yet billed</div>`
|
|
395
|
+
: "";
|
|
396
|
+
|
|
397
|
+
// Retired-agents footer · custom directors that the user has
|
|
398
|
+
// deleted. Their per-agent identity is gone but the tokens were
|
|
399
|
+
// real, so we surface a small footer note acknowledging that the
|
|
400
|
+
// model-level totals above include their share. Hidden when no
|
|
401
|
+
// agents have ever been deleted with consumed tokens.
|
|
402
|
+
const retired = s.retired || { tokens: 0, agents: 0 };
|
|
403
|
+
const retiredNote = retired.tokens > 0
|
|
404
|
+
? `
|
|
405
|
+
<div class="us-usage-retired">
|
|
406
|
+
<span class="us-usage-retired-mark">↓</span>
|
|
407
|
+
<span class="us-usage-retired-text">
|
|
408
|
+
<strong>${retired.agents}</strong> retired
|
|
409
|
+
agent${retired.agents === 1 ? "" : "s"} ·
|
|
410
|
+
<strong>${fmtTokens(retired.tokens)}</strong> tokens preserved across deletions and folded into the model totals above.
|
|
411
|
+
</span>
|
|
412
|
+
</div>
|
|
413
|
+
`
|
|
414
|
+
: "";
|
|
415
|
+
|
|
416
|
+
pane.innerHTML = `
|
|
417
|
+
<div class="us-usage-head">
|
|
418
|
+
<div class="us-usage-total">
|
|
419
|
+
<div class="us-usage-total-num">${fmtTokens(total)}</div>
|
|
420
|
+
<div class="us-usage-total-raw">${total.toLocaleString()} tokens</div>
|
|
421
|
+
</div>
|
|
422
|
+
<div class="us-usage-meta">
|
|
423
|
+
<div class="us-usage-meta-row">
|
|
424
|
+
<span class="us-usage-meta-label">Models</span>
|
|
425
|
+
<span class="us-usage-meta-value">${s.byModel.length}</span>
|
|
426
|
+
</div>
|
|
427
|
+
<div class="us-usage-meta-row">
|
|
428
|
+
<span class="us-usage-meta-label">Agents</span>
|
|
429
|
+
<span class="us-usage-meta-value">${s.agentCount}</span>
|
|
430
|
+
</div>
|
|
431
|
+
<div class="us-usage-meta-row">
|
|
432
|
+
<span class="us-usage-meta-label">Active</span>
|
|
433
|
+
<span class="us-usage-meta-value">${s.byAgent.filter((a) => a.tokens > 0).length}</span>
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
|
|
438
|
+
<div class="us-usage-bar" aria-label="Token distribution by model">${segments}</div>
|
|
439
|
+
|
|
440
|
+
<div class="us-usage-section">
|
|
441
|
+
<div class="us-usage-section-tag">By model</div>
|
|
442
|
+
<div class="us-model-list">${modelRows}</div>
|
|
443
|
+
</div>
|
|
444
|
+
|
|
445
|
+
${topAgents.length ? `
|
|
446
|
+
<div class="us-usage-section">
|
|
447
|
+
<div class="us-usage-section-tag">Top consumers</div>
|
|
448
|
+
<div class="us-agent-list">${agentRows}</div>
|
|
449
|
+
${silentNote}
|
|
450
|
+
</div>
|
|
451
|
+
` : ""}
|
|
452
|
+
|
|
453
|
+
${retiredNote}
|
|
454
|
+
`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function wireUsageSection() {
|
|
458
|
+
try {
|
|
459
|
+
const r = await fetch("/api/usage/summary");
|
|
460
|
+
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
461
|
+
const s = await r.json();
|
|
462
|
+
renderUsagePane(s);
|
|
463
|
+
} catch (e) {
|
|
464
|
+
const pane = paneEl.querySelector("[data-usage-pane]");
|
|
465
|
+
if (pane) pane.innerHTML = `<div class="us-usage-empty"><div class="us-usage-empty-text">couldn't fetch usage stats. ${escape(String(e && e.message || e))}</div></div>`;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// LLM-provider IDs only (excludes Skill Services like Brave — those
|
|
470
|
+
// live in their own pinned subgroup and aren't subject to the
|
|
471
|
+
// "+ add provider" flow).
|
|
472
|
+
const LLM_PROVIDER_IDS = PROVIDERS.filter((p) => p.group === "llm").map((p) => p.id);
|
|
473
|
+
const SKILL_PROVIDER_IDS = PROVIDERS.filter((p) => p.group === "skill").map((p) => p.id);
|
|
474
|
+
|
|
475
|
+
function ensureActiveProviders() {
|
|
476
|
+
if (activeProviders === null) {
|
|
477
|
+
// Only show rows for providers the user has actually configured.
|
|
478
|
+
// Unconfigured providers (incl. OpenRouter) live behind the
|
|
479
|
+
// "+ add provider" chips so the panel reflects what's really
|
|
480
|
+
// wired up rather than projecting an empty OpenRouter row onto
|
|
481
|
+
// a user who never picked it during onboarding.
|
|
482
|
+
activeProviders = LLM_PROVIDER_IDS.filter(
|
|
483
|
+
(k) => _keysMeta[k] && _keysMeta[k].configured,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function renderKeyRow(p, removable) {
|
|
489
|
+
const meta = _keysMeta[p.id];
|
|
490
|
+
const has = !!(meta && meta.configured);
|
|
491
|
+
// The server never returns plaintext, but it does return a 4+4
|
|
492
|
+
// masked preview of the stored key (e.g. "sk-or…YjNH"). Surfacing
|
|
493
|
+
// it as the placeholder lets the user verify which key is in which
|
|
494
|
+
// slot — a real failure mode we hit when the OpenRouter slot
|
|
495
|
+
// silently held a Brave key. When configured we show the preview
|
|
496
|
+
// alone (no "paste to replace" hint — the row is clearly populated
|
|
497
|
+
// and pasting overwrites by default); when empty we show the
|
|
498
|
+
// provider's normal hint.
|
|
499
|
+
const preview = has && meta.preview ? meta.preview : null;
|
|
500
|
+
const placeholder = has
|
|
501
|
+
? (preview || "••••••••")
|
|
502
|
+
: p.placeholder;
|
|
503
|
+
// Default-provider tag · only meaningful for LLM providers.
|
|
504
|
+
// We compute it from the saved defaultModelV's owning provider.
|
|
505
|
+
// The "Set as default" CTA only appears on LLM rows that are
|
|
506
|
+
// configured AND not the current default. Skill rows (Brave) are
|
|
507
|
+
// never default-eligible — they're search, not a model.
|
|
508
|
+
const isLlm = p.group === "llm";
|
|
509
|
+
const isDefault = isLlm && currentDefaultProvider() === p.id;
|
|
510
|
+
const canSetDefault = isLlm && has && !isDefault && !!primaryModelForProvider(p.id);
|
|
511
|
+
const labelExtras = isDefault
|
|
512
|
+
? ' <span class="badge us-key-default-badge">default</span>'
|
|
513
|
+
: "";
|
|
514
|
+
return `
|
|
515
|
+
<div class="us-key-row${isDefault ? " is-default" : ""}" data-provider="${p.id}">
|
|
516
|
+
<div class="us-key-head">
|
|
517
|
+
<div class="us-key-label">${escape(p.label)}${labelExtras}</div>
|
|
518
|
+
<div class="us-key-status ${has ? "on" : "off"}" data-status>${has ? "● configured" : "○ not set"}</div>
|
|
519
|
+
${canSetDefault ? `<button type="button" class="us-key-set-default" data-set-default-provider="${p.id}" title="Use ${escape(p.label)} as the default model provider for new agents">set as default</button>` : ""}
|
|
520
|
+
${removable ? `<button type="button" class="us-key-remove" data-remove-provider="${p.id}" title="Remove">✕</button>` : ""}
|
|
521
|
+
</div>
|
|
522
|
+
<div class="us-key-hint">${escape(p.hint)}</div>
|
|
523
|
+
<div class="us-input-wrap">
|
|
524
|
+
<input
|
|
525
|
+
type="password"
|
|
526
|
+
class="us-input${has ? " has-preview" : ""}"
|
|
527
|
+
data-key-input
|
|
528
|
+
name="bk-${p.id}"
|
|
529
|
+
placeholder="${escape(placeholder)}"
|
|
530
|
+
value=""
|
|
531
|
+
autocomplete="new-password"
|
|
532
|
+
data-lpignore="true"
|
|
533
|
+
data-1p-ignore="true"
|
|
534
|
+
data-form-type="other"
|
|
535
|
+
spellcheck="false">
|
|
536
|
+
<button type="button" class="us-key-eye" data-key-eye title="Show / hide">◉</button>
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function keysSectionHTML() {
|
|
543
|
+
ensureActiveProviders();
|
|
544
|
+
// Anthropic is temporarily excluded from the "+ add provider"
|
|
545
|
+
// chips · only sonnet-4-6 is direct-routable on the Anthropic SDK
|
|
546
|
+
// right now (opus / haiku are openrouterOnly), so adding an
|
|
547
|
+
// Anthropic key alone unlocks just one model — confusing UX. Once
|
|
548
|
+
// the registry has ≥ 2 direct-routable Claude models, drop the
|
|
549
|
+
// exclusion. Existing users who already configured Anthropic still
|
|
550
|
+
// see their row (activeProviders preserves them). */
|
|
551
|
+
const HIDDEN_FROM_ADD = new Set(["anthropic"]);
|
|
552
|
+
const addable = PROVIDERS.filter(
|
|
553
|
+
(p) => p.group === "llm" && !HIDDEN_FROM_ADD.has(p.id) && !activeProviders.includes(p.id),
|
|
554
|
+
);
|
|
555
|
+
const skillProviders = PROVIDERS.filter((p) => p.group === "skill");
|
|
556
|
+
|
|
557
|
+
return `
|
|
558
|
+
<div class="us-pane-head">
|
|
559
|
+
<div class="us-pane-tag">▸ API Key</div>
|
|
560
|
+
<div class="us-pane-deck">stored locally, never uploaded. add a key for any provider — a single key is enough to get started. Skill services power optional capabilities like web search.</div>
|
|
561
|
+
</div>
|
|
562
|
+
|
|
563
|
+
<div class="us-pane-body">
|
|
564
|
+
|
|
565
|
+
<div class="us-key-group">
|
|
566
|
+
<div class="us-key-group-tag">LLM Providers</div>
|
|
567
|
+
${activeProviders.map((id) => {
|
|
568
|
+
const p = PROVIDERS.find((x) => x.id === id);
|
|
569
|
+
if (!p) return "";
|
|
570
|
+
return renderKeyRow(p, true);
|
|
571
|
+
}).join("")}
|
|
572
|
+
${addable.length > 0 ? `
|
|
573
|
+
<div class="us-key-add">
|
|
574
|
+
<span class="us-key-add-label">+ add provider:</span>
|
|
575
|
+
<div class="us-key-add-chips">
|
|
576
|
+
${addable.map((p) => `
|
|
577
|
+
<button type="button" class="us-key-add-chip" data-add-provider="${p.id}">${escape(p.label)}</button>
|
|
578
|
+
`).join("")}
|
|
579
|
+
</div>
|
|
580
|
+
</div>
|
|
581
|
+
` : ""}
|
|
582
|
+
</div>
|
|
583
|
+
|
|
584
|
+
${skillProviders.length > 0 ? `
|
|
585
|
+
<div class="us-key-group us-key-group-skill">
|
|
586
|
+
<div class="us-key-group-tag">Skill Services</div>
|
|
587
|
+
<div class="us-key-group-deck">enables system skills that need an outside service. Each agent can opt in or out per-profile.</div>
|
|
588
|
+
${skillProviders.map((p) => renderKeyRow(p, !!(_keysMeta[p.id] && _keysMeta[p.id].configured))).join("")}
|
|
589
|
+
</div>
|
|
590
|
+
` : ""}
|
|
591
|
+
|
|
592
|
+
<div data-models-summary>${modelsSummaryHTML()}</div>
|
|
593
|
+
|
|
594
|
+
</div>
|
|
595
|
+
`;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/* ── Available models · summary + default picker ─────────────
|
|
599
|
+
Lives at the bottom of the API Key section. Hidden when the
|
|
600
|
+
user has no keys configured. Re-fetched after every key
|
|
601
|
+
write so the route badges and reachable count stay accurate. */
|
|
602
|
+
const PROVIDER_ORDER = ["anthropic", "openai", "google", "xai", "deepseek", "openrouter"];
|
|
603
|
+
const PROVIDER_LABEL = {
|
|
604
|
+
anthropic: "Anthropic",
|
|
605
|
+
openai: "OpenAI",
|
|
606
|
+
google: "Google",
|
|
607
|
+
xai: "xAI",
|
|
608
|
+
deepseek: "DeepSeek",
|
|
609
|
+
openrouter:"OpenRouter",
|
|
610
|
+
};
|
|
611
|
+
function providerLabel(p) { return PROVIDER_LABEL[p] || p; }
|
|
612
|
+
|
|
613
|
+
function routeBadgeHTML(m) {
|
|
614
|
+
const d = !!(m.routes && m.routes.direct);
|
|
615
|
+
const o = !!(m.routes && m.routes.openrouter);
|
|
616
|
+
if (d && o) return `<span class="us-models-route">direct · OR</span>`;
|
|
617
|
+
if (d) return `<span class="us-models-route">direct</span>`;
|
|
618
|
+
if (o) return `<span class="us-models-route">OR</span>`;
|
|
619
|
+
return "";
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function modelsSummaryHTML() {
|
|
623
|
+
const cache = modelsSnapshot();
|
|
624
|
+
if (!cache) {
|
|
625
|
+
return `<div class="us-key-group us-key-group-models">
|
|
626
|
+
<div class="us-key-group-tag">Available models</div>
|
|
627
|
+
<div class="us-models-loading">measuring reach…</div>
|
|
628
|
+
</div>`;
|
|
629
|
+
}
|
|
630
|
+
if (!cache.hasAnyKey) return "";
|
|
631
|
+
const reachable = (cache.reachable || []);
|
|
632
|
+
if (reachable.length === 0) return "";
|
|
633
|
+
|
|
634
|
+
const byProvider = new Map();
|
|
635
|
+
for (const m of reachable) {
|
|
636
|
+
if (!byProvider.has(m.provider)) byProvider.set(m.provider, []);
|
|
637
|
+
byProvider.get(m.provider).push(m);
|
|
638
|
+
}
|
|
639
|
+
const providers = Array.from(byProvider.keys()).sort((a, b) => {
|
|
640
|
+
const ai = PROVIDER_ORDER.indexOf(a), bi = PROVIDER_ORDER.indexOf(b);
|
|
641
|
+
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
const blocks = providers.map((p) => {
|
|
645
|
+
const models = byProvider.get(p);
|
|
646
|
+
return `
|
|
647
|
+
<div class="us-models-provider">
|
|
648
|
+
<div class="us-models-provider-tag">${escape(providerLabel(p))}</div>
|
|
649
|
+
<div class="us-models-rows">
|
|
650
|
+
${models.map((m) => `
|
|
651
|
+
<div class="us-models-row">
|
|
652
|
+
<span class="us-models-name">${escape(m.displayName)}</span>
|
|
653
|
+
<span class="us-models-deck">${escape(m.deck || "")}</span>
|
|
654
|
+
${routeBadgeHTML(m)}
|
|
655
|
+
</div>
|
|
656
|
+
`).join("")}
|
|
657
|
+
</div>
|
|
658
|
+
</div>
|
|
659
|
+
`;
|
|
660
|
+
}).join("");
|
|
661
|
+
|
|
662
|
+
let defaultBlock = "";
|
|
663
|
+
const def = cache.defaultModelV;
|
|
664
|
+
if (reachable.length >= 2) {
|
|
665
|
+
const optgroups = providers.map((p) => {
|
|
666
|
+
const models = byProvider.get(p);
|
|
667
|
+
return `<optgroup label="${escape(providerLabel(p))}">${
|
|
668
|
+
models.map((m) => `<option value="${escape(m.modelV)}"${m.modelV === def ? " selected" : ""}>${escape(m.displayName)}</option>`).join("")
|
|
669
|
+
}</optgroup>`;
|
|
670
|
+
}).join("");
|
|
671
|
+
defaultBlock = `
|
|
672
|
+
<div class="us-models-default">
|
|
673
|
+
<div class="us-models-default-label">Default model</div>
|
|
674
|
+
<div class="us-models-default-hint">new agents inherit this. when an agent's model goes unreachable, it falls back here too.</div>
|
|
675
|
+
<div class="us-input-wrap us-models-default-wrap">
|
|
676
|
+
<select class="us-input us-models-default-select" data-default-model>${optgroups}</select>
|
|
677
|
+
</div>
|
|
678
|
+
</div>
|
|
679
|
+
`;
|
|
680
|
+
} else {
|
|
681
|
+
const m = reachable[0];
|
|
682
|
+
defaultBlock = `
|
|
683
|
+
<div class="us-models-default">
|
|
684
|
+
<div class="us-models-default-label">Default model</div>
|
|
685
|
+
<div class="us-models-default-static">
|
|
686
|
+
<span class="us-models-default-name">${escape(m.displayName)}</span>
|
|
687
|
+
<span class="us-models-default-note">only reachable model</span>
|
|
688
|
+
</div>
|
|
689
|
+
</div>
|
|
690
|
+
`;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return `
|
|
694
|
+
<div class="us-key-group us-key-group-models">
|
|
695
|
+
<div class="us-key-group-tag">Available models</div>
|
|
696
|
+
<div class="us-key-group-deck">${reachable.length} model${reachable.length === 1 ? "" : "s"} reachable across ${providers.length} provider${providers.length === 1 ? "" : "s"}. <code>direct</code> uses the provider key, <code>OR</code> routes through OpenRouter.</div>
|
|
697
|
+
<div class="us-models-list">${blocks}</div>
|
|
698
|
+
${defaultBlock}
|
|
699
|
+
</div>
|
|
700
|
+
`;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/* ── Default Model section ────────────────────────────────────
|
|
704
|
+
A dedicated rail tab for picking the default model the rest of
|
|
705
|
+
the system inherits (new agents, fallback for unreachable
|
|
706
|
+
stale-modelV agents, brief flagship tier). The same dropdown
|
|
707
|
+
also lives at the bottom of the API Key section as a quick
|
|
708
|
+
toggle, but here it gets a focused page with grouping by
|
|
709
|
+
provider + a deck per model row + the active route badge. */
|
|
710
|
+
function defaultModelSectionHTML() {
|
|
711
|
+
const cache = modelsSnapshot();
|
|
712
|
+
if (!cache || !cache.hasAnyKey) {
|
|
713
|
+
return `
|
|
714
|
+
<div class="us-pane-head">
|
|
715
|
+
<div class="us-pane-tag">▸ Default Model</div>
|
|
716
|
+
<div class="us-pane-deck">no LLM key configured yet — add one in <a href="#" data-jump-keys class="us-link">API Key</a> first, then come back to pick a default.</div>
|
|
717
|
+
</div>
|
|
718
|
+
`;
|
|
719
|
+
}
|
|
720
|
+
const reachable = cache.reachable || [];
|
|
721
|
+
if (reachable.length === 0) {
|
|
722
|
+
return `
|
|
723
|
+
<div class="us-pane-head">
|
|
724
|
+
<div class="us-pane-tag">▸ Default Model</div>
|
|
725
|
+
<div class="us-pane-deck">your configured keys don't reach any model right now. Check the key values, or add another carrier in <a href="#" data-jump-keys class="us-link">API Key</a>.</div>
|
|
726
|
+
</div>
|
|
727
|
+
`;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Group reachable models by provider, ordered by PROVIDER_ORDER
|
|
731
|
+
// (anthropic / openai / google / xai / deepseek / openrouter).
|
|
732
|
+
const byProvider = new Map();
|
|
733
|
+
for (const m of reachable) {
|
|
734
|
+
if (!byProvider.has(m.provider)) byProvider.set(m.provider, []);
|
|
735
|
+
byProvider.get(m.provider).push(m);
|
|
736
|
+
}
|
|
737
|
+
const providers = Array.from(byProvider.keys()).sort((a, b) => {
|
|
738
|
+
const ai = PROVIDER_ORDER.indexOf(a), bi = PROVIDER_ORDER.indexOf(b);
|
|
739
|
+
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
|
740
|
+
});
|
|
741
|
+
const def = cache.defaultModelV;
|
|
742
|
+
|
|
743
|
+
const blocks = providers.map((p) => {
|
|
744
|
+
const models = byProvider.get(p);
|
|
745
|
+
const rows = models.map((m) => {
|
|
746
|
+
const isActive = m.modelV === def;
|
|
747
|
+
return `
|
|
748
|
+
<button type="button"
|
|
749
|
+
class="us-default-row${isActive ? " active" : ""}"
|
|
750
|
+
data-default-pick="${escape(m.modelV)}">
|
|
751
|
+
<span class="us-default-row-mark">${isActive ? "●" : "○"}</span>
|
|
752
|
+
<span class="us-default-row-text">
|
|
753
|
+
<span class="us-default-row-name">${escape(m.displayName)}</span>
|
|
754
|
+
<span class="us-default-row-deck">${escape(m.deck || "")}</span>
|
|
755
|
+
</span>
|
|
756
|
+
${routeBadgeHTML(m)}
|
|
757
|
+
</button>
|
|
758
|
+
`;
|
|
759
|
+
}).join("");
|
|
760
|
+
return `
|
|
761
|
+
<div class="us-default-provider">
|
|
762
|
+
<div class="us-default-provider-tag">${escape(providerLabel(p))}</div>
|
|
763
|
+
<div class="us-default-rows">${rows}</div>
|
|
764
|
+
</div>
|
|
765
|
+
`;
|
|
766
|
+
}).join("");
|
|
767
|
+
|
|
768
|
+
return `
|
|
769
|
+
<div class="us-pane-head">
|
|
770
|
+
<div class="us-pane-tag">▸ Default Model</div>
|
|
771
|
+
<div class="us-pane-deck">new agents inherit this. when an agent's saved model becomes unreachable (key removed / model retired), it falls back here too. brief flagship tier uses this as the deep-write model.</div>
|
|
772
|
+
</div>
|
|
773
|
+
|
|
774
|
+
<div class="us-pane-body">
|
|
775
|
+
<div class="us-default-list">${blocks}</div>
|
|
776
|
+
</div>
|
|
777
|
+
`;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function wireDefaultModelSection() {
|
|
781
|
+
if (!paneEl) return;
|
|
782
|
+
// Jump links to the API Key section when the user has no keys.
|
|
783
|
+
paneEl.querySelectorAll("[data-jump-keys]").forEach((a) => {
|
|
784
|
+
a.addEventListener("click", (e) => {
|
|
785
|
+
e.preventDefault();
|
|
786
|
+
renderSection("keys");
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
// Click a row · save defaultModelV, repaint the section so the
|
|
790
|
+
// active-row marker moves. Refresh the shared models cache too so
|
|
791
|
+
// every other picker (composer / agent profile) picks up the new
|
|
792
|
+
// value on its next read.
|
|
793
|
+
paneEl.querySelectorAll("[data-default-pick]").forEach((btn) => {
|
|
794
|
+
btn.addEventListener("click", async (e) => {
|
|
795
|
+
e.preventDefault();
|
|
796
|
+
const v = btn.getAttribute("data-default-pick");
|
|
797
|
+
if (!v) return;
|
|
798
|
+
await saveDefaultModel(v);
|
|
799
|
+
await refreshModels();
|
|
800
|
+
if (currentSection === "default") renderSection("default");
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function refreshModelsSummary() {
|
|
806
|
+
if (!paneEl) return;
|
|
807
|
+
const slot = paneEl.querySelector("[data-models-summary]");
|
|
808
|
+
if (!slot) return;
|
|
809
|
+
slot.innerHTML = modelsSummaryHTML();
|
|
810
|
+
// Also refresh each LLM row's default-state UI · the "set as
|
|
811
|
+
// default" button only appears once a model from that provider
|
|
812
|
+
// becomes reachable, which happens after the user pastes a key
|
|
813
|
+
// and refreshModels() resolves. Walk the rows and patch the
|
|
814
|
+
// default-related controls in place so we don't disturb the
|
|
815
|
+
// input field the user is typing into.
|
|
816
|
+
const defaultProvider = currentDefaultProvider();
|
|
817
|
+
paneEl.querySelectorAll(".us-key-row").forEach((row) => {
|
|
818
|
+
const id = row.dataset.provider;
|
|
819
|
+
const p = PROVIDERS.find((x) => x.id === id);
|
|
820
|
+
if (!p || p.group !== "llm") return;
|
|
821
|
+
const meta = _keysMeta[id];
|
|
822
|
+
const has = !!(meta && meta.configured);
|
|
823
|
+
const isDefault = defaultProvider === id;
|
|
824
|
+
const canSet = has && !isDefault && !!primaryModelForProvider(id);
|
|
825
|
+
// Toggle .is-default on the row.
|
|
826
|
+
row.classList.toggle("is-default", isDefault);
|
|
827
|
+
// Sync the badge in the label.
|
|
828
|
+
const label = row.querySelector(".us-key-label");
|
|
829
|
+
if (label) {
|
|
830
|
+
const existing = label.querySelector(".us-key-default-badge");
|
|
831
|
+
if (isDefault && !existing) {
|
|
832
|
+
label.insertAdjacentHTML("beforeend", ' <span class="badge us-key-default-badge">default</span>');
|
|
833
|
+
} else if (!isDefault && existing) {
|
|
834
|
+
existing.remove();
|
|
835
|
+
// Cleanup adjacent whitespace text node so we don't accumulate
|
|
836
|
+
// spaces over time.
|
|
837
|
+
const next = label.lastChild;
|
|
838
|
+
if (next && next.nodeType === 3 && /\s+/.test(next.nodeValue || "")) next.remove();
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
// Sync the "set as default" button.
|
|
842
|
+
const head = row.querySelector(".us-key-head");
|
|
843
|
+
if (head) {
|
|
844
|
+
const existing = head.querySelector("[data-set-default-provider]");
|
|
845
|
+
if (canSet && !existing) {
|
|
846
|
+
// Insert just before the remove button (or at the end).
|
|
847
|
+
const btn = document.createElement("button");
|
|
848
|
+
btn.type = "button";
|
|
849
|
+
btn.className = "us-key-set-default";
|
|
850
|
+
btn.dataset.setDefaultProvider = id;
|
|
851
|
+
btn.title = `Use ${p.label} as the default model provider for new agents`;
|
|
852
|
+
btn.textContent = "set as default";
|
|
853
|
+
btn.addEventListener("click", async (e) => {
|
|
854
|
+
e.preventDefault();
|
|
855
|
+
await setProviderAsDefault(id);
|
|
856
|
+
});
|
|
857
|
+
const removeBtn = head.querySelector("[data-remove-provider]");
|
|
858
|
+
if (removeBtn) head.insertBefore(btn, removeBtn);
|
|
859
|
+
else head.appendChild(btn);
|
|
860
|
+
} else if (!canSet && existing) {
|
|
861
|
+
existing.remove();
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/* Avatar generation · same flow as the agent profile's regenerate
|
|
868
|
+
button (see agent-profile.js / regenerateProfileAvatar): each
|
|
869
|
+
click pulls a fresh seed from AvatarSkill.randomSeed(), saves it
|
|
870
|
+
to the user prefs (`avatarSeed`), and re-paints. The SVG is
|
|
871
|
+
rendered from that seed via AvatarSkill.generate(). */
|
|
872
|
+
function generateAvatar(seed) {
|
|
873
|
+
return window.AvatarSkill.generate(seed);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/* ── Modal shell ──────────────────────────────────────────── */
|
|
877
|
+
function modalHTML() {
|
|
878
|
+
return `
|
|
879
|
+
<div class="user-settings-overlay" id="user-settings-overlay" role="dialog" aria-modal="true" aria-hidden="true">
|
|
880
|
+
<div class="user-settings-modal" role="document">
|
|
881
|
+
|
|
882
|
+
<div class="us-classification">
|
|
883
|
+
<span><span class="dot">●</span> user · settings</span>
|
|
884
|
+
<span class="right">// local</span>
|
|
885
|
+
</div>
|
|
886
|
+
|
|
887
|
+
<header class="us-head">
|
|
888
|
+
<div class="us-title">Preference</div>
|
|
889
|
+
<button type="button" class="us-close" aria-label="Close">✕</button>
|
|
890
|
+
</header>
|
|
891
|
+
|
|
892
|
+
<div class="us-frame">
|
|
893
|
+
<nav class="us-nav" role="tablist">
|
|
894
|
+
<a href="#" class="us-nav-item active" data-section="user" role="tab" aria-selected="true">User</a>
|
|
895
|
+
<a href="#" class="us-nav-item" data-section="theme" role="tab" aria-selected="false">Theme</a>
|
|
896
|
+
<a href="#" class="us-nav-item" data-section="usage" role="tab" aria-selected="false">Usage</a>
|
|
897
|
+
<a href="#" class="us-nav-item" data-section="keys" role="tab" aria-selected="false">API Key</a>
|
|
898
|
+
<a href="#" class="us-nav-item" data-section="default" role="tab" aria-selected="false">Default Model</a>
|
|
899
|
+
</nav>
|
|
900
|
+
|
|
901
|
+
<div class="us-pane" data-us-pane></div>
|
|
902
|
+
</div>
|
|
903
|
+
|
|
904
|
+
<footer class="us-foot">
|
|
905
|
+
<span class="saved">changes save automatically</span>
|
|
906
|
+
<button type="button" class="us-done">[ Done ]</button>
|
|
907
|
+
</footer>
|
|
908
|
+
|
|
909
|
+
</div>
|
|
910
|
+
</div>
|
|
911
|
+
`;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
let overlay, modal, paneEl, currentSection = "user";
|
|
915
|
+
let activeProviders = null; // populated lazily from saved keys; reset on each open
|
|
916
|
+
|
|
917
|
+
function renderSection(id) {
|
|
918
|
+
currentSection = id;
|
|
919
|
+
if (id === "user") paneEl.innerHTML = userSectionHTML();
|
|
920
|
+
else if (id === "theme") paneEl.innerHTML = themeSectionHTML();
|
|
921
|
+
else if (id === "usage") paneEl.innerHTML = usageSectionHTML();
|
|
922
|
+
else if (id === "keys") paneEl.innerHTML = keysSectionHTML();
|
|
923
|
+
else if (id === "default") paneEl.innerHTML = defaultModelSectionHTML();
|
|
924
|
+
|
|
925
|
+
// Section-specific wiring
|
|
926
|
+
if (id === "user") wireUserSection();
|
|
927
|
+
if (id === "keys") wireKeysSection();
|
|
928
|
+
if (id === "usage") wireUsageSection();
|
|
929
|
+
if (id === "default") wireDefaultModelSection();
|
|
930
|
+
|
|
931
|
+
// Active rail item
|
|
932
|
+
modal.querySelectorAll(".us-nav-item").forEach((el) => {
|
|
933
|
+
const on = el.dataset.section === id;
|
|
934
|
+
el.classList.toggle("active", on);
|
|
935
|
+
el.setAttribute("aria-selected", on ? "true" : "false");
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function paintUserAvatar() {
|
|
940
|
+
const frame = paneEl.querySelector("[data-us-avatar]");
|
|
941
|
+
if (!frame) return;
|
|
942
|
+
const u = getUser();
|
|
943
|
+
// Mirror the agent profile flow · the avatar is whatever seed is
|
|
944
|
+
// saved on the user prefs. If none has ever been generated, mint
|
|
945
|
+
// one now so the avatar is stable across reloads.
|
|
946
|
+
let seed = u.avatarSeed;
|
|
947
|
+
if (!seed && window.AvatarSkill) {
|
|
948
|
+
seed = window.AvatarSkill.randomSeed();
|
|
949
|
+
saveUser({ avatarSeed: seed });
|
|
950
|
+
// Cascade the freshly-minted seed to app.prefs so the sidebar
|
|
951
|
+
// foot picks it up on the same paint.
|
|
952
|
+
if (window.app) {
|
|
953
|
+
window.app.prefs = { ...(window.app.prefs || {}), avatarSeed: seed };
|
|
954
|
+
if (typeof window.app.renderUserBlock === "function") window.app.renderUserBlock();
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
frame.innerHTML = generateAvatar(seed || "default");
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function wireUserSection() {
|
|
961
|
+
const nameInput = paneEl.querySelector("[data-us-name]");
|
|
962
|
+
const introInput = paneEl.querySelector("[data-us-intro]");
|
|
963
|
+
const introCount = paneEl.querySelector("[data-us-intro-count]");
|
|
964
|
+
|
|
965
|
+
function persist() {
|
|
966
|
+
const u = { name: nameInput.value.trim() || "Kay", intro: introInput.value };
|
|
967
|
+
saveUser(u);
|
|
968
|
+
// Refresh app state + redraw sidebar foot via the central renderer.
|
|
969
|
+
if (window.app) {
|
|
970
|
+
window.app.prefs = { ...(window.app.prefs || {}), ...u };
|
|
971
|
+
if (typeof window.app.renderUserBlock === "function") window.app.renderUserBlock();
|
|
972
|
+
} else {
|
|
973
|
+
document.querySelectorAll(".sidebar-foot .user-name").forEach((el) => { el.textContent = (u.name || "Kay").toUpperCase(); });
|
|
974
|
+
document.querySelectorAll(".sidebar-foot .user-menu .name").forEach((el) => { el.textContent = u.name || "Kay"; });
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
nameInput.addEventListener("input", persist);
|
|
979
|
+
introInput.addEventListener("input", () => {
|
|
980
|
+
introCount.textContent = introInput.value.length;
|
|
981
|
+
persist();
|
|
982
|
+
});
|
|
983
|
+
introCount.textContent = introInput.value.length;
|
|
984
|
+
|
|
985
|
+
// Regenerate avatar · same pattern as agent-profile's
|
|
986
|
+
// regenerateProfileAvatar: pull a fresh randomSeed, persist it to
|
|
987
|
+
// the user prefs, repaint. No counter, no name/intro composition —
|
|
988
|
+
// the seed is the only thing that determines the avatar.
|
|
989
|
+
paneEl.querySelector("[data-us-regen-avatar]").addEventListener("click", (e) => {
|
|
990
|
+
e.preventDefault();
|
|
991
|
+
if (!window.AvatarSkill) return;
|
|
992
|
+
const seed = window.AvatarSkill.randomSeed();
|
|
993
|
+
saveUser({ avatarSeed: seed });
|
|
994
|
+
paintUserAvatar();
|
|
995
|
+
// Push the new seed into app.prefs so the sidebar foot's user
|
|
996
|
+
// avatar repaints with the same SVG. Without this, the settings
|
|
997
|
+
// overlay shows the new face but the sidebar keeps the old one
|
|
998
|
+
// until the next reload.
|
|
999
|
+
if (window.app) {
|
|
1000
|
+
window.app.prefs = { ...(window.app.prefs || {}), avatarSeed: seed };
|
|
1001
|
+
if (typeof window.app.renderUserBlock === "function") window.app.renderUserBlock();
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
paintUserAvatar();
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function rerenderKeysSection() {
|
|
1009
|
+
paneEl.innerHTML = keysSectionHTML();
|
|
1010
|
+
wireKeysSection();
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function wireKeysSection() {
|
|
1014
|
+
paneEl.querySelectorAll("[data-key-eye]").forEach((btn) => {
|
|
1015
|
+
btn.addEventListener("click", (e) => {
|
|
1016
|
+
e.preventDefault();
|
|
1017
|
+
const input = btn.parentElement.querySelector("input");
|
|
1018
|
+
if (input) input.type = input.type === "password" ? "text" : "password";
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
// Add a provider row
|
|
1023
|
+
paneEl.querySelectorAll("[data-add-provider]").forEach((btn) => {
|
|
1024
|
+
btn.addEventListener("click", (e) => {
|
|
1025
|
+
e.preventDefault();
|
|
1026
|
+
const id = btn.dataset.addProvider;
|
|
1027
|
+
if (!activeProviders.includes(id)) activeProviders.push(id);
|
|
1028
|
+
rerenderKeysSection();
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
// Remove a provider row (server-side delete clears its key too).
|
|
1033
|
+
paneEl.querySelectorAll("[data-remove-provider]").forEach((btn) => {
|
|
1034
|
+
btn.addEventListener("click", async (e) => {
|
|
1035
|
+
e.preventDefault();
|
|
1036
|
+
const id = btn.dataset.removeProvider;
|
|
1037
|
+
activeProviders = activeProviders.filter((p) => p !== id);
|
|
1038
|
+
await setProviderKey(id, ""); // clears server-side
|
|
1039
|
+
await refreshModels();
|
|
1040
|
+
rerenderKeysSection();
|
|
1041
|
+
});
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
// Set this provider as the default · picks the provider's primary
|
|
1045
|
+
// model and persists it as defaultModelV. The badge moves to the
|
|
1046
|
+
// newly-default row on re-render.
|
|
1047
|
+
paneEl.querySelectorAll("[data-set-default-provider]").forEach((btn) => {
|
|
1048
|
+
btn.addEventListener("click", async (e) => {
|
|
1049
|
+
e.preventDefault();
|
|
1050
|
+
const id = btn.dataset.setDefaultProvider;
|
|
1051
|
+
await setProviderAsDefault(id);
|
|
1052
|
+
});
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
// Default-model picker · persists to /api/prefs.
|
|
1056
|
+
paneEl.querySelectorAll("[data-default-model]").forEach((sel) => {
|
|
1057
|
+
sel.addEventListener("change", () => { saveDefaultModel(sel.value); });
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
// Auto-save: every keystroke / paste persists immediately, no Save button.
|
|
1061
|
+
// We debounce slightly so we don't fire a server PUT on every character —
|
|
1062
|
+
// 220ms after the user stops typing. Empty value is a no-op (NOT a
|
|
1063
|
+
// delete) — browser autofill / blur events occasionally fire `input`
|
|
1064
|
+
// with v="", and we never want that to wipe a real key. Explicit
|
|
1065
|
+
// removal goes through the ✕ button.
|
|
1066
|
+
const debounceMap = new WeakMap();
|
|
1067
|
+
function persistRow(row) {
|
|
1068
|
+
const provider = row.dataset.provider;
|
|
1069
|
+
const input = row.querySelector("[data-key-input]");
|
|
1070
|
+
const v = input.value;
|
|
1071
|
+
const trimmed = v.trim();
|
|
1072
|
+
|
|
1073
|
+
// No-op on empty · never DELETE via the input field. The ✕ button
|
|
1074
|
+
// is the only path that clears a key. This protects against
|
|
1075
|
+
// autofill races + accidental select-all+delete.
|
|
1076
|
+
if (!trimmed) return;
|
|
1077
|
+
|
|
1078
|
+
// Optimistic local UI update on non-empty input.
|
|
1079
|
+
const status = row.querySelector("[data-status]");
|
|
1080
|
+
status.classList.add("on");
|
|
1081
|
+
status.classList.remove("off");
|
|
1082
|
+
status.textContent = "● configured";
|
|
1083
|
+
|
|
1084
|
+
// Debounced server write
|
|
1085
|
+
const prev = debounceMap.get(row);
|
|
1086
|
+
if (prev) clearTimeout(prev);
|
|
1087
|
+
const timer = setTimeout(async () => {
|
|
1088
|
+
await setProviderKey(provider, v);
|
|
1089
|
+
await refreshModels();
|
|
1090
|
+
refreshModelsSummary();
|
|
1091
|
+
}, 220);
|
|
1092
|
+
debounceMap.set(row, timer);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
paneEl.querySelectorAll(".us-key-row").forEach((row) => {
|
|
1096
|
+
const input = row.querySelector("[data-key-input]");
|
|
1097
|
+
if (!input) return;
|
|
1098
|
+
input.addEventListener("input", () => persistRow(row));
|
|
1099
|
+
// Paste handler — input fires after paste too, but this is explicit
|
|
1100
|
+
// and lets us snap-update the status pill on the same tick.
|
|
1101
|
+
input.addEventListener("paste", () => {
|
|
1102
|
+
// paste mutates value asynchronously; defer one tick
|
|
1103
|
+
setTimeout(() => persistRow(row), 0);
|
|
1104
|
+
});
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
// First render of the section · the shared cache may already
|
|
1108
|
+
// have a snapshot (models-cache.js fetches at module load). If
|
|
1109
|
+
// not, kick off a refresh and update the summary when it lands.
|
|
1110
|
+
// Either way, subsequent key-write paths refresh the cache, and
|
|
1111
|
+
// the inline pill refresh below picks up the new state.
|
|
1112
|
+
if (!modelsSnapshot()) {
|
|
1113
|
+
refreshModels().then(refreshModelsSummary);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Always re-fetch /api/keys on every keys-tab open. The bootstrap
|
|
1117
|
+
// fetchKeyMeta runs once at page load, but the user can land on
|
|
1118
|
+
// settings before that resolves, OR can open settings a long time
|
|
1119
|
+
// after — either way `_keysMeta` may be empty / stale and the
|
|
1120
|
+
// status pills come up "○ not set" even when the server has a key.
|
|
1121
|
+
// The cheap GET keeps this tab honest. We update the pills + the
|
|
1122
|
+
// input placeholder INLINE (no full rerender) so the user's
|
|
1123
|
+
// typing-in-progress isn't disturbed and so we don't loop through
|
|
1124
|
+
// wireKeysSection again.
|
|
1125
|
+
fetchKeyMeta().then(() => {
|
|
1126
|
+
if (currentSection !== "keys") return;
|
|
1127
|
+
paneEl.querySelectorAll(".us-key-row").forEach((row) => {
|
|
1128
|
+
const provider = row.dataset.provider;
|
|
1129
|
+
const meta = _keysMeta[provider];
|
|
1130
|
+
const has = !!(meta && meta.configured);
|
|
1131
|
+
const status = row.querySelector("[data-status]");
|
|
1132
|
+
if (!status) return;
|
|
1133
|
+
if (has) {
|
|
1134
|
+
status.classList.add("on");
|
|
1135
|
+
status.classList.remove("off");
|
|
1136
|
+
status.textContent = "● configured";
|
|
1137
|
+
} else {
|
|
1138
|
+
status.classList.add("off");
|
|
1139
|
+
status.classList.remove("on");
|
|
1140
|
+
status.textContent = "○ not set";
|
|
1141
|
+
}
|
|
1142
|
+
// Refresh placeholder using the masked preview · "sk-or…YjNH ·
|
|
1143
|
+
// paste to replace" when configured, the provider's hint when
|
|
1144
|
+
// not. Skip if user has typed something so we don't clobber
|
|
1145
|
+
// their in-progress edit. Toggle the .has-preview class so the
|
|
1146
|
+
// placeholder picks up the "real-value" colour rather than the
|
|
1147
|
+
// dim hint colour — the user reads it as their stored key,
|
|
1148
|
+
// not as missing text.
|
|
1149
|
+
const input = row.querySelector("[data-key-input]");
|
|
1150
|
+
if (input && !input.value) {
|
|
1151
|
+
const provDef = PROVIDERS.find((p) => p.id === provider);
|
|
1152
|
+
const preview = has && meta.preview ? meta.preview : null;
|
|
1153
|
+
input.placeholder = has
|
|
1154
|
+
? (preview || "••••••••")
|
|
1155
|
+
: (provDef ? provDef.placeholder : "");
|
|
1156
|
+
input.classList.toggle("has-preview", has);
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/* ── Open / close ─────────────────────────────────────────── */
|
|
1163
|
+
function open() {
|
|
1164
|
+
if (!overlay) return;
|
|
1165
|
+
activeProviders = null; // re-derive from saved keys on next render
|
|
1166
|
+
renderSection(currentSection || "user");
|
|
1167
|
+
overlay.classList.add("open");
|
|
1168
|
+
overlay.setAttribute("aria-hidden", "false");
|
|
1169
|
+
document.body.style.overflow = "hidden";
|
|
1170
|
+
}
|
|
1171
|
+
function close() {
|
|
1172
|
+
if (!overlay) return;
|
|
1173
|
+
overlay.classList.remove("open");
|
|
1174
|
+
overlay.setAttribute("aria-hidden", "true");
|
|
1175
|
+
document.body.style.overflow = "";
|
|
1176
|
+
// Sync app.keys with whatever the user just configured · the
|
|
1177
|
+
// requireModelKey gate reads from app.keys and would otherwise
|
|
1178
|
+
// see stale state until the next page reload.
|
|
1179
|
+
if (window.app && typeof window.app.refreshKeys === "function") {
|
|
1180
|
+
window.app.refreshKeys();
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function init() {
|
|
1185
|
+
if (document.getElementById("user-settings-overlay")) return;
|
|
1186
|
+
const wrap = document.createElement("div");
|
|
1187
|
+
wrap.innerHTML = modalHTML().trim();
|
|
1188
|
+
document.body.appendChild(wrap.firstChild);
|
|
1189
|
+
overlay = document.getElementById("user-settings-overlay");
|
|
1190
|
+
modal = overlay.querySelector(".user-settings-modal");
|
|
1191
|
+
paneEl = modal.querySelector("[data-us-pane]");
|
|
1192
|
+
|
|
1193
|
+
// Close interactions
|
|
1194
|
+
overlay.querySelector(".us-close").addEventListener("click", close);
|
|
1195
|
+
modal.querySelector(".us-done").addEventListener("click", (e) => { e.preventDefault(); close(); });
|
|
1196
|
+
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
|
1197
|
+
document.addEventListener("keydown", (e) => {
|
|
1198
|
+
if (e.key === "Escape" && overlay.classList.contains("open")) {
|
|
1199
|
+
e.stopImmediatePropagation();
|
|
1200
|
+
close();
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
// Trigger anywhere on the page
|
|
1205
|
+
document.addEventListener("click", (e) => {
|
|
1206
|
+
if (e.target.closest("[data-user-settings-trigger]")) {
|
|
1207
|
+
e.preventDefault();
|
|
1208
|
+
open();
|
|
1209
|
+
}
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
// Rail nav
|
|
1213
|
+
modal.addEventListener("click", (e) => {
|
|
1214
|
+
const item = e.target.closest(".us-nav-item");
|
|
1215
|
+
if (!item) return;
|
|
1216
|
+
e.preventDefault();
|
|
1217
|
+
renderSection(item.dataset.section);
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
// Theme rows (delegated since pane re-renders)
|
|
1221
|
+
modal.addEventListener("click", (e) => {
|
|
1222
|
+
const row = e.target.closest(".us-theme");
|
|
1223
|
+
if (!row) return;
|
|
1224
|
+
e.preventDefault();
|
|
1225
|
+
e.stopPropagation();
|
|
1226
|
+
const slug = row.dataset.themeSlug;
|
|
1227
|
+
paneEl.querySelectorAll(".us-theme").forEach((el) => el.classList.remove("active"));
|
|
1228
|
+
row.classList.add("active");
|
|
1229
|
+
setTheme(slug);
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
// Cross-tab theme sync
|
|
1233
|
+
window.addEventListener("storage", (e) => {
|
|
1234
|
+
if (e.key === THEME_KEY && e.newValue) {
|
|
1235
|
+
setTheme(e.newValue);
|
|
1236
|
+
if (paneEl && currentSection === "theme") {
|
|
1237
|
+
paneEl.querySelectorAll(".us-theme").forEach((el) => {
|
|
1238
|
+
el.classList.toggle("active", el.dataset.themeSlug === e.newValue);
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
// Initial pane
|
|
1245
|
+
renderSection("user");
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Public
|
|
1249
|
+
window.openUserSettings = function (opts) {
|
|
1250
|
+
if (!overlay) init();
|
|
1251
|
+
open();
|
|
1252
|
+
// Optional deep-link · jump to a section + focus a key row.
|
|
1253
|
+
// Used by agent-profile's "Configure key" link on the web-search row.
|
|
1254
|
+
if (opts && typeof opts === "object") {
|
|
1255
|
+
if (typeof opts.section === "string") {
|
|
1256
|
+
renderSection(opts.section);
|
|
1257
|
+
if (opts.section === "keys" && typeof opts.focusProvider === "string") {
|
|
1258
|
+
// Defer one frame so the section's DOM is in place.
|
|
1259
|
+
setTimeout(() => {
|
|
1260
|
+
const row = paneEl.querySelector(`.us-key-row[data-provider="${opts.focusProvider}"]`);
|
|
1261
|
+
if (!row) return;
|
|
1262
|
+
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
1263
|
+
row.classList.add("us-key-row-flash");
|
|
1264
|
+
setTimeout(() => row.classList.remove("us-key-row-flash"), 1500);
|
|
1265
|
+
const input = row.querySelector("input[data-key-input]");
|
|
1266
|
+
if (input) input.focus();
|
|
1267
|
+
}, 60);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
window.closeUserSettings = close;
|
|
1273
|
+
window.applyTheme = setTheme;
|
|
1274
|
+
|
|
1275
|
+
// Bootstrap: prefetch prefs and key meta so the first render has real
|
|
1276
|
+
// values, then init.
|
|
1277
|
+
async function bootstrap() {
|
|
1278
|
+
await Promise.all([fetchPrefs(), fetchKeyMeta()]);
|
|
1279
|
+
init();
|
|
1280
|
+
// Mirror name into sidebar foot once we know it.
|
|
1281
|
+
document.querySelectorAll(".sidebar-foot .user-name").forEach((el) => {
|
|
1282
|
+
el.textContent = (_prefsCache.name || "You").toUpperCase();
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
if (document.readyState === "loading") {
|
|
1287
|
+
document.addEventListener("DOMContentLoaded", bootstrap);
|
|
1288
|
+
} else {
|
|
1289
|
+
bootstrap();
|
|
1290
|
+
}
|
|
1291
|
+
})();
|