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.
- package/dist/boot.js +1142 -50
- package/dist/boot.js.map +1 -1
- package/dist/cli.js +1142 -50
- package/dist/cli.js.map +1 -1
- package/dist/server.js +1121 -50
- package/dist/server.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +3 -2
- package/public/adjourn-overlay.css +2 -2
- package/public/agent-overlay.css +27 -15
- package/public/agent-overlay.js +3 -1
- package/public/agent-profile.css +328 -32
- package/public/agent-profile.js +414 -43
- package/public/app-updater.css +1 -1
- package/public/app.js +1807 -35
- package/public/avatars/chair-blink.svg +1 -0
- package/public/home-3d-loader.js +6 -0
- package/public/home.html +3 -3
- package/public/i18n.js +279 -0
- package/public/icons/folded-sidebar.png +0 -0
- package/public/index.html +410 -147
- package/public/mention-picker.js +1 -1
- package/public/new-agent.css +7 -7
- package/public/onboarding.css +5 -5
- package/public/quote-cta.css +5 -4
- package/public/quote-cta.js +50 -5
- package/public/report.html +27 -7
- package/public/room-settings.css +24 -9
- package/public/thread.css +1211 -0
- package/public/user-settings.css +6 -6
- package/public/user-settings.js +37 -20
- package/public/voice-3d.js +167 -3
- package/public/voice-clone.css +875 -0
- package/public/voice-clone.js +1351 -0
- package/public/voice-replay.css +3 -3
- package/public/icons/search.png +0 -0
package/public/agent-profile.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
3094
|
-
<
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
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
|
-
<
|
|
3244
|
-
<
|
|
3245
|
-
|
|
3246
|
-
|
|
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
|
|
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
|
-
|
|
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]");
|