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.
@@ -11,6 +11,25 @@
11
11
  */
12
12
  (function () {
13
13
 
14
+ function uiT(key, vars) {
15
+ return (window.I18n && window.I18n.t(key, vars)) || key;
16
+ }
17
+
18
+ function profileRoleLabel(p) {
19
+ const r = (p.role || "").trim();
20
+ if (!r) return uiT("ap_role_director_upper");
21
+ const low = r.toLowerCase();
22
+ if (low === "moderator") return uiT("agent_role_tag_moderator");
23
+ if (low === "director") return uiT("ap_role_director_upper");
24
+ return r;
25
+ }
26
+
27
+ function profileStatusLabel(p) {
28
+ if (p.status === "intern") return uiT("ap_status_intern");
29
+ if (p.status === "active" || !p.status) return uiT("ap_status_active");
30
+ return String(p.status).toUpperCase();
31
+ }
32
+
14
33
  const PROFILES = {
15
34
 
16
35
  /* ════════════════════════════════════ SOCRATES ════════════════════════════════════ */
@@ -585,6 +604,7 @@
585
604
  // /api/agents record (via window.app.agentsById) and resolves it here.
586
605
  const MODEL_LABELS = {
587
606
  "sonnet-4-6": { name: "Sonnet 4.6", deck: "balanced · default" },
607
+ "opus-4-6": { name: "Opus 4.6", deck: "deep reasoning · 1M ctx" },
588
608
  "opus-4-7": { name: "Opus 4.7", deck: "deep reasoning" },
589
609
  "opus-4-6": { name: "Opus 4.6", deck: "prior-gen flagship" },
590
610
  "opus-4-6-fast": { name: "Opus 4.6 Fast", deck: "faster 4.6 · same intelligence" },
@@ -597,6 +617,11 @@
597
617
  "gemini-3-1-flash": { name: "Gemini 3.1 Flash Lite", deck: "fast · 1M ctx" },
598
618
  "grok-4-3": { name: "Grok 4.3", deck: "flagship · 1M ctx" },
599
619
  "grok-4-1-fast": { name: "Grok 4.1 Fast", deck: "fast · 256k ctx" },
620
+ "grok-4-20": { name: "Grok 4.20", deck: "2M ctx · big context" },
621
+ "gpt-5-5-pro": { name: "GPT-5.5 Pro", deck: "deep reasoning · 1M ctx" },
622
+ "codex-5-4": { name: "ChatGPT Codex 5.4", deck: "code · agents" },
623
+ "deepseek-v4-pro": { name: "DeepSeek V4 Pro", deck: "reasoning · open weights" },
624
+ "deepseek-v4-flash": { name: "DeepSeek Lite", deck: "V4 Flash · fast · 1M ctx" },
600
625
  };
601
626
 
602
627
  function liveModelFor(slug) {
@@ -901,7 +926,7 @@
901
926
  const bio = bioFor(slug, p);
902
927
  block.innerHTML = `
903
928
  <div class="ap-intel-view" data-ap-intel-view>${
904
- escape(bio) || `<span class="ap-empty">no description yet · click <strong>edit</strong> to add one</span>`
929
+ escape(bio) || `<span class="ap-empty">${escape(uiT("ap_intel_empty"))}</span>`
905
930
  }</div>
906
931
  `;
907
932
  }
@@ -912,12 +937,12 @@
912
937
  const bio = bioFor(slug, p);
913
938
  block.innerHTML = `
914
939
  <div class="ap-intel-edit">
915
- <textarea class="ap-intel-textarea" data-ap-intel-textarea spellcheck="false" maxlength="${BIO_MAX}" placeholder="One sentence on how this director thinks · ${BIO_MIN}–${BIO_MAX} chars">${escape(bio)}</textarea>
940
+ <textarea class="ap-intel-textarea" data-ap-intel-textarea spellcheck="false" maxlength="${BIO_MAX}" placeholder="${escape(uiT("ap_intel_placeholder", { min: BIO_MIN, max: BIO_MAX }))}">${escape(bio)}</textarea>
916
941
  <div class="ap-intel-edit-foot">
917
- <span class="ap-intel-edit-hint" data-ap-intel-hint>${BIO_MIN}–${BIO_MAX} chars · esc to cancel</span>
942
+ <span class="ap-intel-edit-hint" data-ap-intel-hint>${escape(uiT("ap_intel_hint", { min: BIO_MIN, max: BIO_MAX }))}</span>
918
943
  <div class="ap-intel-edit-actions">
919
- <button type="button" class="ap-instr-cancel" data-ap-intel-cancel>cancel</button>
920
- <button type="button" class="ap-instr-save" data-ap-intel-save>save</button>
944
+ <button type="button" class="ap-instr-cancel" data-ap-intel-cancel>${escape(uiT("ap_cancel"))}</button>
945
+ <button type="button" class="ap-instr-save" data-ap-intel-save>${escape(uiT("ap_save"))}</button>
921
946
  </div>
922
947
  </div>
923
948
  </div>
@@ -965,9 +990,9 @@
965
990
  return `
966
991
  <div class="ap-instr" data-ap-instr data-slug="${escape(slug)}">
967
992
  <div class="ap-instr-view" data-ap-instr-view>
968
- ${rendered || `<div class="ap-empty">no instruction yet · click <strong>edit</strong> to write one in markdown</div>`}
993
+ ${rendered || `<div class="ap-empty">${escape(uiT("ap_instr_empty"))}</div>`}
969
994
  </div>
970
- <button type="button" class="ap-instr-toggle" data-ap-instr-toggle aria-expanded="false">show more</button>
995
+ <button type="button" class="ap-instr-toggle" data-ap-instr-toggle aria-expanded="false">${escape(uiT("ap_show_more"))}</button>
971
996
  </div>
972
997
  `;
973
998
  }
@@ -978,9 +1003,9 @@
978
1003
  block.classList.remove("overflowing");
979
1004
  block.innerHTML = `
980
1005
  <div class="ap-instr-view" data-ap-instr-view>
981
- ${renderMarkdown(instructionFor(slug, p)) || `<div class="ap-empty">no instruction yet · click <strong>edit</strong> to write one in markdown</div>`}
1006
+ ${renderMarkdown(instructionFor(slug, p)) || `<div class="ap-empty">${escape(uiT("ap_instr_empty"))}</div>`}
982
1007
  </div>
983
- <button type="button" class="ap-instr-toggle" data-ap-instr-toggle aria-expanded="false">show more</button>
1008
+ <button type="button" class="ap-instr-toggle" data-ap-instr-toggle aria-expanded="false">${escape(uiT("ap_show_more"))}</button>
984
1009
  `;
985
1010
  evaluateInstructionOverflow(slug);
986
1011
  }
@@ -1000,7 +1025,7 @@
1000
1025
  // 'expanded' state from a prior interaction shadowing the check.
1001
1026
  view.classList.remove("expanded");
1002
1027
  toggle.setAttribute("aria-expanded", "false");
1003
- toggle.textContent = "show more";
1028
+ toggle.textContent = uiT("ap_show_more");
1004
1029
  // scrollHeight is the full content; clientHeight is the rendered
1005
1030
  // (capped) height. A few-pixel epsilon avoids flagging content
1006
1031
  // that fits exactly at the cap as "overflowing".
@@ -1029,12 +1054,12 @@
1029
1054
  const md = instructionFor(slug, p);
1030
1055
  block.innerHTML = `
1031
1056
  <div class="ap-instr-edit">
1032
- <textarea class="ap-instr-textarea" data-ap-instr-textarea spellcheck="false" placeholder="Use markdown · ### headings · **bold** · *italic* · - lists · \`code\`">${escape(md)}</textarea>
1057
+ <textarea class="ap-instr-textarea" data-ap-instr-textarea spellcheck="false" placeholder="${escape(uiT("ap_instr_placeholder_editor"))}">${escape(md)}</textarea>
1033
1058
  <div class="ap-instr-edit-foot">
1034
- <span class="ap-instr-edit-hint">markdown supported · esc to cancel</span>
1059
+ <span class="ap-instr-edit-hint">${escape(uiT("ap_instr_edit_hint"))}</span>
1035
1060
  <div class="ap-instr-edit-actions">
1036
- <button type="button" class="ap-instr-cancel" data-ap-instr-cancel>cancel</button>
1037
- <button type="button" class="ap-instr-save" data-ap-instr-save>save</button>
1061
+ <button type="button" class="ap-instr-cancel" data-ap-instr-cancel>${escape(uiT("ap_cancel"))}</button>
1062
+ <button type="button" class="ap-instr-save" data-ap-instr-save>${escape(uiT("ap_save"))}</button>
1038
1063
  </div>
1039
1064
  </div>
1040
1065
  </div>
@@ -1057,7 +1082,7 @@
1057
1082
  function renderRulesInner(slug) {
1058
1083
  const rules = rulesForAgent(slug);
1059
1084
  const list = rules.length === 0
1060
- ? `<li class="ap-rule-empty">no rules yet · use the <strong>+ add rule</strong> button above</li>`
1085
+ ? `<li class="ap-rule-empty">${escape(uiT("ap_rules_empty_list"))}</li>`
1061
1086
  : rules.map((body, i) => `
1062
1087
  <li class="ap-rule" data-rule-idx="${i}">
1063
1088
  <span class="ap-rule-num">${i + 1}</span>
@@ -1477,7 +1502,7 @@
1477
1502
  .join(" ");
1478
1503
  const titleText = keyOk
1479
1504
  ? (enabled ? "Disable Web Search for this director" : "Enable Web Search for this director")
1480
- : "Web Search needs a Brave Search API key — click to configure";
1505
+ : uiT("ag_ws_title_needs");
1481
1506
  // When the global key is missing, omit the text label entirely
1482
1507
  // so the row stays compact. The dotted toggle track + hover
1483
1508
  // tooltip communicate the state on its own; the just-in-time
@@ -1542,7 +1567,7 @@
1542
1567
  const r = await fetch("/api/agents/" + encodeURIComponent(slug) + "/skills");
1543
1568
  if (!r.ok) {
1544
1569
  const j = await r.json().catch(() => ({}));
1545
- list.innerHTML = `<div class="ap-empty">couldn't load skills · ${escape(j.error || ("HTTP " + r.status))}</div>`;
1570
+ list.innerHTML = `<div class="ap-empty">${escape(uiT("ap_skills_load_fail", { detail: String(j.error || ("HTTP " + r.status)) }))}</div>`;
1546
1571
  return;
1547
1572
  }
1548
1573
  const { skills } = await r.json();
@@ -1553,7 +1578,7 @@
1553
1578
  // Header count tag (e.g. "3 / 5 installed").
1554
1579
  const card = block.closest(".ap-block");
1555
1580
  const countTag = card?.querySelector("[data-ap-skills-count]");
1556
- if (countTag) countTag.textContent = `${userCount} / ${cap} installed`;
1581
+ if (countTag) countTag.textContent = uiT("ap_skills_installed", { current: userCount, cap });
1557
1582
  // Radar reflects user skills only — the system skill has no
1558
1583
  // turn-time ability deltas (it runs at brief time).
1559
1584
  if (radarWrap) radarWrap.innerHTML = renderRadar(slug, userSkills);
@@ -1569,16 +1594,16 @@
1569
1594
  drop.classList.add("disabled");
1570
1595
  drop.setAttribute("aria-disabled", "true");
1571
1596
  const txt = drop.querySelector(".ap-skills-drop-text");
1572
- if (txt) txt.innerHTML = `cap reached (${userCount}/${cap}) · uninstall to make room`;
1597
+ if (txt) txt.textContent = uiT("ap_skills_cap_reached", { current: userCount, cap });
1573
1598
  } else {
1574
1599
  drop.classList.remove("disabled");
1575
1600
  drop.removeAttribute("aria-disabled");
1576
1601
  const txt = drop.querySelector(".ap-skills-drop-text");
1577
- if (txt) txt.innerHTML = `install skill · drop a <code>.md</code> file or click`;
1602
+ if (txt) txt.innerHTML = uiT("ap_skills_drop_hint");
1578
1603
  }
1579
1604
  }
1580
1605
  } catch (e) {
1581
- list.innerHTML = `<div class="ap-empty">couldn't load skills · ${escape(e && e.message ? e.message : String(e))}</div>`;
1606
+ list.innerHTML = `<div class="ap-empty">${escape(uiT("ap_skills_load_fail", { detail: String(e && e.message ? e.message : e) }))}</div>`;
1582
1607
  }
1583
1608
  }
