privateboard 0.1.36 → 0.1.38

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.
@@ -2999,10 +2999,109 @@
2999
2999
  await fetchNextVoicePage(30);
3000
3000
  return state.voices;
3001
3001
  }
3002
+ /** Repaint the voice-picker trigger's label for a given director.
3003
+ * Called after `ensureVoiceOptions()` resolves and after a label
3004
+ * rename so the trigger reflects the friendliest available name
3005
+ * without forcing the user to reopen the profile. Idempotent +
3006
+ * cheap (DOM querySelector). */
3007
+ function repaintTriggerLabel(slug) {
3008
+ if (!slug) return;
3009
+ const v = window.app && window.app.agentsById ? window.app.agentsById[slug]?.voice : null;
3010
+ if (!v || !v.voiceId) return;
3011
+ document.querySelectorAll(`[data-ap-voice-row][data-slug="${slug}"]`).forEach((row) => {
3012
+ const name = row.querySelector("[data-ap-voice-name]");
3013
+ if (name) name.textContent = `${v.provider} · ${resolveVoiceLabel(v)}`;
3014
+ });
3015
+ }
3016
+
3002
3017
  function voiceForAgent(slug) {
3003
3018
  const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
3004
3019
  return live && live.voice ? live.voice : null;
3005
3020
  }
