privateboard 0.1.37 → 0.1.40

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.
Files changed (76) hide show
  1. package/dist/boot.js +1415 -91
  2. package/dist/boot.js.map +1 -1
  3. package/dist/cli.js +1415 -91
  4. package/dist/cli.js.map +1 -1
  5. package/dist/server.js +1271 -81
  6. package/dist/server.js.map +1 -1
  7. package/dist/version.d.ts +1 -1
  8. package/dist/version.js +1 -1
  9. package/dist/version.js.map +1 -1
  10. package/package.json +1 -1
  11. package/public/__avatar3d_test.html +156 -0
  12. package/public/adjourn-overlay.css +2 -2
  13. package/public/agent-overlay.css +27 -15
  14. package/public/agent-overlay.js +3 -1
  15. package/public/agent-profile.css +331 -41
  16. package/public/agent-profile.js +499 -75
  17. package/public/app-updater.css +1 -1
  18. package/public/app.js +2090 -547
  19. package/public/avatar-3d-snap.js +205 -0
  20. package/public/avatar-3d.js +792 -0
  21. package/public/avatar-customizer.html +274 -0
  22. package/public/avatar3d-editor.css +240 -0
  23. package/public/avatar3d-editor.js +481 -0
  24. package/public/avatars/3d/chair.png +0 -0
  25. package/public/avatars/3d/first-principles.png +0 -0
  26. package/public/avatars/3d/historian.png +0 -0
  27. package/public/avatars/3d/long-horizon.png +0 -0
  28. package/public/avatars/3d/phenomenologist.png +0 -0
  29. package/public/avatars/3d/socrates.png +0 -0
  30. package/public/avatars/3d/user-empathy.png +0 -0
  31. package/public/avatars/3d/value-investor.png +0 -0
  32. package/public/core-avatars.js +86 -0
  33. package/public/home-3d-loader.js +15 -4
  34. package/public/home-3d-mock.js +18 -7
  35. package/public/home.html +80 -18
  36. package/public/i18n.js +279 -4
  37. package/public/icons/avatar_1779855104027.glb +0 -0
  38. package/public/icons/logo.png +0 -0
  39. package/public/icons/new-style.glb +0 -0
  40. package/public/icons/new-style2.glb +0 -0
  41. package/public/icons/new-style3.glb +0 -0
  42. package/public/icons/new-style4.glb +0 -0
  43. package/public/icons/new-style5.glb +0 -0
  44. package/public/icons/office.glb +0 -0
  45. package/public/icons/stuff.glb +0 -0
  46. package/public/index.html +203 -182
  47. package/public/mention-picker.js +1 -1
  48. package/public/new-agent.css +7 -7
  49. package/public/new-agent.js +46 -20
  50. package/public/office-viewer.html +340 -0
  51. package/public/onboarding.css +5 -5
  52. package/public/quote-cta.css +5 -4
  53. package/public/quote-cta.js +50 -5
  54. package/public/room-settings.css +24 -9
  55. package/public/stuff-viewer.html +330 -0
  56. package/public/thread.css +1211 -0
  57. package/public/user-settings.css +16 -19
  58. package/public/user-settings.js +86 -78
  59. package/public/vendor/BufferGeometryUtils.js +1434 -0
  60. package/public/vendor/DRACOLoader.js +739 -0
  61. package/public/vendor/GLTFLoader.js +4860 -0
  62. package/public/vendor/RoomEnvironment.js +185 -0
  63. package/public/vendor/SkeletonUtils.js +496 -0
  64. package/public/vendor/draco/draco_decoder.js +34 -0
  65. package/public/vendor/draco/draco_decoder.wasm +0 -0
  66. package/public/vendor/draco/draco_encoder.js +33 -0
  67. package/public/vendor/draco/draco_wasm_wrapper.js +117 -0
  68. package/public/vendor/meshopt_decoder.module.js +196 -0
  69. package/public/voice-3d-banner.js +12 -0
  70. package/public/voice-3d.js +1407 -432
  71. package/public/voice-clone.css +875 -0
  72. package/public/voice-clone.js +1351 -0
  73. package/public/voice-replay.css +3 -3
  74. package/public/voice-replay.js +21 -0
  75. package/public/avatar-skill.js +0 -629
  76. package/public/icons/folded-sidebar.png +0 -0
@@ -760,39 +760,81 @@
760
760
  return tag.toUpperCase().slice(0, 8);
761
761
  }
762
762
 
763
- /* Per-agent rules (visual only · localStorage). */
763
+ /* Per-agent rules · PERSISTED SERVER-SIDE (agent.userRules) so the
764
+ orchestrator can inject them into the director's turn prompt. (They
765
+ used to be localStorage-only / "visual" — which is why a rule like
766
+ "不要谈及范冰冰" had zero effect: it never reached the model.)
767
+ A per-slug working copy backs the inputs for snappy editing; changes
768
+ debounce-flush to PATCH /api/agents/:id. */
764
769
  const RULES_MAX = 5;
