privateboard 0.1.9 → 0.1.11

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.
@@ -18,7 +18,7 @@
18
18
 
19
19
  // /api/prefs is async; cache the latest value at module bootstrap so the
20
20
  // synchronous render code below stays simple. saveUser writes through.
21
- let _prefsCache = { name: "You", intro: "", avatarSeed: null };
21
+ let _prefsCache = { name: "You", intro: "", avatarSeed: null, webSearchProvider: "brave", minimaxRegion: "cn" };
22
22
 
23
23
  const THEMES = [
24
24
  { slug: "regent", name: "Regent", desc: "warm gold on dark · default · the boardroom premium",
@@ -49,9 +49,13 @@
49
49
  { id: "openai", label: "OpenAI", hint: "GPT · gpt-5, gpt-5 mini, gpt-4o", placeholder: "sk-…", group: "llm" },
50
50
  { id: "google", label: "Google", hint: "Gemini · 2.5 Pro, 2.5 Flash", placeholder: "AIza…", group: "llm" },
51
51
  { id: "xai", label: "xAI", hint: "Grok · grok-4.3, grok-4.1 fast", placeholder: "xai-…", group: "llm" },
52
+ { id: "minimax", label: "MiniMax", hint: "speech · T2A voices, cloning, streaming audio", placeholder: "mm-…", group: "voice" },
53
+ { id: "elevenlabs", label: "ElevenLabs", hint: "text-to-speech · pricing & docs at elevenlabs.io", placeholder: "xi-…", group: "voice" },
52
54
  // ── Skill Services (not LLM providers, but the same encrypted key store) ──
53
55
  { id: "brave", label: "Brave Search", hint: "powers the Web Search system skill · ≈ $5 / 1000 queries · privacy-respecting",
54
56
  placeholder: "BSA…", group: "skill" },
57
+ { id: "tavily", label: "Tavily Search", hint: "alternate Web Search backend · billed per Tavily credits · LLM-focused results",
58
+ placeholder: "tvly-…", group: "skill" },
55
59
  ];
56
60
 
57
61
  function escape(s) {
@@ -60,6 +64,10 @@
60
64
  }[c]));
61
65
  }
62
66
 
67
+ function tr(key, vars) {
68
+ return (window.I18n && window.I18n.t(key, vars)) || key;
69
+ }
70
+
63
71
  /* ── Storage helpers ───────────────────────────────────────── */
64
72
  function getTheme() { try { return localStorage.getItem(THEME_KEY) || "regent"; } catch (e) { return "regent"; } }
65
73
  function setTheme(slug) {
@@ -76,7 +84,9 @@
76
84
  _prefsCache = {
77
85
  name: typeof data.name === "string" ? data.name : "You",
78
86
  intro: typeof data.intro === "string" ? data.intro : "",
79
- avatarSeed: data.avatarSeed ?? null
87
+ avatarSeed: data.avatarSeed ?? null,
88
+ webSearchProvider: data.webSearchProvider === "tavily" ? "tavily" : "brave",
89
+ minimaxRegion: data.minimaxRegion === "intl" ? "intl" : "cn",
80
90
  };
81
91
  } catch (e) {
82
92
  // Network or server hiccup — fall back to whatever's already cached.
@@ -101,31 +111,28 @@
101
111
  }).catch(() => { /* offline → cache stays, retry on next edit */ });
102
112
  }
103
113
 
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 */ }
114
+ // Provider keys · canonical state lives in keys-store.js (loaded as a
115
+ // module script before this file). All reads/writes go through that store;
116
+ // _keysMeta is a live accessor so the rest of this file needs no changes.
117
+ const _keysMeta = new Proxy({}, {
118
+ get(_, k) { return window.keysStore ? window.keysStore.keysMeta[k] : undefined; },
119
+ set(_, k, v) { if (window.keysStore) window.keysStore.keysMeta[k] = v; return true; },
120
+ ownKeys() { return window.keysStore ? Object.keys(window.keysStore.keysMeta) : []; },
121
+ has(_, k) { return window.keysStore ? k in window.keysStore.keysMeta : false; },
122
+ getOwnPropertyDescriptor(_, k) {
123
+ const v = window.keysStore ? window.keysStore.keysMeta[k] : undefined;
124
+ return v !== undefined ? { value: v, writable: true, enumerable: true, configurable: true } : undefined;
125
+ },
126
+ });
127
+
128
+ function fetchKeyMeta() {
129
+ return window.keysStore ? window.keysStore.fetchKeyMeta() : Promise.resolve();
119
130
  }