1584
1609
 
@@ -1645,7 +1670,7 @@
1645
1670
  <div class="ap-skill-info-actions">
1646
1671
  <button type="button" class="ap-skill-info-configure" data-ap-ws-configure data-provider="${escape(wsProvider)}">
1647
1672
  <span class="ap-skill-info-configure-mark">↗</span>
1648
- <span>configure brave search api key</span>
1673
+ <span>${escape(uiT("ag_ws_configure_key"))}</span>
1649
1674
  </button>
1650
1675
  </div>
1651
1676
  ` : ""}
@@ -1919,7 +1944,8 @@
1919
1944
  { v: "grok-4-3", name: "Grok 4.3", provider: "xAI", deck: "1M ctx" },
1920
1945
  { v: "grok-4-20", name: "Grok 4.20", provider: "xAI", deck: "2M ctx · big context" },
1921
1946
  // DeepSeek
1922
- { v: "deepseek-v4-pro", name: "DeepSeek V4 Pro", provider: "DeepSeek", deck: "reasoning · open weights" }
1947
+ { v: "deepseek-v4-pro", name: "DeepSeek V4 Pro", provider: "DeepSeek", deck: "reasoning · open weights" },
1948
+ { v: "deepseek-v4-flash", name: "DeepSeek Lite", provider: "DeepSeek", deck: "V4 Flash · fast · 1M ctx" }
1923
1949
  ];
1924
1950
  function modelKey(slug) { return "boardroom.agent.model." + slug; }
1925
1951
 
@@ -2176,9 +2202,9 @@
2176
2202
  // `effectiveDefaultModel()` does the actual fallback.
2177
2203
  const warning = reachable
2178
2204
  ? ""
2179
- : `<div class="ap-model-stale" title="This model is not reachable with your current API keys. Runtime calls fall back to the global default.">
2205
+ : `<div class="ap-model-stale" title="${escape(uiT("ap_model_stale_title"))}">
2180
2206
  <span class="ap-model-stale-mark">⚠</span>
2181
- <span class="ap-model-stale-text">unreachable · falls back at runtime</span>
2207
+ <span class="ap-model-stale-text">${escape(uiT("ap_model_stale_text"))}</span>
2182
2208
  </div>`;
