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.
- package/dist/boot.js +1415 -91
- package/dist/boot.js.map +1 -1
- package/dist/cli.js +1415 -91
- package/dist/cli.js.map +1 -1
- package/dist/server.js +1271 -81
- 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 +1 -1
- package/public/__avatar3d_test.html +156 -0
- 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 +331 -41
- package/public/agent-profile.js +499 -75
- package/public/app-updater.css +1 -1
- package/public/app.js +2090 -547
- package/public/avatar-3d-snap.js +205 -0
- package/public/avatar-3d.js +792 -0
- package/public/avatar-customizer.html +274 -0
- package/public/avatar3d-editor.css +240 -0
- package/public/avatar3d-editor.js +481 -0
- package/public/avatars/3d/chair.png +0 -0
- package/public/avatars/3d/first-principles.png +0 -0
- package/public/avatars/3d/historian.png +0 -0
- package/public/avatars/3d/long-horizon.png +0 -0
- package/public/avatars/3d/phenomenologist.png +0 -0
- package/public/avatars/3d/socrates.png +0 -0
- package/public/avatars/3d/user-empathy.png +0 -0
- package/public/avatars/3d/value-investor.png +0 -0
- package/public/core-avatars.js +86 -0
- package/public/home-3d-loader.js +15 -4
- package/public/home-3d-mock.js +18 -7
- package/public/home.html +80 -18
- package/public/i18n.js +279 -4
- package/public/icons/avatar_1779855104027.glb +0 -0
- package/public/icons/logo.png +0 -0
- package/public/icons/new-style.glb +0 -0
- package/public/icons/new-style2.glb +0 -0
- package/public/icons/new-style3.glb +0 -0
- package/public/icons/new-style4.glb +0 -0
- package/public/icons/new-style5.glb +0 -0
- package/public/icons/office.glb +0 -0
- package/public/icons/stuff.glb +0 -0
- package/public/index.html +203 -182
- package/public/mention-picker.js +1 -1
- package/public/new-agent.css +7 -7
- package/public/new-agent.js +46 -20
- package/public/office-viewer.html +340 -0
- package/public/onboarding.css +5 -5
- package/public/quote-cta.css +5 -4
- package/public/quote-cta.js +50 -5
- package/public/room-settings.css +24 -9
- package/public/stuff-viewer.html +330 -0
- package/public/thread.css +1211 -0
- package/public/user-settings.css +16 -19
- package/public/user-settings.js +86 -78
- package/public/vendor/BufferGeometryUtils.js +1434 -0
- package/public/vendor/DRACOLoader.js +739 -0
- package/public/vendor/GLTFLoader.js +4860 -0
- package/public/vendor/RoomEnvironment.js +185 -0
- package/public/vendor/SkeletonUtils.js +496 -0
- package/public/vendor/draco/draco_decoder.js +34 -0
- package/public/vendor/draco/draco_decoder.wasm +0 -0
- package/public/vendor/draco/draco_encoder.js +33 -0
- package/public/vendor/draco/draco_wasm_wrapper.js +117 -0
- package/public/vendor/meshopt_decoder.module.js +196 -0
- package/public/voice-3d-banner.js +12 -0
- package/public/voice-3d.js +1407 -432
- package/public/voice-clone.css +875 -0
- package/public/voice-clone.js +1351 -0
- package/public/voice-replay.css +3 -3
- package/public/voice-replay.js +21 -0
- package/public/avatar-skill.js +0 -629
- package/public/icons/folded-sidebar.png +0 -0
package/public/agent-profile.js
CHANGED
|
@@ -760,39 +760,81 @@
|
|
|
760
760
|
return tag.toUpperCase().slice(0, 8);
|
|
761
761
|
}
|
|
762
762
|
|
|
763
|
-
/* Per-agent rules
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
|
|
774
|
-
|
|
815
|
+
try { localStorage.removeItem(_legacyRulesKey(slug)); } catch (e) { /* */ }
|
|
816
|
+
}).catch(() => { /* offline · working copy keeps the edit */ });
|
|
775
817
|
}
|
|
776
|
-
function
|
|
777
|
-
|
|
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 =
|
|
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 =
|
|
828
|
+
const rules = seedRules(slug);
|
|
787
829
|
if (idx < 0 || idx >= rules.length) return;
|
|
788
830
|
rules[idx] = body;
|
|
789
|
-
|
|
831
|
+
persistRulesSoon(slug);
|
|
790
832
|
}
|
|
791
833
|
function removeRuleFor(slug, idx) {
|
|
792
|
-
const rules =
|
|
834
|
+
const rules = seedRules(slug);
|
|
793
835
|
if (idx < 0 || idx >= rules.length) return;
|
|
794
836
|
rules.splice(idx, 1);
|
|
795
|
-
|
|
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).
|
|
1127
|
-
*
|
|
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
|
-
/**
|
|
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.
|
|
2446
|
-
*
|
|
2447
|
-
*
|
|
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
|
|
2450
|
-
if (!
|
|
2451
|
-
const seed =
|
|
2452
|
-
const
|
|
2453
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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)}
|
|
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
|
-
</
|
|
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
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
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
|
-
<
|
|
3244
|
-
<
|
|
3245
|
-
|
|
3246
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
4855
|
-
//
|
|
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]");
|