120
131
 
121
132
  // Sync read used by new-agent.js: returns a map { provider: truthy } where
122
133
  // 'truthy' is the meta object so existence-check (`if (keys[p])`) still works.
123
134
  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;
135
+ return window.keysStore ? window.keysStore.getConfiguredKeys() : {};
129
136
  }
130
137
 
131
138
  // Available-models snapshot · shape from /api/models. We don't keep
@@ -163,21 +170,9 @@
163
170
  // were removed in favour of the dedicated "Default Model" sidebar
164
171
  // pane (single source of truth). Helpers deleted as dead code.
165
172
 
166
- // Set / clear a single provider key. The server applies the trim+empty-=delete
167
- // semantic; we mirror the resulting meta into our local cache.
168
- async function setProviderKey(provider, value) {
169
- try {
170
- const trimmed = (value || "").trim();
171
- const r = await fetch("/api/keys/" + encodeURIComponent(provider), {
172
- method: trimmed ? "PUT" : "DELETE",
173
- headers: trimmed ? { "content-type": "application/json" } : undefined,
174
- body: trimmed ? JSON.stringify({ key: trimmed }) : undefined
175
- });
176
- if (!r.ok) return null;
177
- const meta = await r.json();
178
- _keysMeta[provider] = meta;
179
- return meta;
180
- } catch (e) { return null; }
173
+ // Set / clear a single provider key delegated to keys-store.js.
174
+ function setProviderKey(provider, value) {
175
+ return window.keysStore ? window.keysStore.setProviderKey(provider, value) : Promise.resolve(null);
181
176
  }
182
177
 
183
178
  // Public — other modules read provider configuration via this
@@ -191,24 +186,24 @@
191
186
  const u = getUser();
192
187
  return `
193
188
  <div class="us-pane-head">
194
- <div class="us-pane-tag">▸ User</div>
195
- <div class="us-pane-deck">how the boardroom addresses you and what it knows about your context.</div>
189
+ <div class="us-pane-tag">${tr("us_user_tag")}</div>
190
+ <div class="us-pane-deck">${tr("us_user_deck")}</div>
196
191
  </div>
197
192
 
198
193
  <div class="us-pane-body">
199
194
  <div class="us-row">
200
- <div class="us-row-label">Avatar</div>
195
+ <div class="us-row-label">${tr("us_avatar")}</div>
201
196
  <div class="us-row-field us-avatar-row">
202
197
  <div class="us-avatar-frame" data-us-avatar></div>
203
198
  <button type="button" class="us-mini-btn" data-us-regen-avatar>
204
199
  <span class="us-mini-btn-mark">◆</span>
205
- <span>generate 8-bit avatar</span>
200
+ <span>${tr("us_regen_avatar")}</span>
206
201
  </button>
207
202
  </div>
208
203
  </div>
209
204
 
210
205
  <div class="us-row">
211
- <div class="us-row-label">Name</div>
206
+ <div class="us-row-label">${tr("us_name")}</div>
212
207
  <div class="us-row-field">
213
208
  <div class="us-input-wrap">
214
209
  <input type="text" class="us-input" data-us-name placeholder="Kay" maxlength="32" value="${escape(u.name || "")}">
@@ -217,12 +212,12 @@
217
212
  </div>
218
213
 
219
214
  <div class="us-row">
220
- <div class="us-row-label">About you</div>
215
+ <div class="us-row-label">${tr("us_about")}</div>
221
216
  <div class="us-row-field">
222
217
  <div class="us-input-wrap tall">
223
- <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>
218
+ <textarea class="us-input" data-us-intro maxlength="320" placeholder="${escape(tr("us_intro_ph"))}">${escape(u.intro || "")}</textarea>
224
219
  </div>
225
- <div class="us-row-meta"><span data-us-intro-count>0</span> / 320 chars</div>
220
+ <div class="us-row-meta"><span data-us-intro-count>0</span><span>${tr("us_intro_meta_rest")}</span></div>
226
221
  </div>
227
222
  </div>
228
223
 
@@ -235,8 +230,8 @@
235
230
  const swatchSpans = (cs) => cs.map((c) => `<span style="background:${c}"></span>`).join("");
236
231
  return `
237
232
  <div class="us-pane-head">
238
- <div class="us-pane-tag">▸ Theme</div>
239
- <div class="us-pane-deck">global · zsh-inspired palettes. Applied instantly across all rooms.</div>
233
+ <div class="us-pane-tag">${tr("us_theme_tag")}</div>
234
+ <div class="us-pane-deck">${tr("us_theme_deck")}</div>
240
235
  </div>
241
236
 
242
237
  <div class="us-pane-body">
@@ -257,9 +252,8 @@
257
252
  }