765
- function rulesKey(slug) { return "boardroom.agent.rules." + slug; }
766
- function rulesForAgent(slug) {
767
- try {
768
- const raw = localStorage.getItem(rulesKey(slug));
769
- if (raw) {
770
- const arr = JSON.parse(raw);
771
- if (Array.isArray(arr)) return arr;
770
+ const _rules = Object.create(null); // slug -> string[] working copy
771
+ const _rulesTimer = Object.create(null); // slug -> debounce timer
772
+ function _legacyRulesKey(slug) { return "boardroom.agent.rules." + slug; }
773
+ function _liveAgentFor(slug) {
774
+ return (window.app && window.app.agentsById) ? window.app.agentsById[slug] : null;
775
+ }
776
+ // Seed the working copy once per slug: prefer the server value
777
+ // (agent.userRules); else migrate any legacy localStorage rules up to
778
+ // the server so a user who set rules in the old "visual-only" era
779
+ // doesn't lose them (and they start actually working).
780
+ function seedRules(slug) {
781
+ if (_rules[slug]) return _rules[slug];
782
+ const live = _liveAgentFor(slug);
783
+ let arr = (live && Array.isArray(live.userRules)) ? live.userRules.slice() : [];
784
+ if (arr.length === 0) {
785
+ try {
786
+ const raw = localStorage.getItem(_legacyRulesKey(slug));
787
+ if (raw) {
788
+ const a = JSON.parse(raw);
789
+ if (Array.isArray(a)) {
790
+ const legacy = a.map((x) => String(x).trim()).filter((x) => x.length > 0);
791
+ if (legacy.length > 0) { arr = legacy; _rules[slug] = arr; persistRules(slug); }
792
+ }
793
+ }
794
+ } catch (e) { /* */ }
795
+ }
796
+ _rules[slug] = arr;
797
+ return _rules[slug];
798
+ }
799
+ function rulesForAgent(slug) { return seedRules(slug); }
800
+ function _cleanRules(slug) {
801
+ return (_rules[slug] || []).map((x) => String(x).trim()).filter((x) => x.length > 0).slice(0, RULES_MAX);
802
+ }
803
+ function persistRules(slug) {
804
+ const arr = _cleanRules(slug);
805
+ const live = _liveAgentFor(slug);
806
+ if (live) live.userRules = arr.slice(); // optimistic
807
+ fetch("/api/agents/" + encodeURIComponent(slug), {
808
+ method: "PATCH",
809
+ headers: { "content-type": "application/json" },
810
+ body: JSON.stringify({ userRules: arr }),
811
+ }).then((r) => (r.ok ? r.json() : null)).then((updated) => {
812
+ if (updated && Array.isArray(updated.userRules) && live) {
813
+ live.userRules = updated.userRules.slice();
772
814
  }
773
- } catch (e) { /* */ }
774
- return [];
815
+ try { localStorage.removeItem(_legacyRulesKey(slug)); } catch (e) { /* */ }
816
+ }).catch(() => { /* offline · working copy keeps the edit */ });
775
817
  }
776
- function setRulesFor(slug, arr) {
777
- try { localStorage.setItem(rulesKey(slug), JSON.stringify(arr)); } catch (e) { /* */ }
818
+ function persistRulesSoon(slug) {
819
+ if (_rulesTimer[slug]) clearTimeout(_rulesTimer[slug]);
820
+ _rulesTimer[slug] = setTimeout(() => { _rulesTimer[slug] = null; persistRules(slug); }, 600);
778
821
  }
779
822
  function addRuleFor(slug) {
780
- const rules = rulesForAgent(slug);
823
+ const rules = seedRules(slug);
781
824
  if (rules.length >= RULES_MAX) return;
782
- rules.push("");
783
- setRulesFor(slug, rules);
825
+ rules.push(""); // empty rows aren't persisted until typed into
784
826
  }
785
827
  function setRuleAt(slug, idx, body) {
786
- const rules = rulesForAgent(slug);
828
+ const rules = seedRules(slug);
787
829
  if (idx < 0 || idx >= rules.length) return;
788
830
  rules[idx] = body;
789
- setRulesFor(slug, rules);
831
+ persistRulesSoon(slug);
790
832
  }
791
833
  function removeRuleFor(slug, idx) {
792
- const rules = rulesForAgent(slug);
834
+ const rules = seedRules(slug);
793
835
  if (idx < 0 || idx >= rules.length) return;
794
836
  rules.splice(idx, 1);
795
- setRulesFor(slug, rules);
837
+ persistRules(slug); // immediate on remove
796
838
  }
797
839
  function repaintProfileRules(slug) {
798
840
  const card = document.querySelector(`.ap-card[data-ap-card-slug="${slug}"]`);
@@ -1123,8 +1165,9 @@
1123
1165
  /** Render the RULES block · editable list of numbered constraints.
1124
1166
  * Mirrors the new-agent overlay UX: each row is a numbered input
1125
1167
  * with a trailing remove button; an "add rule" button below the
1126
- * list (hidden when the cap of 5 is reached). All mutations
1127
- * persist immediately via setRulesFor. */
1168
+ * list (hidden when the cap of 5 is reached). Mutations persist
1169
+ * server-side via PATCH /api/agents/:id (see setRuleAt / removeRuleFor)
1170
+ * so the orchestrator injects them into the director's prompt. */
1128
1171
  function renderRulesBlock(slug) {
1129
1172
  return `<div class="ap-rules-block" data-ap-rules-block data-slug="${escape(slug)}">${renderRulesInner(slug)}</div>`;
1130
1173
  }
@@ -2402,6 +2445,11 @@
2402
2445
  <span class="ap-id-menu-mark">◆</span>
2403
2446
  <span>Regenerate 8-bit avatar</span>
2404
2447
  </button>`);
2448
+ parts.push(`
2449
+ <button type="button" class="ap-id-menu-item" data-ap-menu-action="edit-avatar3d">
2450
+ <span class="ap-id-menu-mark">◈</span>
2451
+ <span>Customize 3D avatar</span>
2452
+ </button>`);
2405
2453
  }
2406
2454
  // Persona MD download · only present for Full-mode agents (those
2407
2455
  // built via the deep persona-builder pipeline). Their `personaSpec`
@@ -2440,17 +2488,19 @@
2440
2488
  if (el) el.remove();
2441
2489
  }
2442
2490
 
2443
- /** Generate a fresh 8-bit SVG and persist it as the agent's
2491
+ /** Render a fresh 3D voxel portrait and persist it as the agent's
2444
2492
  * avatar. Updates the live store so subsequent renders use the
2445
- * new image, then repaints the profile in place. Seeded directors
2446
- * fall back to a localStorage override (the server only stores
2447
- * user-created agents). */
2493
+ * new image, then repaints the profile in place. Uses the shared
2494
+ * Avatar3DSnap helper (same pipeline the agent-profile capture
2495
+ * and home / new-agent flows go through) — no more 8-bit SVG.
2496
+ * Seeded directors fall back to a localStorage override (the
2497
+ * server only stores user-created agents). */
2448
2498
  async function regenerateProfileAvatar(slug) {
2449
- const skill = window.AvatarSkill;
2450
- if (!skill) return;
2451
- const seed = skill.randomSeed();
2452
- const svg = skill.generate(seed);
2453
- const dataUrl = "data:image/svg+xml;utf8," + encodeURIComponent(svg);
2499
+ const snap = window.Avatar3DSnap;
2500
+ if (!snap || typeof snap.generate !== "function") return;
2501
+ const seed = snap.randomSeed();
2502
+ const dataUrl = await snap.generate(seed);
2503
+ if (!dataUrl) return;
2454
2504
  const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
2455
2505
  if (live) {
2456
2506
  try {
@@ -2999,10 +3049,109 @@
2999
3049
  await fetchNextVoicePage(30);
3000
3050
  return state.voices;
3001
3051
  }
3052
+ /** Repaint the voice-picker trigger's label for a given director.
3053
+ * Called after `ensureVoiceOptions()` resolves and after a label
3054
+ * rename so the trigger reflects the friendliest available name
3055
+ * without forcing the user to reopen the profile. Idempotent +
3056
+ * cheap (DOM querySelector). */
3057
+ function repaintTriggerLabel(slug) {
3058
+ if (!slug) return;
3059
+ const v = window.app && window.app.agentsById ? window.app.agentsById[slug]?.voice : null;
3060
+ if (!v || !v.voiceId) return;
3061
+ document.querySelectorAll(`[data-ap-voice-row][data-slug="${slug}"]`).forEach((row) => {
3062
+ const name = row.querySelector("[data-ap-voice-name]");
3063
+ if (name) name.textContent = `${v.provider} · ${resolveVoiceLabel(v)}`;
3064
+ });
3065
+ }
3066
+
3002
3067
  function voiceForAgent(slug) {
3003
3068
  const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
3004
3069
  return live && live.voice ? live.voice : null;
3005
3070
  }
3071
+ /** Standalone voice-label cache · mirrors the server's
3072
+ * `voice_labels` table (mig 055) so the friendly name a user
3073
+ * typed in the clone modal survives a page reload without
3074
+ * waiting for `/api/voices` catalog propagation. Filled on boot
3075
+ * by `prefetchVoiceLabels()`; rewritten when the user renames
3076
+ * a voice in the picker. Map<voiceId, label>. */
3077
+ const voiceLabelCache = new Map();
3078
+ let voiceLabelPrefetchPromise = null;
3079
+
3080
+ async function prefetchVoiceLabels() {
3081
+ if (voiceLabelPrefetchPromise) return voiceLabelPrefetchPromise;
3082
+ voiceLabelPrefetchPromise = (async () => {
3083
+ try {
3084
+ const res = await fetch("/api/voice-labels", { cache: "no-store" });
3085
+ if (!res.ok) return;
3086
+ const json = await res.json();
3087
+ const rows = Array.isArray(json && json.labels) ? json.labels : [];
3088
+ voiceLabelCache.clear();
3089
+ for (const row of rows) {
3090
+ if (row && typeof row.voiceId === "string" && typeof row.label === "string") {
3091
+ voiceLabelCache.set(row.voiceId, row.label);
3092
+ }
3093
+ }
3094
+ } catch { /* network blip · resolveVoiceLabel falls back to catalog */ }
3095
+ })();
3096
+ return voiceLabelPrefetchPromise;
3097
+ }
3098
+ // Boot-time prefetch · the agent-profile module loads on every
3099
+ // page, so the cache is warm by the time the user opens any
3100
+ // director profile. Fire-and-forget; the trigger repaint below
3101
+ // also re-resolves once it lands.
3102
+ prefetchVoiceLabels();
3103
+
3104
+ /** Pick the friendliest display name for a voice. Four sources,
3105
+ * in priority order:
3106
+ * 1. The local `voiceLabelCache` (filled from `/api/voice-labels`
3107
+ * at boot) — survives reload + multi-device sync.
3108
+ * 2. `voice.label` from the catalogue row (provider-side rename
3109
+ * via the MiniMax / ElevenLabs dashboard wins here).
3110
+ * 3. The cached pager state · voices coming from `agent.voice`
3111
+ * (rendered by the trigger) carry only `{provider, model,
3112
+ * voiceId, ...}` — no label field. Look up the matching
3113
+ * voiceId in `voicePagerState.voices` to find the catalog
3114
+ * entry's friendly name.
3115
+ * 4. Raw voice_id as last resort. */
3116
+ function resolveVoiceLabel(voice) {
3117
+ if (!voice) return "";
3118
+ const id = voice.voiceId || "";
3119
+ if (id && voiceLabelCache.has(id)) return voiceLabelCache.get(id);
3120
+ if (voice.label) return voice.label;
3121
+ if (!id) return "";
3122
+ try {
3123
+ const cached = (voicePagerState && voicePagerState.voices) || [];
3124
+ const hit = cached.find((x) =>
3125
+ x.voiceId === id && (!voice.provider || x.provider === voice.provider),
3126
+ );
3127
+ if (hit && hit.label && hit.label !== hit.voiceId) return hit.label;
3128
+ } catch { /* */ }
3129
+ return id;
3130
+ }
3131
+ /** Re-fetch the voice catalog + repaint the open picker in place.
3132
+ * Called from the inline-rename flow after PUT/DELETE on
3133
+ * /api/voice-labels/* so the user sees the new label without
3134
+ * re-opening the dropdown. No-op when the picker isn't open. */
3135
+ async function refreshOpenVoicePicker() {
3136
+ const pop = document.getElementById("ap-voice-picker");
3137
+ if (!pop) return;
3138
+ const slug = pop.dataset.slug;
3139
+ invalidateVoicePager();
3140
+ await fetchNextVoicePage(30);
3141
+ if (!slug) return;
3142
+ renderVoicePickerBody(pop, slug);
3143
+ // Trigger label may also need a refresh (if we renamed the
3144
+ // currently-selected voice).
3145
+ const row = document.querySelector(`[data-ap-voice-row][data-slug="${slug}"]`);
3146
+ if (row) {
3147
+ const v = voiceForAgent(slug);
3148
+ const name = row.querySelector("[data-ap-voice-name]");
3149
+ const state = getVoicePagerState();
3150
+ // Look up the fresh row by voiceId so we pick up the renamed label.
3151
+ const fresh = (state.voices || []).find((x) => v && x.provider === v.provider && x.voiceId === v.voiceId);
3152
+ if (name && v) name.textContent = `${v.provider} · ${resolveVoiceLabel(fresh || v)}`;
3153
+ }
3154
+ }
3006
3155
  /** Format a voice-tune slider value for display. Speed gets an
3007
3156
  * `×` suffix; centered ranges (pitch / modify-*) prefix non-zero
3008
3157
  * values with `+` so the sign is unambiguous. Tabular numerals
@@ -3076,9 +3225,21 @@
3076
3225
  // pops instantly when the user clicks. Without it the first click
3077
3226
  // pays the /api/voices round-trip (hundreds of voices on MiniMax)
3078
3227
  // and the dropdown lags visibly. Idempotent · cache-hit no-ops.
3079
- ensureVoiceOptions();
3228
+ //
3229
+ // After the cache lands (or the parallel voice-labels prefetch
3230
+ // resolves) we re-resolve the trigger label · the initial
3231
+ // render only has access to `agent.voice` (no label field), so
3232
+ // trigger initially shows the raw voice_id. Once either source
3233
+ // lands `resolveVoiceLabel` can find the friendly name.
3234
+ void ensureVoiceOptions().then(() => repaintTriggerLabel(slug));
3235
+ void prefetchVoiceLabels().then(() => repaintTriggerLabel(slug));
3080
3236
  const v = voiceForAgent(slug);
3081
- const label = v ? `${v.provider} · ${v.voiceId}` : uiT("ap_voice_browser_default");
3237
+ // Trigger label prefers the user-typed name for cloned voices
3238
+ // (stored in localStorage at clone time) over the raw voice_id.
3239
+ // resolveVoiceLabel() picks the friendliest available string.
3240
+ const label = v
3241
+ ? `${v.provider} · ${resolveVoiceLabel(v)}`
3242
+ : uiT("ap_voice_browser_default");
3082
3243
  const speed = v?.speed ?? 1;
3083
3244
  const pitch = v?.pitch ?? 0;
3084
3245
  const emotion = v?.emotion || "";
@@ -3090,34 +3251,73 @@
3090
3251
 
3091
3252
  return `
3092
3253
  <div class="ap-voice-config" data-ap-voice-row data-slug="${escape(slug)}">
3093
- <div class="ap-voice-picker-row">
3094
- <button type="button" class="ap-model-trigger" data-ap-voice-trigger>
3095
- <span class="ap-model-trigger-text">
3096
- <span class="ap-model-trigger-name" data-ap-voice-name>${escape(label)}</span>
3097
- </span>
3098
- <span class="ap-model-trigger-caret">▾</span>
3099
- </button>
3100
- <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>
3101
- </div>
3102
- <div class="ap-voice-emotion-row">
3103
- <button type="button" class="ap-model-trigger ap-voice-emotion-trigger" data-ap-emotion-trigger data-slug="${escape(slug)}">
3104
- <span class="ap-model-trigger-text">
3105
- <span class="ap-model-trigger-name" data-ap-voice-emotion-label>${escape(emotionLabel)}</span>
3106
- </span>
3107
- <span class="ap-model-trigger-caret">▾</span>
3108
- </button>
3109
- <div class="ap-voice-emotion-hint">${escape(uiT("ap_voice_emotion_hint"))}</div>
3110
- </div>
3111
- <details class="ap-voice-advanced">
3112
- <summary>${escape(uiT("ap_voice_advanced"))}</summary>
3113
- <div class="ap-voice-tune-grid">
3114
- ${renderVoiceTuneRow(slug, "speed", uiT("ap_voice_speed"), speed, 0.5, 2, 0.1)}
3115
- ${renderVoiceTuneRow(slug, "pitch", uiT("ap_voice_pitch"), pitch, -12, 12, 1)}
3116
- ${renderVoiceTuneRow(slug, "modifyPitch", uiT("ap_voice_modify_pitch"), modPitch, -100, 100, 5)}
3117
- ${renderVoiceTuneRow(slug, "modifyIntensity", uiT("ap_voice_modify_intensity"), modIntensity, -100, 100, 5)}
3118
- ${renderVoiceTuneRow(slug, "modifyTimbre", uiT("ap_voice_modify_timbre"), modTimbre, -100, 100, 5)}
3254
+ <section class="ap-voice-section">
3255
+ <header class="ap-voice-section-head">${escape(uiT("ap_voice_section_voice"))}</header>
3256
+ <div class="ap-voice-picker-row">
3257
+ <button type="button" class="ap-model-trigger" data-ap-voice-trigger>
3258
+ <span class="ap-model-trigger-text">
3259
+ <span class="ap-model-trigger-name" data-ap-voice-name>${escape(label)}</span>
3260
+ </span>
3261
+ <span class="ap-model-trigger-caret">▾</span>
3262
+ </button>
3263
+ <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>
3119
3264
  </div>
3120
- </details>
3265
+ </section>
3266
+ <section class="ap-voice-section">
3267
+ <header class="ap-voice-section-head">${escape(uiT("ap_voice_section_emotion"))}</header>
3268
+ <div class="ap-voice-emotion-row">
3269
+ <button type="button" class="ap-model-trigger ap-voice-emotion-trigger" data-ap-emotion-trigger data-slug="${escape(slug)}">
3270
+ <span class="ap-model-trigger-text">
3271
+ <span class="ap-model-trigger-name" data-ap-voice-emotion-label>${escape(emotionLabel)}</span>
3272
+ </span>
3273
+ <span class="ap-model-trigger-caret">▾</span>
3274
+ </button>
3275
+ <div class="ap-voice-emotion-hint">${escape(uiT("ap_voice_emotion_hint"))}</div>
3276
+ </div>
3277
+ </section>
3278
+ <section class="ap-voice-section">
3279
+ <header class="ap-voice-section-head">${escape(uiT("ap_voice_section_preview"))}</header>
3280
+ <div class="ap-voice-preview-row">
3281
+ <textarea
3282
+ id="ap-voice-preview-text-${escape(slug)}"
3283
+ class="ap-voice-preview-text"
3284
+ data-ap-voice-preview-text="${escape(slug)}"
3285
+ rows="2"
3286
+ maxlength="240"
3287
+ placeholder="${escape(uiT("ap_voice_preview_sample"))}"
3288
+ >${escape(loadPreviewText(slug))}</textarea>
3289
+ </div>
3290
+ </section>
3291
+ <section class="ap-voice-section">
3292
+ <button type="button" class="ap-voice-forge" data-ap-voice-clone="${escape(slug)}">
3293
+ <span class="ap-voice-forge-corner ap-voice-forge-corner-tl" aria-hidden="true"></span>
3294
+ <span class="ap-voice-forge-corner ap-voice-forge-corner-tr" aria-hidden="true"></span>
3295
+ <span class="ap-voice-forge-corner ap-voice-forge-corner-bl" aria-hidden="true"></span>
3296
+ <span class="ap-voice-forge-corner ap-voice-forge-corner-br" aria-hidden="true"></span>
3297
+ <span class="ap-voice-forge-scan" aria-hidden="true"></span>
3298
+ <span class="ap-voice-forge-kicker">${escape(uiT("voice_clone_btn_kicker"))}</span>
3299
+ <span class="ap-voice-forge-body">
3300
+ <span class="ap-voice-forge-rune" aria-hidden="true">
3301
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/></svg>
3302
+ </span>
3303
+ <span class="ap-voice-forge-title">${escape(uiT("voice_clone_btn"))}</span>
3304
+ <span class="ap-voice-forge-arrow" aria-hidden="true">›</span>
3305
+ </span>
3306
+ <span class="ap-voice-forge-hint">${escape(uiT("voice_clone_btn_hint"))}</span>
3307
+ </button>
3308
+ </section>
3309
+ <section class="ap-voice-section">
3310
+ <details class="ap-voice-advanced">
3311
+ <summary>${escape(uiT("ap_voice_advanced"))}</summary>
3312
+ <div class="ap-voice-tune-grid">
3313
+ ${renderVoiceTuneRow(slug, "speed", uiT("ap_voice_speed"), speed, 0.5, 2, 0.1)}
3314
+ ${renderVoiceTuneRow(slug, "pitch", uiT("ap_voice_pitch"), pitch, -12, 12, 1)}
3315
+ ${renderVoiceTuneRow(slug, "modifyPitch", uiT("ap_voice_modify_pitch"), modPitch, -100, 100, 5)}
3316
+ ${renderVoiceTuneRow(slug, "modifyIntensity", uiT("ap_voice_modify_intensity"), modIntensity, -100, 100, 5)}
3317
+ ${renderVoiceTuneRow(slug, "modifyTimbre", uiT("ap_voice_modify_timbre"), modTimbre, -100, 100, 5)}
3318
+ </div>
3319
+ </details>
3320
+ </section>
3121
3321
  </div>
3122
3322
  `;
3123
3323
  }
@@ -3230,20 +3430,49 @@
3230
3430
  // popover. Same treatment as the empty-state error.
3231
3431
  const errBannerHtml = voicePickerErrorHtml(state.error);
3232
3432
  if (errBannerHtml) groups.push(errBannerHtml);
3433
+ // Two-level grouping · user-owned cloned voices get their own
3434
+ // header at the top of the dropdown ("// cloned · MiniMax"), then
3435
+ // the standard provider groups for system / premade voices. Cloned
3436
+ // voices on both providers carry a recognisable language tag —
3437
+ // MiniMax sets it to "clone" (we tag it), ElevenLabs uses v2's
3438
+ // `category` which is "cloned" / "professional" for user voices.
3439
+ const isClonedTag = (v) => v && (v.language === "clone" || v.language === "cloned" || v.language === "professional");
3233
3440
  let last = null;
3234
3441
  for (const v of voices) {
3235
3442
  const provider = String(v.provider || "browser");
3236
- if (provider !== last) {
3237
- groups.push(`<div class="ap-model-group">${escape(provider)}</div>`);
3238
- last = provider;
3443
+ const cloned = isClonedTag(v);
3444
+ // Group key reflects the section the row belongs to. Two
3445
+ // cloned rows from the same provider share a header; two
3446
+ // system rows do too.
3447
+ const groupKey = cloned ? `clone:${provider}` : `system:${provider}`;
3448
+ if (groupKey !== last) {
3449
+ const label = cloned
3450
+ ? uiT("ap_voice_group_cloned_provider", { provider })
3451
+ : provider;
3452
+ groups.push(`<div class="ap-model-group${cloned ? " ap-model-group-cloned" : ""}">${escape(label)}</div>`);
3453
+ last = groupKey;
3239
3454
  }
3240
3455
  const id = [provider, v.model || "", v.voiceId || ""].join("|");
3241
3456
  const active = current && current.provider === provider && current.model === v.model && current.voiceId === v.voiceId;
3457
+ // For cloned rows the `language: "clone"` hint is redundant
3458
+ // (the group header already says so); show just the model.
3459
+ const hintParts = [v.model || ""];
3460
+ if (!cloned && v.language) hintParts.push(v.language);
3461
+ // Inline rename button · only meaningful for provider-side
3462
+ // voices (cloned / system on minimax + elevenlabs). The browser
3463
+ // fallback row has no voice_id to label, so skip the chip there.
3464
+ const canRename = (provider === "minimax" || provider === "elevenlabs") && v.voiceId;
3465
+ const renameBtn = canRename
3466
+ ? `<button type="button" class="ap-model-opt-rename" data-ap-voice-label-edit data-voice-id="${escape(v.voiceId)}" data-provider="${escape(provider)}" data-current-label="${escape(resolveVoiceLabel(v) || "")}" aria-label="${escape(uiT("ap_voice_rename_btn"))}" title="${escape(uiT("ap_voice_rename_btn"))}">✎</button>`
3467
+ : "";
3242
3468
  groups.push(`
3243
- <button type="button" class="ap-model-opt${active ? " active" : ""}" data-ap-voice-pick="${escape(id)}">
3244
- <span class="ap-model-opt-label">${escape(v.label || v.voiceId || uiT("ap_voice_fallback_voice"))}</span>
3245
- <span class="ap-model-opt-hint">${escape((v.model || "") + (v.language ? " · " + v.language : ""))}</span>
3246
- </button>
3469
+ <div class="ap-model-opt-row${cloned ? " is-cloned" : ""}">
3470
+ <button type="button" class="ap-model-opt${active ? " active" : ""}${cloned ? " ap-model-opt-cloned" : ""}" data-ap-voice-pick="${escape(id)}">
3471
+ <span class="ap-model-opt-label">${escape(resolveVoiceLabel(v) || uiT("ap_voice_fallback_voice"))}</span>
3472
+ <span class="ap-model-opt-hint">${escape(hintParts.filter(Boolean).join(" · "))}</span>
3473
+ </button>
3474
+ ${renameBtn}
3475
+ </div>
3247
3476
  `);
3248
3477
  }
3249
3478
  // Trailing sentinel · either a "loading more" pulse (when we're
@@ -3381,7 +3610,7 @@
3381
3610
  rows.forEach((row) => {
3382
3611
  const name = row.querySelector("[data-ap-voice-name]");
3383
3612
  const prov = row.querySelector("[data-ap-voice-provider]");
3384
- if (name && nv && nv.provider && nv.voiceId) name.textContent = `${nv.provider} · ${nv.voiceId}`;
3613
+ if (name && nv && nv.provider && nv.voiceId) name.textContent = `${nv.provider} · ${resolveVoiceLabel(nv)}`;
3385
3614
  if (prov && nv && nv.model != null) prov.textContent = nv.model;
3386
3615
  const emLb = row.querySelector("[data-ap-voice-emotion-label]");
3387
3616
  if (emLb && nv) emLb.textContent = voiceEmotionOptionLabel(nv.emotion ?? "");
@@ -3390,6 +3619,25 @@
3390
3619
  .catch((e) => alert(uiT("ap_voice_save_err", { msg: e && e.message ? e.message : String(e) })));
3391
3620
  }
3392
3621
 
3622
+ /** Per-slug custom preview text · persisted to localStorage so the
3623
+ * user's preferred sample line is remembered across renders /
3624
+ * reloads / agent-profile open & close. Falls back to the active
3625
+ * locale's `ap_voice_preview_sample` when empty. */
3626
+ function previewTextStorageKey(slug) {
3627
+ return `pb.voice-preview-text.${slug}`;
3628
+ }
3629
+ function loadPreviewText(slug) {
3630
+ try {
3631
+ return localStorage.getItem(previewTextStorageKey(slug)) || "";
3632
+ } catch { return ""; }
3633
+ }
3634
+ function savePreviewText(slug, value) {
3635
+ try {
3636
+ if (value && value.trim()) localStorage.setItem(previewTextStorageKey(slug), value);
3637
+ else localStorage.removeItem(previewTextStorageKey(slug));
3638
+ } catch { /* */ }
3639
+ }
3640
+
3393
3641
  async function previewVoice(slug) {
3394
3642
  const v = voiceForAgent(slug);
3395
3643
  if (!v || !v.voiceId) {
@@ -3403,16 +3651,17 @@
3403
3651
  // doesn't match the system's mono register.
3404
3652
  if (btn) { btn.disabled = true; btn.classList.add("is-loading"); }
3405
3653
  try {
3654
+ // User's custom preview text wins when set (saved per slug to
3655
+ // localStorage from the .ap-voice-preview-text textarea). When
3656
+ // empty we send the active locale's default — server otherwise
3657
+ // falls back to a hardcoded Chinese phrase, which surfaced in
3658
+ // EN/JA/ES locales as the wrong language being read.
3659
+ const customText = (loadPreviewText(slug) || "").trim();
3406
3660
  const r = await fetch("/api/voices/preview", {
3407
3661
  method: "POST",
3408
3662
  headers: { "content-type": "application/json" },
3409
3663
  body: JSON.stringify({
3410
- // Localised sample line · the server falls back to a
3411
- // hardcoded Chinese default when `text` is omitted, which
3412
- // surfaced in English / Japanese / Spanish locales as a
3413
- // Chinese phrase being read out of the voice. Sending the
3414
- // i18n key value matches the user's active UI locale.
3415
- text: uiT("ap_voice_preview_sample"),
3664
+ text: customText || uiT("ap_voice_preview_sample"),
3416
3665
  provider: v.provider,
3417
3666
  model: v.model,
3418
3667
  voiceId: v.voiceId,
@@ -4159,6 +4408,9 @@
4159
4408
  e.preventDefault();
4160
4409
  closeProfileIdMenu();
4161
4410
  if (action === "regen-avatar" && slug) regenerateProfileAvatar(slug);
4411
+ if (action === "edit-avatar3d" && slug && typeof window.openAvatar3DEditor === "function") {
4412
+ window.openAvatar3DEditor(slug);
4413
+ }
4162
4414
  if (action === "delete" && slug && window.app && typeof window.app.deleteAgent === "function") {
4163
4415
  // deleteAgent handles confirm + DELETE call + closes the
4164
4416
  // profile + refreshes the sidebar. No-op for seed/chair
@@ -4851,8 +5103,8 @@
4851
5103
  }
4852
5104
  });
4853
5105
 
4854
- // Rules · persist edits as the user types (debounce-free; the
4855
- // payload is small and writes go to localStorage).
5106
+ // Rules · persist edits as the user types · setRuleAt debounce-
5107
+ // flushes to PATCH /api/agents/:id so the orchestrator picks them up.
4856
5108
  document.addEventListener("input", (e) => {
4857
5109
  const ri = e.target.closest("[data-ap-rule-input]");
4858
5110
  if (!ri) return;
@@ -5033,6 +5285,61 @@
5033
5285
  closeEmotionPicker();
5034
5286
  return;
5035
5287
  }
5288
+ // Inline rename · per-row ✎ button. Prompt for the new label,
5289
+ // PUT it to /api/voice-labels/:voiceId, drop both the server
5290
+ // catalogue cache (the route does that for us) and the client
5291
+ // pager state, then re-fetch + repaint the open picker. This
5292
+ // keeps the menu open during the rename so the user sees the
5293
+ // label flip in place.
5294
+ const labelBtn = e.target.closest("[data-ap-voice-label-edit]");
5295
+ if (labelBtn) {
5296
+ e.preventDefault();
5297
+ e.stopPropagation();
5298
+ const voiceId = labelBtn.getAttribute("data-voice-id") || "";
5299
+ const provider = labelBtn.getAttribute("data-provider") || "";
5300
+ const current = labelBtn.getAttribute("data-current-label") || "";
5301
+ if (!voiceId || !provider) return;
5302
+ const next = window.prompt(uiT("ap_voice_rename_prompt", { id: voiceId }), current);
5303
+ if (next === null) return; // cancelled
5304
+ const trimmed = String(next).trim();
5305
+ if (!trimmed) {
5306
+ // Empty input clears the custom label so the catalog name
5307
+ // wins again (or falls back to voice_id).
5308
+ if (!window.confirm(uiT("ap_voice_rename_clear_confirm"))) return;
5309
+ fetch(`/api/voice-labels/${encodeURIComponent(voiceId)}`, { method: "DELETE" })
5310
+ .then(() => {
5311
+ voiceLabelCache.delete(voiceId);
5312
+ refreshOpenVoicePicker();
5313
+ // Trigger labels for any director sitting on this voice
5314
+ // need to re-resolve · their previous "friendly" name
5315
+ // just disappeared.
5316
+ document.querySelectorAll(`[data-ap-voice-row]`).forEach((row) => {
5317
+ const s = row.getAttribute("data-slug");
5318
+ if (s) repaintTriggerLabel(s);
5319
+ });
5320
+ })
5321
+ .catch(() => { /* */ });
5322
+ return;
5323
+ }
5324
+ fetch(`/api/voice-labels/${encodeURIComponent(voiceId)}`, {
5325
+ method: "PUT",
5326
+ headers: { "content-type": "application/json" },
5327
+ body: JSON.stringify({ provider, label: trimmed }),
5328
+ })
5329
+ .then((r) => r.ok ? r.json() : r.json().then((j) => Promise.reject(new Error(j.error || `HTTP ${r.status}`))))
5330
+ .then(() => {
5331
+ voiceLabelCache.set(voiceId, trimmed);
5332
+ refreshOpenVoicePicker();
5333
+ // Repaint every trigger that currently displays this
5334
+ // voice so the rename lands immediately.
5335
+ document.querySelectorAll(`[data-ap-voice-row]`).forEach((row) => {
5336
+ const s = row.getAttribute("data-slug");
5337
+ if (s) repaintTriggerLabel(s);
5338
+ });
5339
+ })
5340
+ .catch((err) => alert(uiT("ap_voice_rename_err", { msg: err?.message || String(err) })));
5341
+ return;
5342
+ }
5036
5343
  const voiceOpt = e.target.closest("[data-ap-voice-pick]");
5037
5344
  if (voiceOpt) {
5038
5345
  e.preventDefault();
@@ -5054,6 +5361,113 @@
5054
5361
  if (slug) previewVoice(slug);
5055
5362
  return;
5056
5363
  }
5364
+ // Preview text textarea · persist on blur so we don't hammer
5365
+ // localStorage on every keystroke; the input listener below
5366
+ // syncs the in-memory STATE for the next previewVoice call.
5367
+ // Voice cloning · open the boardroomVoiceClone overlay. The
5368
+ // singleton lives in `public/voice-clone.js`; the `onApplied`
5369
+ // callback re-renders this voice block so the picker label
5370
+ // updates to the new voice_id without a full profile reload.
5371
+ const cloneBtn = e.target.closest("[data-ap-voice-clone]");
5372
+ if (cloneBtn) {
5373
+ e.preventDefault();
5374
+ const slug = cloneBtn.getAttribute("data-ap-voice-clone");
5375
+ if (!slug) return;
5376
+ const vc = window.boardroomVoiceClone;
5377
+ if (!vc || typeof vc.open !== "function") return;
5378
+ const agent = (window.app && window.app.agentsById && window.app.agentsById[slug]) || null;
5379
+ vc.open({
5380
+ agentId: slug,
5381
+ agentName: agent ? agent.name : "",
5382
+ onApplied: async (applied) => {
5383
+ // `applied` = { voiceId, label, provider } from voice-clone.js.
5384
+ // Four steps land the user squarely on the new voice:
5385
+ // 1. Sync the client-side agent cache (`window.app.
5386
+ // agentsById[slug].voice`) so the picker trigger
5387
+ // renders the new selection.
5388
+ // 2. Drop the pager cache + re-fetch /api/voices · the
5389
+ // server has just dropped its catalogue cache, so a
5390
+ // fresh fetch may pick up the new voice straight from
5391
+ // the provider (5-30 s propagation can still mean the
5392
+ // catalog doesn't carry it yet — step 3 fills the gap).
5393
+ // 3. Inject the new voice into the pager state if the
5394
+ // fresh fetch didn't bring it back. This is the
5395
+ // optimistic safety net for the propagation gap. The
5396
+ // model field MUST match the model the catalog will
5397
+ // return on the next refresh — otherwise dedup misses
5398
+ // and the row appears twice. We hard-code the
5399
+ // cloning-model per provider (same as the server
5400
+ // worker writes into agent.voice.model).
5401
+ // 4. Re-render the voice block so the trigger label
5402
+ // reflects the new voice immediately.
5403
+ try {
5404
+ const provider = (applied && applied.provider) || "minimax";
5405
+ const model = provider === "elevenlabs" ? "eleven_multilingual_v2" : "speech-2.8-hd";
5406
+ const voiceId = applied && applied.voiceId;
5407
+ const label = (applied && applied.label) || "";
5408
+
5409
+ // Step 1 · live agent cache.
5410
+ const liveAgent = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
5411
+ if (liveAgent && voiceId) {
5412
+ const prev = liveAgent.voice || {};
5413
+ liveAgent.voice = {
5414
+ speed: prev.speed,
5415
+ pitch: prev.pitch,
5416
+ volume: prev.volume,
5417
+ emotion: prev.emotion,
5418
+ provider,
5419
+ model,
5420
+ voiceId,
5421
+ };
5422
+ }
5423
+ // Step 1b · local label cache · keeps the friendly name
5424
+ // available across a page reload BEFORE `/api/voice-labels`
5425
+ // prefetch round-trips on the next boot.
5426
+ if (voiceId && label) {
5427
+ voiceLabelCache.set(voiceId, label);
5428
+ }
5429
+
5430
+ // Step 2 · drop pager cache + force re-fetch. Without
5431
+ // this, any later `invalidateVoicePager` triggered by
5432
+ // settings tweaks would strand a stale optimistic-only
5433
+ // row (cleared state has no voices for the new id).
5434
+ invalidateVoicePager();
5435
+ try { await fetchNextVoicePage(30); } catch { /* */ }
5436
+
5437
+ // Step 3 · inject if the catalog refetch didn't include it.
5438
+ const state = getVoicePagerState();
5439
+ if (voiceId && state) {
5440
+ const id = `${provider}|${model}|${voiceId}`;
5441
+ const dupeIdx = (state.voices || []).findIndex((x) => `${x.provider}|${x.model || ""}|${x.voiceId || ""}` === id);
5442
+ if (dupeIdx < 0) {
5443
+ state.voices.unshift({
5444
+ provider,
5445
+ model,
5446
+ voiceId,
5447
+ label: label || voiceId,
5448
+ language: "clone",
5449
+ configured: true,
5450
+ });
5451
+ } else if (label) {
5452
+ if (!state.voices[dupeIdx].label || state.voices[dupeIdx].label === voiceId) {
5453
+ state.voices[dupeIdx] = { ...state.voices[dupeIdx], label, language: "clone" };
5454
+ }
5455
+ }
5456
+ }
5457
+
5458
+ // 3 · re-render the voice block (trigger label now correct).
5459
+ const row = document.querySelector(`.ap-voice-config[data-slug="${slug}"], .ap-voice-locked[data-slug="${slug}"]`);
5460
+ if (row && typeof renderVoiceBlock === "function") {
5461
+ const wrap = document.createElement("div");
5462
+ wrap.innerHTML = renderVoiceBlock(slug);
5463
+ const fresh = wrap.firstElementChild;
5464
+ if (fresh && row.parentNode) row.parentNode.replaceChild(fresh, row);
5465
+ }
5466
+ } catch { /* */ }
5467
+ },
5468
+ });
5469
+ return;
5470
+ }
5057
5471
  });
5058
5472
  document.addEventListener("click", (e) => {
5059
5473
  const pop = document.getElementById("ap-model-picker");
@@ -5094,6 +5508,16 @@
5094
5508
  range.style.setProperty("--fill-hi", hi);
5095
5509
  return;
5096
5510
  }
5511
+ // Custom preview text · persist per-slug to localStorage so the
5512
+ // next previewVoice call (or the next agent-profile open) picks
5513
+ // it up. Save on every keystroke; the cost is one tiny write
5514
+ // and the user never wonders "did my edit stick?".
5515
+ const previewText = e.target.closest("[data-ap-voice-preview-text]");
5516
+ if (previewText) {
5517
+ const slug = previewText.getAttribute("data-ap-voice-preview-text");
5518
+ if (slug) savePreviewText(slug, previewText.value);
5519
+ return;
5520
+ }
5097
5521
  });
5098
5522
  document.addEventListener("change", (e) => {
5099
5523
  const range = e.target.closest("[data-ap-voice-range]");