2183
2209
  // Trigger meta line · show the route when present, fall back to
2184
2210
  // provider otherwise. We DROP the leading provider when the route
@@ -2204,6 +2230,7 @@
2204
2230
 
2205
2231
  function openModelPicker(triggerEl) {
2206
2232
  closeModelPicker();
2233
+ closeEmotionPicker();
2207
2234
  const row = triggerEl.closest("[data-ap-model-row]");
2208
2235
  const slug = row?.getAttribute("data-slug");
2209
2236
  if (!slug) return;
@@ -2279,6 +2306,348 @@
2279
2306
  if (prov) prov.textContent = formatTriggerMeta(m);
2280
2307
  }
2281
2308
 
2309
+ function voiceEmotionOptionLabel(emotionSlug) {
2310
+ const s = emotionSlug === undefined || emotionSlug === null ? "" : String(emotionSlug);
2311
+ const key = !s ? "ap_voice_emotion_auto" : `ap_voice_emotion_${s}`;
2312
+ const txt = uiT(key);
2313
+ return txt === key ? s || "auto" : txt;
2314
+ }
2315
+
2316
+ /** API emotion slugs mirrored in PATCH body `voice.emotion`. */
2317
+ const VOICE_EMOTION_VALUES = ["", "happy", "sad", "angry", "fearful", "disgusted", "surprised", "calm", "fluent"];
2318
+
2319
+ let voiceOptionsCache = null;
2320
+ async function ensureVoiceOptions() {
2321
+ if (voiceOptionsCache) return voiceOptionsCache;
2322
+ try {
2323
+ const r = await fetch("/api/voices");
2324
+ const j = r.ok ? await r.json() : {};
2325
+ voiceOptionsCache = Array.isArray(j.voices) ? j.voices : [];
2326
+ } catch {
2327
+ voiceOptionsCache = [];
2328
+ }
2329
+ return voiceOptionsCache;
2330
+ }
2331
+ function voiceForAgent(slug) {
2332
+ const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
2333
+ return live && live.voice ? live.voice : null;
2334
+ }
2335
+ /** Format a voice-tune slider value for display. Speed gets an
2336
+ * `×` suffix; centered ranges (pitch / modify-*) prefix non-zero
2337
+ * values with `+` so the sign is unambiguous. Tabular numerals
2338
+ * in CSS keep the value's box width stable as the user drags. */
2339
+ function formatVoiceVal(param, val) {
2340
+ if (param === "speed") return `${Number(val).toFixed(1)}×`;
2341
+ const n = Number(val);
2342
+ if (n > 0) return `+${n}`;
2343
+ return String(n);
2344
+ }
2345
+
2346
+ /** Compute the lime-fill segment for a given range as two
2347
+ * percentages (`lo` and `hi`) that the CSS gradient consumes.
2348
+ * Non-centered ranges fill from 0 to value%; centered ranges
2349
+ * (min < 0 < max) fill the band between zero and value, so a
2350
+ * positive value lights up the right half and a negative value
2351
+ * the left half. */
2352
+ function rangeFillPositions(min, max, value) {
2353
+ const span = max - min;
2354
+ if (span <= 0) return { lo: "0%", hi: "0%" };
2355
+ const pct = ((value - min) / span) * 100;
2356
+ if (min < 0 && max > 0) {
2357
+ const zero = ((0 - min) / span) * 100;
2358
+ return {
2359
+ lo: `${Math.min(pct, zero)}%`,
2360
+ hi: `${Math.max(pct, zero)}%`,
2361
+ };
2362
+ }
2363
+ return { lo: "0%", hi: `${pct}%` };
2364
+ }
2365
+
2366
+ /** One slider row inside Advanced tuning · header (label left,
2367
+ * value right) on top, hairline track + square thumb beneath.
2368
+ * `centered` ranges (pitch / modify-*) get a mid-point tick
2369
+ * AND a band-style lime fill (zero ↔ value); non-centered
2370
+ * ranges (speed) get a "left-of-thumb" lime fill. */
2371
+ function renderVoiceTuneRow(slug, param, label, value, min, max, step) {
2372
+ const centered = min < 0 && max > 0;
2373
+ const { lo, hi } = rangeFillPositions(min, max, value);
2374
+ return `
2375
+ <div class="ap-voice-tune${centered ? " ap-voice-tune-centered" : ""}">
2376
+ <div class="ap-voice-tune-head">
2377
+ <span class="ap-voice-tune-label">${escape(label)}</span>
2378
+ <span class="ap-voice-tune-value" data-ap-voice-val="${escape(param)}">${escape(formatVoiceVal(param, value))}</span>
2379
+ </div>
2380
+ <input type="range" class="ap-voice-tune-range" min="${min}" max="${max}" step="${step}" value="${value}" data-ap-voice-range="${escape(param)}" data-slug="${escape(slug)}" style="--fill-lo: ${lo}; --fill-hi: ${hi};">
2381
+ </div>`;
2382
+ }
2383
+
2384
+ function renderVoiceBlock(slug) {
2385
+ // No voice provider configured · render the gamified "locked"
2386
+ // card instead of the picker + sliders. Removing the chrome
2387
+ // makes the missing-key state read as a feature gate ("set
2388
+ // this up to unlock") rather than a broken control panel.
2389
+ const hasVoiceKey = !!(window.app && typeof window.app.hasAnyVoiceKey === "function" && window.app.hasAnyVoiceKey());
2390
+ if (!hasVoiceKey) {
2391
+ return `
2392
+ <div class="ap-voice-locked" data-ap-voice-row data-slug="${escape(slug)}">
2393
+ <div class="ap-voice-locked-glyph" aria-hidden="true">
2394
+ <i></i><i></i><i></i><i></i><i></i>
2395
+ </div>
2396
+ <div class="ap-voice-locked-title">${uiT("ap_voice_locked_title")}</div>
2397
+ <button type="button" class="ap-voice-locked-cta" data-ap-voice-unlock>
2398
+ <span>${escape(uiT("ap_voice_locked_cta"))}</span>
2399
+ <span class="ap-voice-locked-cta-arrow" aria-hidden="true">→</span>
2400
+ </button>
2401
+ </div>
2402
+ `;
2403
+ }
2404
+ // Fire-and-forget prefetch · warms voiceOptionsCache so the picker
2405
+ // pops instantly when the user clicks. Without it the first click
2406
+ // pays the /api/voices round-trip (hundreds of voices on MiniMax)
2407
+ // and the dropdown lags visibly. Idempotent · cache-hit no-ops.
2408
+ ensureVoiceOptions();
2409
+ const v = voiceForAgent(slug);
2410
+ const label = v ? `${v.provider} · ${v.voiceId}` : uiT("ap_voice_browser_default");
2411
+ const deck = v ? v.model : uiT("ap_voice_engine_browser");
2412
+ const speed = v?.speed ?? 1;
2413
+ const pitch = v?.pitch ?? 0;
2414
+ const emotion = v?.emotion || "";
2415
+ const modPitch = v?.modifyPitch ?? 0;
2416
+ const modIntensity = v?.modifyIntensity ?? 0;
2417
+ const modTimbre = v?.modifyTimbre ?? 0;
2418
+
2419
+ const emotionLabel = voiceEmotionOptionLabel(emotion);
2420
+
2421
+ return `
2422
+ <div class="ap-voice-config" data-ap-voice-row data-slug="${escape(slug)}">
2423
+ <div class="ap-voice-picker-row">
2424
+ <button type="button" class="ap-model-trigger" data-ap-voice-trigger>
2425
+ <span class="ap-model-trigger-text">
2426
+ <span class="ap-model-trigger-name" data-ap-voice-name>${escape(label)}</span>
2427
+ <span class="ap-model-trigger-provider" data-ap-voice-provider>${escape(deck)}</span>
2428
+ </span>
2429
+ <span class="ap-model-trigger-caret">▾</span>
2430
+ </button>
2431
+ <button type="button" class="ap-voice-preview-btn" data-ap-voice-preview data-slug="${escape(slug)}" title="${escape(uiT("ap_voice_preview_btn_title"))}" aria-label="${escape(uiT("ap_voice_preview_btn_title"))}"><span class="ap-voice-preview-glyph">▶</span><span class="ap-voice-preview-dots" aria-hidden="true"><i></i><i></i><i></i></span></button>
2432
+ </div>
2433
+ <div class="ap-voice-emotion-row">
2434
+ <button type="button" class="ap-model-trigger ap-voice-emotion-trigger" data-ap-emotion-trigger data-slug="${escape(slug)}">
2435
+ <span class="ap-model-trigger-text">
2436
+ <span class="ap-model-trigger-name" data-ap-voice-emotion-label>${escape(emotionLabel)}</span>
2437
+ </span>
2438
+ <span class="ap-model-trigger-caret">▾</span>
2439
+ </button>
2440
+ <div class="ap-voice-emotion-hint">${escape(uiT("ap_voice_emotion_hint"))}</div>
2441
+ </div>
2442
+ <details class="ap-voice-advanced">
2443
+ <summary>${escape(uiT("ap_voice_advanced"))}</summary>
2444
+ <div class="ap-voice-tune-grid">
2445
+ ${renderVoiceTuneRow(slug, "speed", uiT("ap_voice_speed"), speed, 0.5, 2, 0.1)}
2446
+ ${renderVoiceTuneRow(slug, "pitch", uiT("ap_voice_pitch"), pitch, -12, 12, 1)}
2447
+ ${renderVoiceTuneRow(slug, "modifyPitch", uiT("ap_voice_modify_pitch"), modPitch, -100, 100, 5)}
2448
+ ${renderVoiceTuneRow(slug, "modifyIntensity", uiT("ap_voice_modify_intensity"), modIntensity, -100, 100, 5)}
2449
+ ${renderVoiceTuneRow(slug, "modifyTimbre", uiT("ap_voice_modify_timbre"), modTimbre, -100, 100, 5)}
2450
+ </div>
2451
+ </details>
2452
+ </div>
2453
+ `;
2454
+ }
2455
+ /** Position a `.ap-model-picker` popover under (or above, if there
2456
+ * isn't enough room below) a trigger element, clamping the height
2457
+ * to fit the available viewport. The picker uses `position: fixed`
2458
+ * so we work in viewport coords throughout. Returns nothing —
2459
+ * mutates `pop.style` in place.
2460
+ *
2461
+ * Preference: open BELOW the trigger. Flip ABOVE only when the
2462
+ * below-space is < 180px AND the above-space is bigger. Either
2463
+ * way, set `max-height` to whatever fits, so a long voice list
2464
+ * scrolls inside the picker rather than disappearing off-screen. */
2465
+ function placePickerNearTrigger(pop, triggerEl, popW) {
2466
+ const margin = 8;
2467
+ const minBelow = 180; // below this many px, prefer flipping above
2468
+ const rect = triggerEl.getBoundingClientRect();
2469
+ const left = Math.round(Math.min(rect.left, window.innerWidth - popW - margin));
2470
+ pop.style.left = `${left}px`;
2471
+ pop.style.width = `${popW}px`;
2472
+
2473
+ const spaceBelow = Math.max(0, window.innerHeight - rect.bottom - margin);
2474
+ const spaceAbove = Math.max(0, rect.top - margin);
2475
+
2476
+ const flipAbove = spaceBelow < minBelow && spaceAbove > spaceBelow;
2477
+ if (flipAbove) {
2478
+ // Anchor the bottom edge of the picker just above the trigger.
2479
+ // The picker grows upward · we use `bottom` rather than `top +
2480
+ // translateY` so max-height clipping stays predictable.
2481
+ pop.style.top = "auto";
2482
+ pop.style.bottom = `${Math.round(window.innerHeight - rect.top + 4)}px`;
2483
+ pop.style.maxHeight = `${Math.round(spaceAbove - 4)}px`;
2484
+ } else {
2485
+ pop.style.top = `${Math.round(rect.bottom + 4)}px`;
2486
+ pop.style.bottom = "auto";
2487
+ pop.style.maxHeight = `${Math.round(spaceBelow - 4)}px`;
2488
+ }
2489
+ }
2490
+
2491
+ async function openVoicePicker(triggerEl) {
2492
+ closeVoicePicker();
2493
+ closeEmotionPicker();
2494
+ const row = triggerEl.closest("[data-ap-voice-row]");
2495
+ const slug = row?.getAttribute("data-slug");
2496
+ if (!slug) return;
2497
+ const current = voiceForAgent(slug);
2498
+
2499
+ // Mount the popover shell IMMEDIATELY · the user gets visual
2500
+ // confirmation of their click in the same frame. If the cache
2501
+ // is cold, the skeleton holds the place while /api/voices
2502
+ // round-trips; once it lands we swap content in. Without this,
2503
+ // a cold cache produced "click → nothing → eventually picker"
2504
+ // which felt unresponsive on the first open of every session.
2505
+ const pop = document.createElement("div");
2506
+ pop.id = "ap-voice-picker";
2507
+ pop.className = "ap-model-picker";
2508
+ pop.dataset.slug = slug;
2509
+ // Loading row · animated dot trio + label. Visible feedback so
2510
+ // users don't read a static "loading" line as a frozen popover.
2511
+ pop.innerHTML = `
2512
+ <div class="ap-model-picker-loading">
2513
+ <span class="ap-loading-dots" aria-hidden="true"><i></i><i></i><i></i></span>
2514
+ <span>${escape(uiT("ap_voice_loading"))}</span>
2515
+ </div>`;
2516
+ document.body.appendChild(pop);
2517
+ placePickerNearTrigger(pop, triggerEl, 280);
2518
+
2519
+ const voices = await ensureVoiceOptions();
2520
+ // The user (or a sibling open) may have closed this picker
2521
+ // during the await · don't write into a detached node.
2522
+ if (!document.body.contains(pop)) return;
2523
+
2524
+ if (voices.length === 0) {
2525
+ pop.innerHTML = `<div class="ap-model-group">${escape(uiT("ap_voice_no_provider"))}</div>`;
2526
+ return;
2527
+ }
2528
+ const groups = [];
2529
+ let last = null;
2530
+ for (const v of voices) {
2531
+ const provider = String(v.provider || "browser");
2532
+ if (provider !== last) {
2533
+ groups.push(`<div class="ap-model-group">${escape(provider)}</div>`);
2534
+ last = provider;
2535
+ }
2536
+ const id = [provider, v.model || "", v.voiceId || ""].join("|");
2537
+ const active = current && current.provider === provider && current.model === v.model && current.voiceId === v.voiceId;
2538
+ groups.push(`
2539
+ <button type="button" class="ap-model-opt${active ? " active" : ""}" data-ap-voice-pick="${escape(id)}">
2540
+ <span class="ap-model-opt-label">${escape(v.label || v.voiceId || uiT("ap_voice_fallback_voice"))}</span>
2541
+ <span class="ap-model-opt-hint">${escape((v.model || "") + (v.language ? " · " + v.language : ""))}</span>
2542
+ </button>
2543
+ `);
2544
+ }
2545
+ pop.innerHTML = groups.join("");
2546
+ }
2547
+ function closeVoicePicker() {
2548
+ const el = document.getElementById("ap-voice-picker");
2549
+ if (el) el.remove();
2550
+ }
2551
+ function closeEmotionPicker() {
2552
+ const el = document.getElementById("ap-emotion-picker");
2553
+ if (el) el.remove();
2554
+ }
2555
+ /** Custom popover for voice emotion · matches `.ap-model-picker` chrome (no native `<select>` menu). */
2556
+ function openEmotionPicker(triggerEl) {
2557
+ closeEmotionPicker();
2558
+ closeVoicePicker();
2559
+ closeModelPicker();
2560
+ const row = triggerEl.closest("[data-ap-voice-row]");
2561
+ const slug = row?.getAttribute("data-slug");
2562
+ if (!slug) return;
2563
+ const curVoice = voiceForAgent(slug);
2564
+ const raw = curVoice && curVoice.emotion != null && curVoice.emotion !== ""
2565
+ ? String(curVoice.emotion)
2566
+ : "";
2567
+ const parts = [`<div class="ap-model-group">${escape(uiT("ap_voice_emotion_label"))}</div>`];
2568
+ for (const e of VOICE_EMOTION_VALUES) {
2569
+ const active = raw === e;
2570
+ parts.push(`
2571
+ <button type="button" class="ap-model-opt${active ? " active" : ""}" data-ap-emotion-pick="${escape(e)}">
2572
+ <span class="ap-model-opt-label">${escape(voiceEmotionOptionLabel(e))}</span>
2573
+ </button>`);
2574
+ }
2575
+ const pop = document.createElement("div");
2576
+ pop.id = "ap-emotion-picker";
2577
+ pop.className = "ap-model-picker";
2578
+ pop.dataset.slug = slug;
2579
+ pop.innerHTML = parts.join("");
2580
+ document.body.appendChild(pop);
2581
+ // Width tracks the trigger so the popover hugs whichever
2582
+ // emotion-picker variant is in use (compact vs full row).
2583
+ const triggerWidth = triggerEl.getBoundingClientRect().width;
2584
+ placePickerNearTrigger(pop, triggerEl, Math.round(Math.min(280, Math.max(220, triggerWidth))));
2585
+ }
2586
+ function setVoiceFor(slug, voice) {
2587
+ const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
2588
+ if (!live) return;
2589
+ fetch("/api/agents/" + encodeURIComponent(slug), {
2590
+ method: "PATCH",
2591
+ headers: { "content-type": "application/json" },
2592
+ body: JSON.stringify({ voice }),
2593
+ })
2594
+ .then((r) => r.ok ? r.json() : r.json().then((j) => Promise.reject(new Error(j.error || `HTTP ${r.status}`))))
2595
+ .then((updated) => {
2596
+ const nv = updated.voice != null ? updated.voice : voice;
2597
+ live.voice = nv;
2598
+ const row = document.querySelector(`[data-ap-voice-row][data-slug="${slug}"]`);
2599
+ const name = row?.querySelector("[data-ap-voice-name]");
2600
+ const prov = row?.querySelector("[data-ap-voice-provider]");
2601
+ if (name && nv && nv.provider && nv.voiceId) name.textContent = `${nv.provider} · ${nv.voiceId}`;
2602
+ if (prov && nv && nv.model != null) prov.textContent = nv.model;
2603
+ const emLb = row?.querySelector("[data-ap-voice-emotion-label]");
2604
+ if (emLb && nv) emLb.textContent = voiceEmotionOptionLabel(nv.emotion ?? "");
2605
+ })
2606
+ .catch((e) => alert(uiT("ap_voice_save_err", { msg: e && e.message ? e.message : String(e) })));
2607
+ }
2608
+
2609
+ async function previewVoice(slug) {
2610
+ const v = voiceForAgent(slug);
2611
+ if (!v || !v.voiceId) {
2612
+ alert(uiT("ap_voice_preview_need_voice"));
2613
+ return;
2614
+ }
2615
+ const btn = document.querySelector(`[data-ap-voice-preview][data-slug="${slug}"]`);
2616
+ // Loading state · class-toggle so the inner glyph hides and the
2617
+ // animated 3-dot indicator takes its place. Avoids the previous
2618
+ // `⏳` emoji which renders inconsistently across platforms and
2619
+ // doesn't match the system's mono register.
2620
+ if (btn) { btn.disabled = true; btn.classList.add("is-loading"); }
2621
+ try {
2622
+ const r = await fetch("/api/voices/preview", {
2623
+ method: "POST",
2624
+ headers: { "content-type": "application/json" },
2625
+ body: JSON.stringify({
2626
+ provider: v.provider,
2627
+ model: v.model,
2628
+ voiceId: v.voiceId,
2629
+ speed: v.speed,
2630
+ pitch: v.pitch,
2631
+ emotion: v.emotion,
2632
+ modifyPitch: v.modifyPitch,
2633
+ modifyIntensity: v.modifyIntensity,
2634
+ modifyTimbre: v.modifyTimbre,
2635
+ }),
2636
+ });
2637
+ const data = await r.json();
2638
+ if (!r.ok || !data.audioBase64) {
2639
+ alert(uiT("ap_voice_preview_failed", { msg: data.error || "no audio" }));
2640
+ return;
2641
+ }
2642
+ const audio = new Audio(`data:${data.mimeType};base64,${data.audioBase64}`);
2643
+ audio.play().catch((e) => alert(uiT("ap_voice_preview_playback_blocked", { msg: e.message || String(e) })));
2644
+ } catch (e) {
2645
+ alert(uiT("ap_voice_preview_err", { msg: e.message || String(e) }));
2646
+ } finally {
2647
+ if (btn) { btn.disabled = false; btn.classList.remove("is-loading"); }
2648
+ }
2649
+ }
2650
+
2282
2651
  function pageHTML(p, slug) {
2283
2652
  const skills = skillsForAgent(slug);
2284
2653
  const liveModel = liveModelFor(slug) || (p.metrics && p.metrics.model) || { name: "—", deck: "" };
@@ -2302,7 +2671,7 @@
2302
2671
  }).join("");