258
253
 
259
254
  /* ── Other settings · misc per-user toggles that don't fit a
260
- dedicated section. Currently just the typing-sound effect; a
261
- natural home for future small ambient / UX preferences (sound
262
- cues, animations, etc.) without growing the nav for each one. */
255
+ dedicated section. Interface language · typing-sound effect;
256
+ natural home for future small ambient / UX preferences. */
263
257
  function otherSettingsSectionHTML() {
264
258
  return `
265
259
  <div class="us-pane-head">
@@ -268,6 +262,18 @@
268
262
  </div>
269
263
 
270
264
  <div class="us-pane-body">
265
+ <div class="us-row">
266
+ <div class="us-row-label">${tr("us_locale_label")}</div>
267
+ <div class="us-row-field">
268
+ <button type="button" class="cmp-dd" data-cmp-dropdown="locale" title="${escape(tr("us_locale_label"))}" data-i18n-aria="aria_language" aria-label="">
269
+ <span class="cmp-dd-label" data-i18n="us_locale_label">${escape(tr("us_locale_label"))}</span>
270
+ <span class="cmp-dd-value" data-cmp-dd-value="locale">${escape(tr(window.I18n && window.I18n.getLocale && window.I18n.getLocale() === "zh" ? "locale_zh" : "locale_en"))}</span>
271
+ <span class="cmp-dd-chevron">▾</span>
272
+ </button>
273
+ <p class="us-locale-deck">${escape(tr("us_locale_deck"))}</p>
274
+ </div>
275
+ </div>
276
+
271
277
  <div class="us-row">
272
278
  <div class="us-row-label">Typing sound</div>
273
279
  <div class="us-row-field">
@@ -288,6 +294,12 @@
288
294
 
289
295
  function wireOtherSettingsSection() {
290
296
  if (!paneEl) return;
297
+ if (window.I18n && typeof window.I18n.applyDom === "function") {
298
+ window.I18n.applyDom(paneEl);
299
+ }
300
+ if (window.I18n && typeof window.I18n.syncLocaleControls === "function") {
301
+ window.I18n.syncLocaleControls();
302
+ }
291
303
  // Typing-sound toggle · the persistence + audio context lives in
292
304
  // window.boardroomTypingSfx (typing-sfx.js); this row only mirrors
293
305
  // the current state and proxies clicks. Reading inside wire-up
@@ -339,13 +351,13 @@
339
351
  function usageSectionHTML() {
340
352
  return `
341
353
  <div class="us-pane-head">
342
- <div class="us-pane-tag">▸ Usage</div>
343
- <div class="us-pane-deck">cumulative LLM-call accounting · billed across every director / chair turn since this boardroom was opened.</div>
354
+ <div class="us-pane-tag">${tr("us_usage_tag")}</div>
355
+ <div class="us-pane-deck">${tr("us_usage_deck")}</div>
344
356
  </div>
345
357
 
346
358
  <div class="us-pane-body">
347
359
  <div class="us-usage" data-usage-pane>
348
- <div class="us-usage-loading">measuring…</div>
360
+ <div class="us-usage-loading">${tr("us_usage_loading")}</div>
349
361
  </div>
350
362
  </div>
351
363
  `;
@@ -378,7 +390,7 @@
378
390
  pane.innerHTML = `
379
391
  <div class="us-usage-empty">
380
392
  <div class="us-usage-empty-num">0</div>
381
- <div class="us-usage-empty-text">no LLM calls billed yet · open a room and let the directors speak.</div>
393
+ <div class="us-usage-empty-text">${tr("us_usage_empty")}</div>
382
394
  </div>
383
395
  `;
384
396
  return;
@@ -539,7 +551,7 @@
539
551
  <div class="us-model-stats">
540
552
  <span class="us-model-tokens">${fmtTokens(m.tokens)}</span>
541
553
  <span class="us-model-pct">${pct.toFixed(1)}%</span>
542
- <span class="us-model-agents">${m.agents} agent${m.agents === 1 ? "" : "s"}</span>
554
+ <span class="us-model-agents">${tr("us_usage_agents", { n: m.agents })}</span>
543
555
  </div>
544
556
  </div>
545
557
  `;
@@ -549,7 +561,7 @@
549
561
  const agentRows = topAgents.map((a) => {
550
562
  const pct = (a.tokens / total) * 100;
551
563
  const color = PROVIDER_COLOR_VAR[a.provider] || PROVIDER_COLOR_VAR.unknown;
552
- const role = a.roleKind === "moderator" ? "chair" : "director";
564
+ const role = a.roleKind === "moderator" ? tr("us_usage_chair") : tr("us_usage_director");
553
565
  return `
554
566
  <div class="us-agent-row">
555
567
  <div class="us-agent-name-col">
@@ -567,7 +579,7 @@
567
579
 
568
580
  const silentCount = byAgent.length - topAgents.length;
569
581
  const silentNote = silentCount > 0
570
- ? `<div class="us-agent-silent">+ ${silentCount} agent${silentCount === 1 ? "" : "s"} not yet billed</div>`
582
+ ? `<div class="us-agent-silent">${tr("us_usage_silent", { n: silentCount })}</div>`
571
583
  : "";
572
584
 
573
585
  const retiredNote = retired.tokens > 0
@@ -660,6 +672,7 @@
660
672
  // "+ add provider" flow).
661
673
  const LLM_PROVIDER_IDS = PROVIDERS.filter((p) => p.group === "llm").map((p) => p.id);
662
674
  const SKILL_PROVIDER_IDS = PROVIDERS.filter((p) => p.group === "skill").map((p) => p.id);
675
+ const VOICE_PROVIDER_IDS = PROVIDERS.filter((p) => p.group === "voice").map((p) => p.id);
663
676
 
664
677
  function ensureActiveProviders() {
665
678
  if (activeProviders === null) {
@@ -700,7 +713,23 @@
700
713
  <div class="us-key-head">
701
714
  <div class="us-key-label">${escape(p.label)}</div>
702
715
  <div class="us-key-status ${has ? "on" : "off"}" data-status>${has ? "● configured" : "○ not set"}</div>
703
- ${removable ? `<button type="button" class="us-key-remove" data-remove-provider="${p.id}" title="Remove">✕</button>` : ""}
716
+ ${(() => {
717
+ if (!removable) return "";
718
+ // Last-LLM guardrail · matches the server's DELETE check.
719
+ // Block removal of the ONE configured LLM key so the boardroom
720
+ // never lands in "no usable carrier" state. Unconfigured rows
721
+ // (or non-LLM providers) bypass the lock — removing them
722
+ // doesn't reduce working-key count.
723
+ const isLLM = p.group === "llm";
724
+ const isConfigured = !!(_keysMeta[p.id] && _keysMeta[p.id].configured);
725
+ const llmConfiguredCount = LLM_PROVIDER_IDS.filter(
726
+ (id) => _keysMeta[id] && _keysMeta[id].configured,
727
+ ).length;
728
+ const lock = isLLM && isConfigured && llmConfiguredCount <= 1;
729
+ return lock
730
+ ? `<button type="button" class="us-key-remove is-locked" disabled title="Add another LLM key first — at least one must remain configured.">✕</button>`
731
+ : `<button type="button" class="us-key-remove" data-remove-provider="${p.id}" title="Remove">✕</button>`;
732
+ })()}
704
733
  </div>
705
734
  <div class="us-key-hint">${escape(p.hint)}</div>
706
735
  <div class="us-input-wrap">
@@ -722,6 +751,52 @@
722
751
  `;
723
752
  }
724
753
 
754
+ /** Shown only when BOTH Brave Search and Tavily keys are configured. */
755
+ function webSearchBackendPrefHTML() {
756
+ const braveOk = !!(_keysMeta.brave && _keysMeta.brave.configured);
757
+ const tavilyOk = !!(_keysMeta.tavily && _keysMeta.tavily.configured);
758
+ const visible = braveOk && tavilyOk;
759
+ const pref = _prefsCache.webSearchProvider === "tavily" ? "tavily" : "brave";
760
+ return `
761
+ <div class="us-key-group us-key-group-ws-backend" data-us-ws-backend-wrap ${visible ? "" : "hidden"}>
762
+ <div class="us-key-group-tag">${tr("us_ws_backend_tag")}</div>
763
+ <div class="us-key-group-deck">${tr("us_ws_backend_deck")}</div>
764
+ <div class="us-ws-backend-radios">
765
+ <label class="us-ws-backend-label">
766
+ <input type="radio" name="us-ws-backend" value="brave" ${pref === "brave" ? "checked" : ""}>
767
+ <span>${tr("us_ws_backend_brave")}</span>
768
+ </label>
769
+ <label class="us-ws-backend-label">
770
+ <input type="radio" name="us-ws-backend" value="tavily" ${pref === "tavily" ? "checked" : ""}>
771
+ <span>${tr("us_ws_backend_tavily")}</span>
772
+ </label>
773
+ </div>
774
+ </div>
775
+ `;
776
+ }
777
+
778
+ function minimaxRegionPrefHTML() {
779
+ const minimaxOk = !!(_keysMeta.minimax && _keysMeta.minimax.configured);
780
+ if (!minimaxOk) return "";
781
+ const region = (_prefsCache && _prefsCache.minimaxRegion) || "cn";
782
+ return `
783
+ <div class="us-key-group us-key-group-minimax-region" data-us-minimax-region-wrap>
784
+ <div class="us-key-group-tag">MiniMax API region</div>
785
+ <div class="us-key-group-deck">Select the region matching your API key source.</div>
786
+ <div class="us-ws-backend-radios">
787
+ <label class="us-ws-backend-label">
788
+ <input type="radio" name="us-minimax-region" value="cn" ${region === "cn" ? "checked" : ""}>
789
+ <span>China (api.minimaxi.com)</span>
790
+ </label>
791
+ <label class="us-ws-backend-label">
792
+ <input type="radio" name="us-minimax-region" value="intl" ${region === "intl" ? "checked" : ""}>
793
+ <span>International (api.minimax.io)</span>
794
+ </label>
795
+ </div>
796
+ </div>
797
+ `;
798
+ }
799
+
725
800
  function keysSectionHTML() {
726
801
  ensureActiveProviders();
727
802
  // Anthropic is temporarily excluded from the "+ add provider"
@@ -736,17 +811,18 @@
736
811
  (p) => p.group === "llm" && !HIDDEN_FROM_ADD.has(p.id) && !activeProviders.includes(p.id),
737
812
  );
738
813
  const skillProviders = PROVIDERS.filter((p) => p.group === "skill");
814
+ const voiceProviders = PROVIDERS.filter((p) => p.group === "voice");
739
815
 
740
816
  return `
741
817
  <div class="us-pane-head">
742
- <div class="us-pane-tag">▸ API Key</div>
743
- <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>
818
+ <div class="us-pane-tag">${tr("us_keys_tag")}</div>
819
+ <div class="us-pane-deck">${tr("us_keys_deck")}</div>
744
820
  </div>
745
821
 
746
822
  <div class="us-pane-body">
747
823
 
748
824
  <div class="us-key-group">
749
- <div class="us-key-group-tag">LLM Providers</div>
825
+ <div class="us-key-group-tag">${tr("us_keys_group_llm")}</div>
750
826
  ${activeProviders.map((id) => {
751
827
  const p = PROVIDERS.find((x) => x.id === id);
752
828
  if (!p) return "";
@@ -754,7 +830,7 @@
754
830
  }).join("")}
755
831
  ${addable.length > 0 ? `
756
832
  <div class="us-key-add">
757
- <span class="us-key-add-label">+ add provider:</span>
833
+ <span class="us-key-add-label">${tr("us_keys_add_label")}</span>
758
834
  <div class="us-key-add-chips">
759
835
  ${addable.map((p) => `
760
836
  <button type="button" class="us-key-add-chip" data-add-provider="${p.id}">${escape(p.label)}</button>
@@ -766,12 +842,24 @@
766
842
 
767
843
  ${skillProviders.length > 0 ? `
768
844
  <div class="us-key-group us-key-group-skill">
769
- <div class="us-key-group-tag">Skill Services</div>
770
- <div class="us-key-group-deck">enables system skills that need an outside service. Each agent can opt in or out per-profile.</div>
845
+ <div class="us-key-group-tag">${tr("us_keys_group_skill")}</div>
846
+ <div class="us-key-group-deck">${tr("us_keys_skill_deck")}</div>
771
847
  ${skillProviders.map((p) => renderKeyRow(p, !!(_keysMeta[p.id] && _keysMeta[p.id].configured))).join("")}
772
848
  </div>
773
849
  ` : ""}
774
850
 
851
+ ${webSearchBackendPrefHTML()}
852
+
853
+ ${voiceProviders.length > 0 ? `
854
+ <div class="us-key-group us-key-group-voice">
855
+ <div class="us-key-group-tag">Voice providers</div>
856
+ <div class="us-key-group-deck">Used for voice meetings and per-director speech synthesis.</div>
857
+ ${voiceProviders.map((p) => renderKeyRow(p, !!(_keysMeta[p.id] && _keysMeta[p.id].configured))).join("")}
858
+ </div>
859
+ ` : ""}
860
+
861
+ ${minimaxRegionPrefHTML()}
862
+
775
863
  <div data-models-summary>${modelsSummaryHTML()}</div>
776
864
 
777
865
  </div>
@@ -922,8 +1010,8 @@
922
1010
 
923
1011
  return `
924
1012
  <div class="us-pane-head">
925
- <div class="us-pane-tag">▸ Default Model</div>
926
- <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>
1013
+ <div class="us-pane-tag">${tr("us_default_tag")}</div>
1014
+ <div class="us-pane-deck">${tr("us_default_deck_long")}</div>
927
1015
  </div>
928
1016
 
929
1017
  <div class="us-pane-body">
@@ -984,20 +1072,20 @@
984
1072
  <div class="user-settings-modal" role="document">
985
1073
 
986
1074
  <div class="us-classification">
987
- <span><span class="dot">●</span> user · settings</span>
988
- <span class="right">// local</span>
1075
+ <span><span class="dot">●</span> <span data-i18n="us_modal_kicker_left"></span></span>
1076
+ <span class="right" data-i18n="us_modal_kicker_right"></span>
989
1077
  </div>
990
1078
 
991
- <button type="button" class="us-close" aria-label="Close">✕</button>
1079
+ <button type="button" class="us-close" data-i18n-aria="us_close" aria-label="Close">✕</button>
992
1080
 
993
1081
  <div class="us-frame">
994
1082
  <nav class="us-nav" role="tablist">
995
- <a href="#" class="us-nav-item active" data-section="user" role="tab" aria-selected="true">User</a>
996
- <a href="#" class="us-nav-item" data-section="theme" role="tab" aria-selected="false">Theme</a>
997
- <a href="#" class="us-nav-item" data-section="usage" role="tab" aria-selected="false">Usage</a>
998
- <a href="#" class="us-nav-item" data-section="keys" role="tab" aria-selected="false">API Key</a>
999
- <a href="#" class="us-nav-item" data-section="default" role="tab" aria-selected="false">Default Model</a>
1000
- <a href="#" class="us-nav-item" data-section="other" role="tab" aria-selected="false">Other settings</a>
1083
+ <a href="#" class="us-nav-item active" data-section="user" role="tab" aria-selected="true" data-i18n="us_nav_user"></a>
1084
+ <a href="#" class="us-nav-item" data-section="theme" role="tab" aria-selected="false" data-i18n="us_nav_theme"></a>
1085
+ <a href="#" class="us-nav-item" data-section="usage" role="tab" aria-selected="false" data-i18n="us_nav_usage"></a>
1086
+ <a href="#" class="us-nav-item" data-section="keys" role="tab" aria-selected="false" data-i18n="us_nav_api_key"></a>
1087
+ <a href="#" class="us-nav-item" data-section="default" role="tab" aria-selected="false" data-i18n="us_default_model_title"></a>
1088
+ <a href="#" class="us-nav-item" data-section="other" role="tab" aria-selected="false" data-i18n="us_nav_other_settings"></a>
1001
1089
  <div class="us-nav-foot" data-us-version aria-label="App version">
1002
1090
  <span class="us-nav-foot-label">version</span>
1003
1091
  <span class="us-nav-foot-value" data-us-version-value>·</span>
@@ -1008,10 +1096,10 @@
1008
1096
  </div>
1009
1097
 
1010
1098
  <footer class="us-foot">
1011
- <span class="saved">changes save automatically</span>
1099
+ <span class="saved" data-i18n="us_foot_saved"></span>
1012
1100
  <div class="us-foot-right">
1013
- <a class="us-website" href="/home.html" target="_blank" rel="noopener">website ↗</a>
1014
- <button type="button" class="us-done">[ Done ]</button>
1101
+ <a class="us-website" href="/home.html" target="_blank" rel="noopener" data-i18n="us_foot_website"></a>
1102
+ <button type="button" class="us-done" data-i18n="us_done"></button>
1015
1103
  </div>
1016
1104
  </footer>
1017
1105
 
@@ -1194,6 +1282,7 @@
1194
1282
  await setProviderKey(provider, v);
1195
1283
  await refreshModels();
1196
1284
  refreshModelsSummary();
1285
+ syncWsBackendPicker();
1197
1286
  }, 220);
1198
1287
  debounceMap.set(row, timer);
1199
1288
  }
@@ -1210,6 +1299,50 @@
1210
1299
  });