3021
+ /** Standalone voice-label cache · mirrors the server's
3022
+ * `voice_labels` table (mig 055) so the friendly name a user
3023
+ * typed in the clone modal survives a page reload without
3024
+ * waiting for `/api/voices` catalog propagation. Filled on boot
3025
+ * by `prefetchVoiceLabels()`; rewritten when the user renames
3026
+ * a voice in the picker. Map<voiceId, label>. */
3027
+ const voiceLabelCache = new Map();
3028
+ let voiceLabelPrefetchPromise = null;
3029
+
3030
+ async function prefetchVoiceLabels() {
3031
+ if (voiceLabelPrefetchPromise) return voiceLabelPrefetchPromise;
3032
+ voiceLabelPrefetchPromise = (async () => {
3033
+ try {
3034
+ const res = await fetch("/api/voice-labels", { cache: "no-store" });
3035
+ if (!res.ok) return;
3036
+ const json = await res.json();
3037
+ const rows = Array.isArray(json && json.labels) ? json.labels : [];
3038
+ voiceLabelCache.clear();
3039
+ for (const row of rows) {
3040
+ if (row && typeof row.voiceId === "string" && typeof row.label === "string") {
3041
+ voiceLabelCache.set(row.voiceId, row.label);
3042
+ }
3043
+ }
3044
+ } catch { /* network blip · resolveVoiceLabel falls back to catalog */ }
3045
+ })();
3046
+ return voiceLabelPrefetchPromise;
3047
+ }
3048
+ // Boot-time prefetch · the agent-profile module loads on every
3049
+ // page, so the cache is warm by the time the user opens any
3050
+ // director profile. Fire-and-forget; the trigger repaint below
3051
+ // also re-resolves once it lands.
3052
+ prefetchVoiceLabels();
3053
+
3054
+ /** Pick the friendliest display name for a voice. Four sources,
3055
+ * in priority order:
3056
+ * 1. The local `voiceLabelCache` (filled from `/api/voice-labels`
3057
+ * at boot) — survives reload + multi-device sync.
3058
+ * 2. `voice.label` from the catalogue row (provider-side rename
3059
+ * via the MiniMax / ElevenLabs dashboard wins here).
3060
+ * 3. The cached pager state · voices coming from `agent.voice`
3061
+ * (rendered by the trigger) carry only `{provider, model,
3062
+ * voiceId, ...}` — no label field. Look up the matching
3063
+ * voiceId in `voicePagerState.voices` to find the catalog
3064
+ * entry's friendly name.
3065
+ * 4. Raw voice_id as last resort. */
3066
+ function resolveVoiceLabel(voice) {
3067
+ if (!voice) return "";
3068
+ const id = voice.voiceId || "";
3069
+ if (id && voiceLabelCache.has(id)) return voiceLabelCache.get(id);
3070
+ if (voice.label) return voice.label;
3071
+ if (!id) return "";
3072
+ try {
3073
+ const cached = (voicePagerState && voicePagerState.voices) || [];
3074
+ const hit = cached.find((x) =>
3075
+ x.voiceId === id && (!voice.provider || x.provider === voice.provider),
3076
+ );
3077
+ if (hit && hit.label && hit.label !== hit.voiceId) return hit.label;
3078
+ } catch { /* */ }
3079
+ return id;
3080
+ }
3081
+ /** Re-fetch the voice catalog + repaint the open picker in place.
3082
+ * Called from the inline-rename flow after PUT/DELETE on
3083
+ * /api/voice-labels/* so the user sees the new label without
3084
+ * re-opening the dropdown. No-op when the picker isn't open. */
3085
+ async function refreshOpenVoicePicker() {
3086
+ const pop = document.getElementById("ap-voice-picker");
3087
+ if (!pop) return;
3088
+ const slug = pop.dataset.slug;
3089
+ invalidateVoicePager();
3090
+ await fetchNextVoicePage(30);
3091
+ if (!slug) return;
3092
+ renderVoicePickerBody(pop, slug);
3093
+ // Trigger label may also need a refresh (if we renamed the
3094
+ // currently-selected voice).
3095
+ const row = document.querySelector(`[data-ap-voice-row][data-slug="${slug}"]`);
3096
+ if (row) {
3097
+ const v = voiceForAgent(slug);
3098
+ const name = row.querySelector("[data-ap-voice-name]");
3099
+ const state = getVoicePagerState();
3100
+ // Look up the fresh row by voiceId so we pick up the renamed label.
3101
+ const fresh = (state.voices || []).find((x) => v && x.provider === v.provider && x.voiceId === v.voiceId);
3102
+ if (name && v) name.textContent = `${v.provider} · ${resolveVoiceLabel(fresh || v)}`;
3103
+ }
3104
+ }
3006
3105
  /** Format a voice-tune slider value for display. Speed gets an
3007
3106
  * `×` suffix; centered ranges (pitch / modify-*) prefix non-zero
3008
3107
  * values with `+` so the sign is unambiguous. Tabular numerals
@@ -3076,9 +3175,21 @@
3076
3175
  // pops instantly when the user clicks. Without it the first click
3077
3176
  // pays the /api/voices round-trip (hundreds of voices on MiniMax)
3078
3177
  // and the dropdown lags visibly. Idempotent · cache-hit no-ops.
3079
- ensureVoiceOptions();
3178
+ //
3179
+ // After the cache lands (or the parallel voice-labels prefetch
3180
+ // resolves) we re-resolve the trigger label · the initial
3181
+ // render only has access to `agent.voice` (no label field), so
3182
+ // trigger initially shows the raw voice_id. Once either source
3183
+ // lands `resolveVoiceLabel` can find the friendly name.
3184
+ void ensureVoiceOptions().then(() => repaintTriggerLabel(slug));
3185
+ void prefetchVoiceLabels().then(() => repaintTriggerLabel(slug));
3080
3186
  const v = voiceForAgent(slug);
3081
- const label = v ? `${v.provider} · ${v.voiceId}` : uiT("ap_voice_browser_default");
3187
+ // Trigger label prefers the user-typed name for cloned voices
3188
+ // (stored in localStorage at clone time) over the raw voice_id.
3189
+ // resolveVoiceLabel() picks the friendliest available string.
3190
+ const label = v
3191
+ ? `${v.provider} · ${resolveVoiceLabel(v)}`
3192
+ : uiT("ap_voice_browser_default");
3082
3193
  const speed = v?.speed ?? 1;
3083
3194
  const pitch = v?.pitch ?? 0;
3084
3195
  const emotion = v?.emotion || "";
@@ -3090,34 +3201,73 @@
3090
3201
 
3091
3202
  return `
3092
3203
  <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)}
3204
+ <section class="ap-voice-section">
3205
+ <header class="ap-voice-section-head">${escape(uiT("ap_voice_section_voice"))}</header>
3206
+ <div class="ap-voice-picker-row">
3207
+ <button type="button" class="ap-model-trigger" data-ap-voice-trigger>
3208
+ <span class="ap-model-trigger-text">
3209
+ <span class="ap-model-trigger-name" data-ap-voice-name>${escape(label)}</span>
3210
+ </span>
3211
+ <span class="ap-model-trigger-caret">▾</span>
3212
+ </button>
3213
+ <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
3214
  </div>
3120
- </details>
3215
+ </section>
3216
+ <section class="ap-voice-section">
3217
+ <header class="ap-voice-section-head">${escape(uiT("ap_voice_section_emotion"))}</header>
3218
+ <div class="ap-voice-emotion-row">
3219
+ <button type="button" class="ap-model-trigger ap-voice-emotion-trigger" data-ap-emotion-trigger data-slug="${escape(slug)}">
3220
+ <span class="ap-model-trigger-text">
3221
+ <span class="ap-model-trigger-name" data-ap-voice-emotion-label>${escape(emotionLabel)}</span>
3222
+ </span>
3223
+ <span class="ap-model-trigger-caret">▾</span>
3224
+ </button>
3225
+ <div class="ap-voice-emotion-hint">${escape(uiT("ap_voice_emotion_hint"))}</div>
3226
+ </div>
3227
+ </section>
3228
+ <section class="ap-voice-section">
3229
+ <header class="ap-voice-section-head">${escape(uiT("ap_voice_section_preview"))}</header>
3230
+ <div class="ap-voice-preview-row">
3231
+ <textarea
3232
+ id="ap-voice-preview-text-${escape(slug)}"
3233
+ class="ap-voice-preview-text"
3234
+ data-ap-voice-preview-text="${escape(slug)}"
3235
+ rows="2"
3236
+ maxlength="240"
3237
+ placeholder="${escape(uiT("ap_voice_preview_sample"))}"
3238
+ >${escape(loadPreviewText(slug))}</textarea>
3239
+ </div>
3240
+ </section>
3241
+ <section class="ap-voice-section">
3242
+ <button type="button" class="ap-voice-forge" data-ap-voice-clone="${escape(slug)}">
3243
+ <span class="ap-voice-forge-corner ap-voice-forge-corner-tl" aria-hidden="true"></span>
3244
+ <span class="ap-voice-forge-corner ap-voice-forge-corner-tr" aria-hidden="true"></span>
3245
+ <span class="ap-voice-forge-corner ap-voice-forge-corner-bl" aria-hidden="true"></span>
3246
+ <span class="ap-voice-forge-corner ap-voice-forge-corner-br" aria-hidden="true"></span>
3247
+ <span class="ap-voice-forge-scan" aria-hidden="true"></span>
3248
+ <span class="ap-voice-forge-kicker">${escape(uiT("voice_clone_btn_kicker"))}</span>
3249
+ <span class="ap-voice-forge-body">
3250
+ <span class="ap-voice-forge-rune" aria-hidden="true">
3251
+ <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>
3252
+ </span>
3253
+ <span class="ap-voice-forge-title">${escape(uiT("voice_clone_btn"))}</span>
3254
+ <span class="ap-voice-forge-arrow" aria-hidden="true">›</span>
3255
+ </span>
3256
+ <span class="ap-voice-forge-hint">${escape(uiT("voice_clone_btn_hint"))}</span>
3257
+ </button>
3258
+ </section>
3259
+ <section class="ap-voice-section">
3260
+ <details class="ap-voice-advanced">
3261
+ <summary>${escape(uiT("ap_voice_advanced"))}</summary>
3262
+ <div class="ap-voice-tune-grid">
3263
+ ${renderVoiceTuneRow(slug, "speed", uiT("ap_voice_speed"), speed, 0.5, 2, 0.1)}
3264
+ ${renderVoiceTuneRow(slug, "pitch", uiT("ap_voice_pitch"), pitch, -12, 12, 1)}
3265
+ ${renderVoiceTuneRow(slug, "modifyPitch", uiT("ap_voice_modify_pitch"), modPitch, -100, 100, 5)}
3266
+ ${renderVoiceTuneRow(slug, "modifyIntensity", uiT("ap_voice_modify_intensity"), modIntensity, -100, 100, 5)}
3267
+ ${renderVoiceTuneRow(slug, "modifyTimbre", uiT("ap_voice_modify_timbre"), modTimbre, -100, 100, 5)}
3268
+ </div>
3269
+ </details>
3270
+ </section>
3121
3271
  </div>
3122
3272
  `;
3123
3273
  }
@@ -3230,20 +3380,49 @@
3230
3380
  // popover. Same treatment as the empty-state error.
3231
3381
  const errBannerHtml = voicePickerErrorHtml(state.error);
3232
3382
  if (errBannerHtml) groups.push(errBannerHtml);
3383
+ // Two-level grouping · user-owned cloned voices get their own
3384
+ // header at the top of the dropdown ("// cloned · MiniMax"), then
3385
+ // the standard provider groups for system / premade voices. Cloned
3386
+ // voices on both providers carry a recognisable language tag —
3387
+ // MiniMax sets it to "clone" (we tag it), ElevenLabs uses v2's
3388
+ // `category` which is "cloned" / "professional" for user voices.
3389
+ const isClonedTag = (v) => v && (v.language === "clone" || v.language === "cloned" || v.language === "professional");
3233
3390
  let last = null;
3234
3391
  for (const v of voices) {
3235
3392
  const provider = String(v.provider || "browser");
3236
- if (provider !== last) {
3237
- groups.push(`<div class="ap-model-group">${escape(provider)}</div>`);
3238
- last = provider;
3393
+ const cloned = isClonedTag(v);
3394
+ // Group key reflects the section the row belongs to. Two
3395
+ // cloned rows from the same provider share a header; two
3396
+ // system rows do too.
3397
+ const groupKey = cloned ? `clone:${provider}` : `system:${provider}`;
3398
+ if (groupKey !== last) {
3399
+ const label = cloned
3400
+ ? uiT("ap_voice_group_cloned_provider", { provider })
3401
+ : provider;
3402
+ groups.push(`<div class="ap-model-group${cloned ? " ap-model-group-cloned" : ""}">${escape(label)}</div>`);
3403
+ last = groupKey;
3239
3404
  }
3240
3405
  const id = [provider, v.model || "", v.voiceId || ""].join("|");
3241
3406
  const active = current && current.provider === provider && current.model === v.model && current.voiceId === v.voiceId;
3407
+ // For cloned rows the `language: "clone"` hint is redundant
3408
+ // (the group header already says so); show just the model.
3409
+ const hintParts = [v.model || ""];
3410
+ if (!cloned && v.language) hintParts.push(v.language);
3411
+ // Inline rename button · only meaningful for provider-side
3412
+ // voices (cloned / system on minimax + elevenlabs). The browser
3413
+ // fallback row has no voice_id to label, so skip the chip there.
3414
+ const canRename = (provider === "minimax" || provider === "elevenlabs") && v.voiceId;
3415
+ const renameBtn = canRename
3416
+ ? `<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>`
3417
+ : "";
3242
3418
  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>
3419
+ <div class="ap-model-opt-row${cloned ? " is-cloned" : ""}">
3420
+ <button type="button" class="ap-model-opt${active ? " active" : ""}${cloned ? " ap-model-opt-cloned" : ""}" data-ap-voice-pick="${escape(id)}">
3421
+ <span class="ap-model-opt-label">${escape(resolveVoiceLabel(v) || uiT("ap_voice_fallback_voice"))}</span>
3422
+ <span class="ap-model-opt-hint">${escape(hintParts.filter(Boolean).join(" · "))}</span>
3423
+ </button>
3424
+ ${renameBtn}
3425
+ </div>
3247
3426
  `);
3248
3427
  }
3249
3428
  // Trailing sentinel · either a "loading more" pulse (when we're
@@ -3381,7 +3560,7 @@
3381
3560
  rows.forEach((row) => {
3382
3561
  const name = row.querySelector("[data-ap-voice-name]");
3383
3562
  const prov = row.querySelector("[data-ap-voice-provider]");
3384
- if (name && nv && nv.provider && nv.voiceId) name.textContent = `${nv.provider} · ${nv.voiceId}`;
3563
+ if (name && nv && nv.provider && nv.voiceId) name.textContent = `${nv.provider} · ${resolveVoiceLabel(nv)}`;
3385
3564
  if (prov && nv && nv.model != null) prov.textContent = nv.model;
3386
3565
  const emLb = row.querySelector("[data-ap-voice-emotion-label]");
3387
3566
  if (emLb && nv) emLb.textContent = voiceEmotionOptionLabel(nv.emotion ?? "");
@@ -3390,6 +3569,25 @@
3390
3569
  .catch((e) => alert(uiT("ap_voice_save_err", { msg: e && e.message ? e.message : String(e) })));
3391
3570
  }
3392
3571
 
3572
+ /** Per-slug custom preview text · persisted to localStorage so the
3573
+ * user's preferred sample line is remembered across renders /
3574
+ * reloads / agent-profile open & close. Falls back to the active
3575
+ * locale's `ap_voice_preview_sample` when empty. */
3576
+ function previewTextStorageKey(slug) {
3577
+ return `pb.voice-preview-text.${slug}`;
3578
+ }
3579
+ function loadPreviewText(slug) {
3580
+ try {
3581
+ return localStorage.getItem(previewTextStorageKey(slug)) || "";
3582
+ } catch { return ""; }
3583
+ }
3584
+ function savePreviewText(slug, value) {
3585
+ try {
3586
+ if (value && value.trim()) localStorage.setItem(previewTextStorageKey(slug), value);
3587
+ else localStorage.removeItem(previewTextStorageKey(slug));
3588
+ } catch { /* */ }
3589
+ }
3590
+
3393
3591
  async function previewVoice(slug) {
3394
3592
  const v = voiceForAgent(slug);
3395
3593
  if (!v || !v.voiceId) {
@@ -3403,16 +3601,17 @@
3403
3601
  // doesn't match the system's mono register.
3404
3602
  if (btn) { btn.disabled = true; btn.classList.add("is-loading"); }
3405
3603
  try {
3604
+ // User's custom preview text wins when set (saved per slug to
3605
+ // localStorage from the .ap-voice-preview-text textarea). When
3606
+ // empty we send the active locale's default — server otherwise
3607
+ // falls back to a hardcoded Chinese phrase, which surfaced in
3608
+ // EN/JA/ES locales as the wrong language being read.
3609
+ const customText = (loadPreviewText(slug) || "").trim();
3406
3610
  const r = await fetch("/api/voices/preview", {
3407
3611
  method: "POST",
3408
3612
  headers: { "content-type": "application/json" },
3409
3613
  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"),
3614
+ text: customText || uiT("ap_voice_preview_sample"),
3416
3615
  provider: v.provider,
3417
3616
  model: v.model,
3418
3617
  voiceId: v.voiceId,
@@ -5033,6 +5232,61 @@
5033
5232
  closeEmotionPicker();
5034
5233
  return;
5035
5234
  }
5235
+ // Inline rename · per-row ✎ button. Prompt for the new label,
5236
+ // PUT it to /api/voice-labels/:voiceId, drop both the server
5237
+ // catalogue cache (the route does that for us) and the client
5238
+ // pager state, then re-fetch + repaint the open picker. This
5239
+ // keeps the menu open during the rename so the user sees the
5240
+ // label flip in place.
5241
+ const labelBtn = e.target.closest("[data-ap-voice-label-edit]");
5242
+ if (labelBtn) {
5243
+ e.preventDefault();
5244
+ e.stopPropagation();
5245
+ const voiceId = labelBtn.getAttribute("data-voice-id") || "";
5246
+ const provider = labelBtn.getAttribute("data-provider") || "";
5247
+ const current = labelBtn.getAttribute("data-current-label") || "";
5248
+ if (!voiceId || !provider) return;
5249
+ const next = window.prompt(uiT("ap_voice_rename_prompt", { id: voiceId }), current);
5250
+ if (next === null) return; // cancelled
5251
+ const trimmed = String(next).trim();
5252
+ if (!trimmed) {
5253
+ // Empty input clears the custom label so the catalog name
5254
+ // wins again (or falls back to voice_id).
5255
+ if (!window.confirm(uiT("ap_voice_rename_clear_confirm"))) return;
5256
+ fetch(`/api/voice-labels/${encodeURIComponent(voiceId)}`, { method: "DELETE" })
5257
+ .then(() => {
5258
+ voiceLabelCache.delete(voiceId);
5259
+ refreshOpenVoicePicker();
5260
+ // Trigger labels for any director sitting on this voice
5261
+ // need to re-resolve · their previous "friendly" name
5262
+ // just disappeared.
5263
+ document.querySelectorAll(`[data-ap-voice-row]`).forEach((row) => {
5264
+ const s = row.getAttribute("data-slug");
5265
+ if (s) repaintTriggerLabel(s);
5266
+ });
5267
+ })
5268
+ .catch(() => { /* */ });
5269
+ return;
5270
+ }
5271
+ fetch(`/api/voice-labels/${encodeURIComponent(voiceId)}`, {
5272
+ method: "PUT",
5273
+ headers: { "content-type": "application/json" },
5274
+ body: JSON.stringify({ provider, label: trimmed }),
5275
+ })
5276
+ .then((r) => r.ok ? r.json() : r.json().then((j) => Promise.reject(new Error(j.error || `HTTP ${r.status}`))))
5277
+ .then(() => {
5278
+ voiceLabelCache.set(voiceId, trimmed);
5279
+ refreshOpenVoicePicker();
5280
+ // Repaint every trigger that currently displays this
5281
+ // voice so the rename lands immediately.
5282
+ document.querySelectorAll(`[data-ap-voice-row]`).forEach((row) => {
5283
+ const s = row.getAttribute("data-slug");
5284
+ if (s) repaintTriggerLabel(s);
5285
+ });
5286
+ })
5287
+ .catch((err) => alert(uiT("ap_voice_rename_err", { msg: err?.message || String(err) })));
5288
+ return;
5289
+ }
5036
5290
  const voiceOpt = e.target.closest("[data-ap-voice-pick]");
5037
5291
  if (voiceOpt) {
5038
5292
  e.preventDefault();
@@ -5054,6 +5308,113 @@
5054
5308
  if (slug) previewVoice(slug);
5055
5309
  return;
5056
5310
  }
5311
+ // Preview text textarea · persist on blur so we don't hammer
5312
+ // localStorage on every keystroke; the input listener below
5313
+ // syncs the in-memory STATE for the next previewVoice call.
5314
+ // Voice cloning · open the boardroomVoiceClone overlay. The
5315
+ // singleton lives in `public/voice-clone.js`; the `onApplied`
5316
+ // callback re-renders this voice block so the picker label
5317
+ // updates to the new voice_id without a full profile reload.
5318
+ const cloneBtn = e.target.closest("[data-ap-voice-clone]");
5319
+ if (cloneBtn) {
5320
+ e.preventDefault();
5321
+ const slug = cloneBtn.getAttribute("data-ap-voice-clone");
5322
+ if (!slug) return;
5323
+ const vc = window.boardroomVoiceClone;
5324
+ if (!vc || typeof vc.open !== "function") return;
5325
+ const agent = (window.app && window.app.agentsById && window.app.agentsById[slug]) || null;
5326
+ vc.open({
5327
+ agentId: slug,
5328
+ agentName: agent ? agent.name : "",
5329
+ onApplied: async (applied) => {
5330
+ // `applied` = { voiceId, label, provider } from voice-clone.js.
5331
+ // Four steps land the user squarely on the new voice:
5332
+ // 1. Sync the client-side agent cache (`window.app.
5333
+ // agentsById[slug].voice`) so the picker trigger
5334
+ // renders the new selection.
5335
+ // 2. Drop the pager cache + re-fetch /api/voices · the
5336
+ // server has just dropped its catalogue cache, so a
5337
+ // fresh fetch may pick up the new voice straight from
5338
+ // the provider (5-30 s propagation can still mean the
5339
+ // catalog doesn't carry it yet — step 3 fills the gap).
5340
+ // 3. Inject the new voice into the pager state if the
5341
+ // fresh fetch didn't bring it back. This is the
5342
+ // optimistic safety net for the propagation gap. The
5343
+ // model field MUST match the model the catalog will
5344
+ // return on the next refresh — otherwise dedup misses
5345
+ // and the row appears twice. We hard-code the
5346
+ // cloning-model per provider (same as the server
5347
+ // worker writes into agent.voice.model).
5348
+ // 4. Re-render the voice block so the trigger label
5349
+ // reflects the new voice immediately.
5350
+ try {
5351
+ const provider = (applied && applied.provider) || "minimax";
5352
+ const model = provider === "elevenlabs" ? "eleven_multilingual_v2" : "speech-2.8-hd";
5353
+ const voiceId = applied && applied.voiceId;
5354
+ const label = (applied && applied.label) || "";
5355
+
5356
+ // Step 1 · live agent cache.
5357
+ const liveAgent = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
5358
+ if (liveAgent && voiceId) {
5359
+ const prev = liveAgent.voice || {};
5360
+ liveAgent.voice = {
5361
+ speed: prev.speed,
5362
+ pitch: prev.pitch,
5363
+ volume: prev.volume,
5364
+ emotion: prev.emotion,
5365
+ provider,
5366
+ model,
5367
+ voiceId,
5368
+ };
5369
+ }
5370
+ // Step 1b · local label cache · keeps the friendly name
5371
+ // available across a page reload BEFORE `/api/voice-labels`
5372
+ // prefetch round-trips on the next boot.
5373
+ if (voiceId && label) {
5374
+ voiceLabelCache.set(voiceId, label);
5375
+ }
5376
+
5377
+ // Step 2 · drop pager cache + force re-fetch. Without
5378
+ // this, any later `invalidateVoicePager` triggered by
5379
+ // settings tweaks would strand a stale optimistic-only
5380
+ // row (cleared state has no voices for the new id).
5381
+ invalidateVoicePager();
5382
+ try { await fetchNextVoicePage(30); } catch { /* */ }
5383
+
5384
+ // Step 3 · inject if the catalog refetch didn't include it.
5385
+ const state = getVoicePagerState();
5386
+ if (voiceId && state) {
5387
+ const id = `${provider}|${model}|${voiceId}`;
5388
+ const dupeIdx = (state.voices || []).findIndex((x) => `${x.provider}|${x.model || ""}|${x.voiceId || ""}` === id);
5389
+ if (dupeIdx < 0) {
5390
+ state.voices.unshift({
5391
+ provider,
5392
+ model,
5393
+ voiceId,
5394
+ label: label || voiceId,
5395
+ language: "clone",
5396
+ configured: true,
5397
+ });
5398
+ } else if (label) {
5399
+ if (!state.voices[dupeIdx].label || state.voices[dupeIdx].label === voiceId) {
5400
+ state.voices[dupeIdx] = { ...state.voices[dupeIdx], label, language: "clone" };
5401
+ }
5402
+ }
5403
+ }
5404
+
5405
+ // 3 · re-render the voice block (trigger label now correct).
5406
+ const row = document.querySelector(`.ap-voice-config[data-slug="${slug}"], .ap-voice-locked[data-slug="${slug}"]`);
5407
+ if (row && typeof renderVoiceBlock === "function") {
5408
+ const wrap = document.createElement("div");
5409
+ wrap.innerHTML = renderVoiceBlock(slug);
5410
+ const fresh = wrap.firstElementChild;
5411
+ if (fresh && row.parentNode) row.parentNode.replaceChild(fresh, row);
5412
+ }
5413
+ } catch { /* */ }
5414
+ },
5415
+ });
5416
+ return;
5417
+ }
5057
5418
  });
5058
5419
  document.addEventListener("click", (e) => {
5059
5420
  const pop = document.getElementById("ap-model-picker");
@@ -5094,6 +5455,16 @@
5094
5455
  range.style.setProperty("--fill-hi", hi);
5095
5456
  return;
5096
5457
  }
5458
+ // Custom preview text · persist per-slug to localStorage so the
5459
+ // next previewVoice call (or the next agent-profile open) picks
5460
+ // it up. Save on every keystroke; the cost is one tiny write
5461
+ // and the user never wonders "did my edit stick?".
5462
+ const previewText = e.target.closest("[data-ap-voice-preview-text]");
5463
+ if (previewText) {
5464
+ const slug = previewText.getAttribute("data-ap-voice-preview-text");
5465
+ if (slug) savePreviewText(slug, previewText.value);
5466
+ return;
5467
+ }
5097
5468
  });
5098
5469
  document.addEventListener("change", (e) => {
5099
5470
  const range = e.target.closest("[data-ap-voice-range]");
@@ -170,7 +170,7 @@
170
170
 
171
171
  .upd-deck {
172
172
  font-family: var(--font-human, system-ui, sans-serif);
173
- font-size: 13px;
173
+ font-size: 14px;
174
174
  line-height: 1.55;
175
175
  color: var(--text-soft);
176
176
  margin: 0;