2303
2672
 
2304
2673
  const bioBody = (Array.isArray(p.bio) ? p.bio.join("\n\n") : (p.bio || "")).trim();
2305
- const statusLabel = p.status === "intern" ? "INTERN · TRIAL" : (p.status || "ACTIVE").toUpperCase();
2674
+ const statusLabel = profileStatusLabel(p);
2306
2675
 
2307
2676
  return `
2308
2677
  <section class="ap-profile-card ap-profile-card-full" data-ap-card-slug="${escape(slug)}">
@@ -2316,12 +2685,12 @@
2316
2685
  <div class="ap-id-text">
2317
2686
  <h1 class="ap-id-name">${escape(p.name)}</h1>
2318
2687
  <div class="ap-id-meta">
2319
- <span class="ap-id-role">${escape(p.role || "DIRECTOR")}</span>
2688
+ <span class="ap-id-role">${escape(profileRoleLabel(p))}</span>
2320
2689
  ${p.handle ? `<span class="ap-id-dot">·</span><span class="ap-id-handle">${escape(p.handle)}</span>` : ""}
2321
2690
  <span class="ap-status-pill">${escape(statusLabel)}</span>
2322
2691
  </div>
2323
2692
  </div>
2324
- <button type="button" class="ap-id-menu" data-ap-id-menu data-slug="${escape(slug)}" aria-label="more">⋯</button>
2693
+ <button type="button" class="ap-id-menu" data-ap-id-menu data-slug="${escape(slug)}" aria-label="${escape(uiT("ap_aria_id_menu"))}">⋯</button>
2325
2694
  </div>
2326
2695
  </section>
2327
2696
 
@@ -2332,27 +2701,27 @@
2332
2701
 
2333
2702
  <section class="ap-block">
2334
2703
  <header class="ap-block-h">
2335
- <span class="ap-block-h-title">Intel</span>
2336
- <button type="button" class="ap-block-h-action" data-ap-intel-edit>edit</button>
2704
+ <span class="ap-block-h-title">${escape(uiT("ap_intel"))}</span>
2705
+ <button type="button" class="ap-block-h-action" data-ap-intel-edit>${escape(uiT("ap_edit"))}</button>
2337
2706
  </header>
2338
2707
  <div class="ap-intel" data-ap-intel data-slug="${escape(slug)}">
2339
- <div class="ap-intel-view" data-ap-intel-view>${escape(bioBody) || `<span class="ap-empty">no description yet · click <strong>edit</strong> to add one</span>`}</div>
2708
+ <div class="ap-intel-view" data-ap-intel-view>${escape(bioBody) || `<span class="ap-empty">${escape(uiT("ap_intel_empty"))}</span>`}</div>
2340
2709
  </div>
2341
2710
  </section>
2342
2711
 
2343
2712
  <section class="ap-block">
2344
2713
  <header class="ap-block-h">
2345
- <span class="ap-block-h-title">Instruction</span>
2346
- <button type="button" class="ap-block-h-action" data-ap-instr-edit>edit</button>
2714
+ <span class="ap-block-h-title">${escape(uiT("ap_instruction"))}</span>
2715
+ <button type="button" class="ap-block-h-action" data-ap-instr-edit>${escape(uiT("ap_edit"))}</button>
2347
2716
  </header>
2348
2717
  ${renderInstructionBlock(p, slug)}
2349
2718
  </section>
2350
2719
 
2351
2720
  <section class="ap-block">
2352
2721
  <header class="ap-block-h">
2353
- <span class="ap-block-h-title">Rules</span>
2722
+ <span class="ap-block-h-title">${escape(uiT("ap_rules"))}</span>
2354
2723
  <button type="button" class="ap-block-h-action" data-ap-rule-add data-slug="${escape(slug)}" ${rulesForAgent(slug).length >= RULES_MAX ? "disabled" : ""}>
2355
- ${rulesForAgent(slug).length >= RULES_MAX ? `max ${RULES_MAX}` : "+ add rule"}
2724
+ ${rulesForAgent(slug).length >= RULES_MAX ? escape(uiT("ap_rules_max", { n: RULES_MAX })) : escape(uiT("ap_rules_add"))}
2356
2725
  </button>
2357
2726
  </header>
2358
2727
  ${renderRulesBlock(slug)}
@@ -2361,10 +2730,10 @@
2361
2730
  ${isChair ? `