1211
1300
  });
1212
1301
 
1302
+ function syncWsBackendPicker() {
1303
+ const wrap = paneEl.querySelector("[data-us-ws-backend-wrap]");
1304
+ if (!wrap) return;
1305
+ const braveOk = !!(_keysMeta.brave && _keysMeta.brave.configured);
1306
+ const tavilyOk = !!(_keysMeta.tavily && _keysMeta.tavily.configured);
1307
+ wrap.hidden = !(braveOk && tavilyOk);
1308
+ const pref = _prefsCache.webSearchProvider === "tavily" ? "tavily" : "brave";
1309
+ wrap.querySelectorAll("input[name=\"us-ws-backend\"]").forEach((inp) => {
1310
+ inp.checked = inp.value === pref;
1311
+ });
1312
+ }
1313
+
1314
+ paneEl.querySelectorAll("input[name=us-ws-backend]").forEach((input) => {
1315
+ input.addEventListener("change", async () => {
1316
+ if (!input.checked) return;
1317
+ const v = input.value === "tavily" ? "tavily" : "brave";
1318
+ _prefsCache = { ..._prefsCache, webSearchProvider: v };
1319
+ try {
1320
+ await fetch("/api/prefs", {
1321
+ method: "PUT",
1322
+ headers: { "content-type": "application/json" },
1323
+ body: JSON.stringify({ webSearchProvider: v }),
1324
+ });
1325
+ } catch (_) { /* offline · prefs stay in _prefsCache */ }
1326
+ });
1327
+ });
1328
+
1329
+ paneEl.querySelectorAll("input[name=us-minimax-region]").forEach((input) => {
1330
+ input.addEventListener("change", async () => {
1331
+ if (!input.checked) return;
1332
+ const v = input.value === "intl" ? "intl" : "cn";
1333
+ _prefsCache = { ..._prefsCache, minimaxRegion: v };
1334
+ try {
1335
+ await fetch("/api/prefs", {
1336
+ method: "PUT",
1337
+ headers: { "content-type": "application/json" },
1338
+ body: JSON.stringify({ minimaxRegion: v }),
1339
+ });
1340
+ } catch (_) { /* offline */ }
1341
+ });
1342
+ });
1343
+
1344
+ syncWsBackendPicker();
1345
+
1213
1346
  // First render of the section · the shared cache may already