2362
2731
  <section class="ap-block">
2363
2732
  <header class="ap-block-h">
2364
- <span class="ap-block-h-title">Memory</span>
2733
+ <span class="ap-block-h-title">${escape(uiT("ap_memory"))}</span>
2365
2734
  <div class="ap-block-h-actions">
2366
- <button type="button" class="ap-block-h-action ap-dream-trigger" data-ap-dream-trigger data-slug="${escape(slug)}" title="Consolidate · drop stale notes, keep stable patterns">☾ run consolidation</button>
2367
- <button type="button" class="ap-block-h-action" data-ap-memory-add-toggle data-slug="${escape(slug)}">+ add note</button>
2735
+ <button type="button" class="ap-block-h-action ap-dream-trigger" data-ap-dream-trigger data-slug="${escape(slug)}" title="${escape(uiT("ap_dream_consolidate_title"))}">${escape(uiT("ap_dream_consolidate_btn"))}</button>
2736
+ <button type="button" class="ap-block-h-action" data-ap-memory-add-toggle data-slug="${escape(slug)}">${escape(uiT("ap_memory_add"))}</button>
2368
2737
  </div>
2369
2738
  </header>
2370
2739
  ${renderMemoryBlock(slug)}
@@ -2377,23 +2746,23 @@
2377
2746
 
2378
2747
  <section class="ap-block">
2379
2748
  <header class="ap-block-h">
2380
- <span class="ap-block-h-title">Track Record</span>
2381
- <span class="ap-block-h-tag">model · usage</span>
2749
+ <span class="ap-block-h-title">${escape(uiT("ap_track_record"))}</span>
2750
+ <span class="ap-block-h-tag">${escape(uiT("ap_track_tag_model_usage"))}</span>
2382
2751
  </header>
2383
2752
  <div class="ap-block-body">
2384
2753
  ${renderModelBlock(slug, liveModel)}
2385
2754
  <div class="ap-stats-grid" data-ap-stats data-slug="${escape(slug)}">
2386
2755
  <div class="ap-stat">
2387
2756
  <div class="ap-stat-v" data-ap-stat-rooms>—</div>
2388
- <div class="ap-stat-l">rooms</div>
2757
+ <div class="ap-stat-l">${escape(uiT("ap_stat_rooms"))}</div>
2389
2758
  </div>
2390
2759
  <div class="ap-stat">
2391
2760
  <div class="ap-stat-v" data-ap-stat-rounds>—</div>
2392
- <div class="ap-stat-l">rounds</div>
2761
+ <div class="ap-stat-l">${escape(uiT("ap_stat_rounds"))}</div>
2393
2762
  </div>
2394
2763
  <div class="ap-stat">
2395
2764
  <div class="ap-stat-v" data-ap-stat-tokens>—</div>
2396
- <div class="ap-stat-l">tokens</div>
2765
+ <div class="ap-stat-l">${escape(uiT("ap_stat_tokens"))}</div>
2397
2766
  </div>
2398
2767
  </div>
2399
2768
  </div>
@@ -2401,22 +2770,19 @@
2401
2770
 
2402
2771
  <section class="ap-block">
2403
2772
  <header class="ap-block-h">
2404
- <span class="ap-block-h-title">Skills</span>
2405
- <span class="ap-block-h-tag" data-ap-skills-count>0 / ${isChair ? SKILL_CAP.chair : SKILL_CAP.director} installed</span>
2773
+ <span class="ap-block-h-title">${escape(uiT("ap_skills"))}</span>
2774
+ <span class="ap-block-h-tag" data-ap-skills-count>${escape(uiT("ap_skills_installed", { current: 0, cap: isChair ? SKILL_CAP.chair : SKILL_CAP.director }))}</span>
2406
2775
  </header>
2407
2776
  ${renderSkillsBlockV2(slug, isChair)}
2408
2777
  </section>
2409
2778
 
2410
2779
  <section class="ap-block">
2411
2780
  <header class="ap-block-h">
2412
- <span class="ap-block-h-title">Equipment</span>
2413
- <span class="ap-block-h-tag">coming soon</span>
2781
+ <span class="ap-block-h-title">${escape(uiT("ap_voice_section"))}</span>
2782
+ <span class="ap-block-h-tag">${escape(uiT("ap_voice_section_tag"))}</span>
2414
2783
  </header>
2415
- <div class="ap-coming-soon">
2416
- <div class="ap-coming-soon-mark">◆</div>
2417
- <div class="ap-coming-soon-title">Knowledge docs</div>
2418
- <p class="ap-coming-soon-body">Attach PDFs, links, and reference notes to ground this agent's reasoning. They'll be cited inline during rooms and available for the agent to recall by name.</p>
2419
- <div class="ap-coming-soon-tag">in development</div>
2784
+ <div class="ap-block-body">
2785
+ ${renderVoiceBlock(slug)}
2420
2786
  </div>
2421
2787
  </section>
2422
2788
 
@@ -3242,9 +3608,10 @@
3242
3608
  const provider = wsToggle.getAttribute("data-provider") || "brave";
3243
3609
  if (!keyConfigured) {
3244
3610
  const ok = confirm(
3245
- "Web Search needs a Brave Search API key.\n\n" +
3246
- "Brave Search · ≈ $5 per 1000 queries · privacy-respecting\n\n" +
3247
- "Open Preferences to paste your key now?",
3611
+ (window.I18n && typeof window.I18n.t === "function")
3612
+ ? uiT("ag_ws_need_key_confirm")
3613
+ :
3614
+ ("Web Search needs Brave Search or Tavily API credentials.\n\nBrave Search · ≈ $5 per 1000 queries. Tavily · per Tavily API credits.\n\nOpen Preferences now?"),
3248
3615
  );
3249
3616
  if (ok && typeof window.openUserSettings === "function") {
3250
3617
  window.openUserSettings({ section: "keys", focusProvider: provider });
@@ -3394,7 +3761,7 @@
3394
3761
  if (!view) return;
3395
3762
  const expanded = view.classList.toggle("expanded");
3396
3763
  instrToggle.setAttribute("aria-expanded", String(expanded));
3397
- instrToggle.textContent = expanded ? "show less" : "show more";
3764
+ instrToggle.textContent = expanded ? uiT("ap_show_less") : uiT("ap_show_more");
3398
3765
  return;
3399
3766
  }
3400
3767
 
@@ -3550,6 +3917,72 @@
3550
3917
  closeModelPicker();
3551
3918
  return;
3552
3919
  }
3920
+ const voiceTrigger = e.target.closest("[data-ap-voice-trigger]");
3921
+ if (voiceTrigger) {
3922
+ e.preventDefault();
3923
+ if (document.getElementById("ap-voice-picker")) closeVoicePicker();
3924
+ else openVoicePicker(voiceTrigger);
3925
+ return;
3926
+ }
3927
+ // Locked card CTA · deep-links to user-settings keys panel
3928
+ // and scrolls the MiniMax row into view (the same deep-link
3929
+ // the composer's voice toggle uses when no key is set).
3930
+ // After the user closes the modal, refreshAgentProfileSkills
3931
+ // already fires (window-level wrapper); we also refresh the
3932
+ // visible voice block so a configured key flips the locked
3933
+ // card into the picker without a page reload.
3934
+ const unlockBtn = e.target.closest("[data-ap-voice-unlock]");
3935
+ if (unlockBtn) {
3936
+ e.preventDefault();
3937
+ if (typeof window.openUserSettings === "function") {
3938
+ window.openUserSettings({ section: "keys", focusProvider: "minimax" });
3939
+ }
3940
+ return;
3941
+ }
3942
+ const emoTrig = e.target.closest("[data-ap-emotion-trigger]");
3943
+ if (emoTrig) {
3944
+ e.preventDefault();
3945
+ if (document.getElementById("ap-emotion-picker")) closeEmotionPicker();
3946
+ else openEmotionPicker(emoTrig);
3947
+ return;
3948
+ }
3949
+ const emoOpt = e.target.closest("[data-ap-emotion-pick]");
3950
+ if (emoOpt) {
3951
+ e.preventDefault();
3952
+ const pop = document.getElementById("ap-emotion-picker");
3953
+ const slug = pop?.dataset.slug;
3954
+ if (!slug) return;
3955
+ let rawPick = emoOpt.getAttribute("data-ap-emotion-pick");
3956
+ if (rawPick === null) rawPick = "";
3957
+ const existing = voiceForAgent(slug) || { provider: "minimax", model: "speech-2.8-hd", voiceId: "male-qn-qingse" };
3958
+ setVoiceFor(slug, {
3959
+ ...existing,
3960
+ emotion: rawPick === "" ? undefined : rawPick,
3961
+ });
3962
+ closeEmotionPicker();
3963
+ return;
3964
+ }
3965
+ const voiceOpt = e.target.closest("[data-ap-voice-pick]");
3966
+ if (voiceOpt) {
3967
+ e.preventDefault();
3968
+ const raw = voiceOpt.getAttribute("data-ap-voice-pick") || "";
3969
+ const pop = document.getElementById("ap-voice-picker");
3970
+ const slug = pop?.dataset.slug;
3971
+ const [provider, model, voiceId] = raw.split("|");
3972
+ if (!slug || !provider || !model || !voiceId) return;
3973
+ const existing = voiceForAgent(slug) || {};
3974
+ setVoiceFor(slug, { ...existing, provider, model, voiceId });
3975
+ closeVoicePicker();
3976
+ return;
3977
+ }
3978
+ // Preview button
3979
+ const previewBtn = e.target.closest("[data-ap-voice-preview]");
3980
+ if (previewBtn) {
3981
+ e.preventDefault();
3982
+ const slug = previewBtn.getAttribute("data-slug");
3983
+ if (slug) previewVoice(slug);
3984
+ return;
3985
+ }
3553
3986
  });
3554
3987
  document.addEventListener("click", (e) => {
3555
3988
  const pop = document.getElementById("ap-model-picker");
@@ -3558,6 +3991,51 @@
3558
3991
  if (e.target.closest("[data-ap-model-trigger]")) return;
3559
3992
  closeModelPicker();
3560
3993
  }, true);
3994
+ document.addEventListener("click", (e) => {
3995
+ const pop = document.getElementById("ap-voice-picker");
3996
+ if (!pop) return;
3997
+ if (e.target.closest("#ap-voice-picker")) return;
3998
+ if (e.target.closest("[data-ap-voice-trigger]")) return;
3999
+ closeVoicePicker();
4000
+ }, true);
4001
+ document.addEventListener("click", (e) => {
4002
+ const pop = document.getElementById("ap-emotion-picker");
4003
+ if (!pop) return;
4004
+ if (e.target.closest("#ap-emotion-picker")) return;
4005
+ if (e.target.closest("[data-ap-emotion-trigger]")) return;
4006
+ closeEmotionPicker();
4007
+ }, true);
4008
+
4009
+ // Voice config sliders + emotion
4010
+ document.addEventListener("input", (e) => {
4011
+ const range = e.target.closest("[data-ap-voice-range]");
4012
+ if (range) {
4013
+ const param = range.getAttribute("data-ap-voice-range");
4014
+ const val = parseFloat(range.value);
4015
+ // Update display value · same formatter that initial render
4016
+ // uses, so dragging matches the static "1.0×" / "+5" reading.
4017
+ const container = range.closest(".ap-voice-config");
4018
+ const display = container?.querySelector(`[data-ap-voice-val="${param}"]`);
4019
+ if (display) display.textContent = formatVoiceVal(param, val);
4020
+ // Repaint the lime-fill segment as the thumb moves.
4021
+ const { lo, hi } = rangeFillPositions(parseFloat(range.min), parseFloat(range.max), val);
4022
+ range.style.setProperty("--fill-lo", lo);
4023
+ range.style.setProperty("--fill-hi", hi);
4024
+ return;
4025
+ }
4026
+ });
4027
+ document.addEventListener("change", (e) => {
4028
+ const range = e.target.closest("[data-ap-voice-range]");
4029
+ if (range) {
4030
+ const param = range.getAttribute("data-ap-voice-range");
4031
+ const slug = range.getAttribute("data-slug");
4032
+ if (!param || !slug) return;
4033
+ const val = parseFloat(range.value);
4034
+ const existing = voiceForAgent(slug) || { provider: "minimax", model: "speech-2.8-hd", voiceId: "male-qn-qingse" };
4035
+ setVoiceFor(slug, { ...existing, [param]: val });
4036
+ return;
4037
+ }
4038
+ });
3561
4039
 
3562
4040
  // Back-to-room paths now that the explicit Back button is gone:
3563
4041
  // • clicking any sidebar room row → switch back to room view
@@ -3636,12 +4114,26 @@
3636
4114
  window.closeAgentProfile = showRoom;
3637
4115
  // Re-fetch the open profile's skills (incl. per-skill keyConfigured
3638
4116
  // flags) so the web-search toggle row's cached `data-key-configured`
3639
- // refreshes after the user adds a key in Preferences. No-op when no
3640
- // profile is currently open.
4117
+ // refreshes after the user adds a key in Preferences. Also re-renders
4118
+ // the Voice Setup block so the gamified locked card flips into the
4119
+ // picker the moment the user adds a MiniMax / ElevenLabs key without
4120
+ // forcing a page reload. No-op when no profile is currently open.
3641
4121
  window.refreshAgentProfileSkills = function () {
3642
- if (currentlyOpenSlug) loadSkillsForV2(currentlyOpenSlug);
4122
+ if (!currentlyOpenSlug) return;
4123
+ loadSkillsForV2(currentlyOpenSlug);
4124
+ const voiceRow = document.querySelector(`[data-ap-voice-row][data-slug="${currentlyOpenSlug}"]`);
4125
+ if (voiceRow) {
4126
+ const wrap = document.createElement("div");
4127
+ wrap.innerHTML = renderVoiceBlock(currentlyOpenSlug).trim();
4128
+ const fresh = wrap.firstElementChild;
4129
+ if (fresh) voiceRow.replaceWith(fresh);
4130
+ }
3643
4131
  };
3644
4132
 
4133
+ document.addEventListener("boardroom:locale", () => {
4134
+ if (currentlyOpenSlug && typeof open === "function") open(currentlyOpenSlug);
4135
+ });
4136
+
3645
4137
  if (document.readyState === "loading") {
3646
4138
  document.addEventListener("DOMContentLoaded", init);
3647
4139
  } else {