1214
1347
  // have a snapshot (models-cache.js fetches at module load). If
1215
1348
  // not, kick off a refresh and update the summary when it lands.
@@ -1282,6 +1415,7 @@
1282
1415
  input.classList.toggle("has-preview", has);
1283
1416
  }
1284
1417
  });
1418
+ syncWsBackendPicker();
1285
1419
  });
1286
1420
  }
1287
1421
 
@@ -1322,21 +1456,33 @@
1322
1456
  }
1323
1457
  } catch { /* swallow · the foot just stays at "·" if offline */ }
1324
1458
  }
1325
- function close() {
1459
+ async function close() {
1326
1460
  if (!overlay) return;
1327
1461
  overlay.classList.remove("open");
1328
1462
  overlay.setAttribute("aria-hidden", "true");
1329
1463
  document.body.style.overflow = "";
1330
- // Sync app.keys with whatever the user just configured · the
1331
- // requireModelKey gate reads from app.keys and would otherwise
1332
- // see stale state until the next page reload.
1464
+ // Sync app.keys + app.agentsById with whatever the user just
1465
+ // configured · the requireModelKey gate, hasAnyVoiceKey, and the
1466
+ // agent profile's voice block all read from these caches. Both
1467
+ // refreshes must complete BEFORE we fire refreshAgentProfileSkills
1468
+ // — otherwise the re-render reads stale state and the locked
1469
+ // voice card persists until the next page reload, and any voices
1470
+ // auto-assigned server-side on a 0→1 voice-key transition won't
1471
+ // surface until then either. Run the two fetches in parallel.
1472
+ const ops = [];
1333
1473
  if (window.app && typeof window.app.refreshKeys === "function") {
1334
- window.app.refreshKeys();
1474
+ ops.push(window.app.refreshKeys());
1475
+ }
1476
+ if (window.app && typeof window.app.refreshAgents === "function") {
1477
+ ops.push(window.app.refreshAgents());
1335
1478
  }
1479
+ try { await Promise.all(ops); } catch { /* swallow · stale state is recoverable */ }
1480
+
1336
1481
  // If an agent profile is open, its skill rows have data-key-
1337
- // configured cached from first paint. Re-fetch so the web-search
1338
- // toggle no longer shows the "configure key" prompt after the
1339
- // user added the Brave key here.
1482
+ // configured cached from first paint and the voice section may
1483
+ // be showing the locked card. Re-fetch / re-render so both the
1484
+ // web-search toggle and the voice picker reflect the new state
1485
+ // immediately, no manual refresh required.
1340
1486
  if (typeof window.refreshAgentProfileSkills === "function") {
1341
1487
  window.refreshAgentProfileSkills();
1342
1488
  }
@@ -1350,8 +1496,16 @@
1350
1496
  overlay = document.getElementById("user-settings-overlay");
1351
1497
  modal = overlay.querySelector(".user-settings-modal");
1352
1498
  paneEl = modal.querySelector("[data-us-pane]");
1499
+ if (window.I18n && typeof window.I18n.applyDom === "function") {
1500
+ window.I18n.applyDom(overlay);
1501
+ }
1353
1502
 
1354
- // Close interactions
1503
+ document.addEventListener("boardroom:locale", () => {
1504
+ if (overlay && window.I18n && typeof window.I18n.applyDom === "function") {
1505
+ window.I18n.applyDom(overlay);
1506
+ }
1507
+ if (overlay && overlay.classList.contains("open")) renderSection(currentSection);
1508
+ });
1355
1509
  overlay.querySelector(".us-close").addEventListener("click", close);
1356
1510
  modal.querySelector(".us-done").addEventListener("click", (e) => { e.preventDefault(); close(); });
1357
1511
  overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });