privateboard 0.1.32 → 0.1.37
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 +482 -208
- package/dist/boot.js.map +1 -1
- package/dist/cli.js +482 -208
- package/dist/cli.js.map +1 -1
- package/dist/server.js +482 -208
- 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 +8 -0
- package/public/agent-profile.css +46 -0
- package/public/agent-profile.js +247 -48
- package/public/app.js +799 -717
- package/public/avatars/chair-blink.svg +1 -0
- package/public/home-3d-loader.js +6 -0
- package/public/home.html +1 -1
- package/public/i18n.js +122 -0
- package/public/icons/folded-sidebar.png +0 -0
- package/public/index.html +898 -990
- package/public/report.html +27 -7
- package/public/room-settings.css +18 -0
- package/public/themes.css +11 -0
- package/public/user-settings.js +37 -20
- package/public/voice-3d-banner.js +110 -36
- package/public/voice-3d.js +206 -6
- package/public/icons/share.png +0 -0
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
package/dist/version.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/version.ts"],"sourcesContent":["/**\n * Single source of truth for the app version.\n *\n * Imported by `cli.ts` (CLI banner / `--version`), `server.ts` (the\n * `/health` payload + the `/api/version` endpoint), and bundled into\n * the frontend via the version endpoint. Bump alongside `package.json`\n * on every release — the existing `npm version <patch|minor|major>`\n * + commit pattern updates package.json automatically; this file\n * needs the matching manual bump.\n *\n * If two strings drift (bumped one but not the other), the wrong\n * number ends up surfaced in the user-facing footer or banner. Keep\n * this file as the canonical source — every callsite reads from here.\n */\nexport const VERSION = \"0.1.
|
|
1
|
+
{"version":3,"sources":["../src/version.ts"],"sourcesContent":["/**\n * Single source of truth for the app version.\n *\n * Imported by `cli.ts` (CLI banner / `--version`), `server.ts` (the\n * `/health` payload + the `/api/version` endpoint), and bundled into\n * the frontend via the version endpoint. Bump alongside `package.json`\n * on every release — the existing `npm version <patch|minor|major>`\n * + commit pattern updates package.json automatically; this file\n * needs the matching manual bump.\n *\n * If two strings drift (bumped one but not the other), the wrong\n * number ends up surfaced in the user-facing footer or banner. Keep\n * this file as the canonical source — every callsite reads from here.\n */\nexport const VERSION = \"0.1.37\";\n"],"mappings":";;;AAcO,IAAM,UAAU;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateboard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.37",
|
|
4
4
|
"description": "PrivateBoard · your private board meeting, on call. Local-first, multi-agent thinking amplifier.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "electron-entry.cjs",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"electron:build-ts": "tsup && tsc -p electron/tsconfig.json",
|
|
31
31
|
"electron:build-icon": "node scripts/build-app-icon.mjs",
|
|
32
32
|
"electron:dev": "npm run electron:rebuild && npm run electron:build-ts && electron .",
|
|
33
|
-
"electron:dist": "electron-builder install-app-deps && npm run electron:build-ts && npm run electron:build-icon && electron-builder --mac --publish always",
|
|
33
|
+
"electron:dist": "electron-builder install-app-deps && npm run electron:build-ts && npm run electron:build-icon && electron-builder --mac --publish always && npm run release:mac:finalize",
|
|
34
|
+
"release:mac:finalize": "node scripts/finalize-mac-release.mjs",
|
|
34
35
|
"electron:dist:local": "electron-builder install-app-deps && npm run electron:build-ts && npm run electron:build-icon && electron-builder --mac --publish never",
|
|
35
36
|
"electron:dist:unsigned": "CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder install-app-deps && npm run electron:build-ts && npm run electron:build-icon && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --mac --publish never --config.mac.hardenedRuntime=false --config.mac.notarize=false"
|
|
36
37
|
},
|
|
@@ -569,6 +569,14 @@
|
|
|
569
569
|
accent-color: var(--lime);
|
|
570
570
|
}
|
|
571
571
|
|
|
572
|
+
/* Custom dropdown popover (spine / house-style) · `_openRenderDropdown`
|
|
573
|
+
appends it to <body> with class `ap-model-picker adjourn-render-pop`.
|
|
574
|
+
`.ap-model-picker` defaults to z-index 9100 (agent-profile chrome),
|
|
575
|
+
but the adjourn / supplement modals sit at 9400 / 9500 — without
|
|
576
|
+
this lift, the popover renders BEHIND the modal and the user sees
|
|
577
|
+
the trigger open but no menu content. Pinned above both modals. */
|
|
578
|
+
.ap-model-picker.adjourn-render-pop { z-index: 9900; }
|
|
579
|
+
|
|
572
580
|
/* Narrow viewport · summary rows tighten. The modal width itself
|
|
573
581
|
already collapses gracefully below ~592px via the
|
|
574
582
|
`min(560px, calc(100vw - 32px))` rule on .adjourn-modal. */
|
package/public/agent-profile.css
CHANGED
|
@@ -2507,6 +2507,52 @@
|
|
|
2507
2507
|
.ap-model-picker-loading .ap-loading-dots i:nth-child(2) { animation-delay: 0.15s; }
|
|
2508
2508
|
.ap-model-picker-loading .ap-loading-dots i:nth-child(3) { animation-delay: 0.30s; }
|
|
2509
2509
|
|
|
2510
|
+
/* Upstream-error banner inside the voice picker · fires when the
|
|
2511
|
+
catalogue fetch failed in an actionable way (missing scope on
|
|
2512
|
+
the API key, auth rejected, etc.). Sits at the top of the
|
|
2513
|
+
popover above any voice rows; the `fixUrl` CTA opens the
|
|
2514
|
+
provider's API key settings page in a new tab. */
|
|
2515
|
+
.ap-voice-picker-err {
|
|
2516
|
+
padding: 14px 14px 12px;
|
|
2517
|
+
border-bottom: 1px solid var(--line-bright);
|
|
2518
|
+
font-family: var(--sans);
|
|
2519
|
+
display: flex;
|
|
2520
|
+
flex-direction: column;
|
|
2521
|
+
gap: 6px;
|
|
2522
|
+
}
|
|
2523
|
+
.ap-voice-picker-err-title {
|
|
2524
|
+
font-size: 12px;
|
|
2525
|
+
font-weight: 600;
|
|
2526
|
+
color: var(--text);
|
|
2527
|
+
letter-spacing: -0.005em;
|
|
2528
|
+
}
|
|
2529
|
+
.ap-voice-picker-err-body {
|
|
2530
|
+
font-size: 11px;
|
|
2531
|
+
color: var(--text-soft);
|
|
2532
|
+
line-height: 1.45;
|
|
2533
|
+
}
|
|
2534
|
+
.ap-voice-picker-err-upstream {
|
|
2535
|
+
font-family: var(--mono);
|
|
2536
|
+
font-size: 10px;
|
|
2537
|
+
color: var(--text-dim);
|
|
2538
|
+
letter-spacing: 0;
|
|
2539
|
+
line-height: 1.4;
|
|
2540
|
+
margin-top: 2px;
|
|
2541
|
+
/* Long upstream messages (200 char cap) wrap rather than overflow. */
|
|
2542
|
+
word-break: break-word;
|
|
2543
|
+
}
|
|
2544
|
+
.ap-voice-picker-err-cta {
|
|
2545
|
+
margin-top: 4px;
|
|
2546
|
+
font-family: var(--mono);
|
|
2547
|
+
font-size: 11px;
|
|
2548
|
+
text-transform: uppercase;
|
|
2549
|
+
letter-spacing: 0.10em;
|
|
2550
|
+
color: var(--lime);
|
|
2551
|
+
text-decoration: none;
|
|
2552
|
+
align-self: flex-start;
|
|
2553
|
+
}
|
|
2554
|
+
.ap-voice-picker-err-cta:hover { color: var(--text); }
|
|
2555
|
+
|
|
2510
2556
|
/* Provider section header · matches .cmp-dd-group · mono / faint /
|
|
2511
2557
|
uppercase, hairline divider above (suppressed on the first one). */
|
|
2512
2558
|
.ap-model-group {
|
package/public/agent-profile.js
CHANGED
|
@@ -2895,17 +2895,109 @@
|
|
|
2895
2895
|
/** API emotion slugs mirrored in PATCH body `voice.emotion`. */
|
|
2896
2896
|
const VOICE_EMOTION_VALUES = ["", "happy", "sad", "angry", "fearful", "disgusted", "surprised", "calm", "fluent"];
|
|
2897
2897
|
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2898
|
+
/** Voice-picker pager state · drives infinite-scroll loading inside
|
|
2899
|
+
* the dropdown. ElevenLabs accounts can carry hundreds of voices
|
|
2900
|
+
* and MiniMax has dozens; rendering them all at once was visibly
|
|
2901
|
+
* slow on first picker open. Now we fetch one page at a time and
|
|
2902
|
+
* append on scroll-to-bottom. State persists across picker
|
|
2903
|
+
* opens/closes within a session · `invalidateVoicePager()` resets
|
|
2904
|
+
* it on key changes (called from `refreshAgentProfileSkills`). */
|
|
2905
|
+
let voicePagerState = null;
|
|
2906
|
+
function invalidateVoicePager() {
|
|
2907
|
+
voicePagerState = null;
|
|
2908
|
+
// Drop the in-flight reference too · the old fetch is still running
|
|
2909
|
+
// but it'll write into the (now orphaned) old state object and the
|
|
2910
|
+
// result is silently discarded. Clearing the reference lets the
|
|
2911
|
+
// next caller start a fresh fetch against the new state instead of
|
|
2912
|
+
// awaiting a promise that will populate the wrong object.
|
|
2913
|
+
voicePageInFlight = null;
|
|
2914
|
+
}
|
|
2915
|
+
function getVoicePagerState() {
|
|
2916
|
+
if (!voicePagerState) {
|
|
2917
|
+
voicePagerState = {
|
|
2918
|
+
voices: [],
|
|
2919
|
+
cursor: null,
|
|
2920
|
+
hasMore: true,
|
|
2921
|
+
loading: false,
|
|
2922
|
+
initialised: false,
|
|
2923
|
+
provider: null,
|
|
2924
|
+
configured: false,
|
|
2925
|
+
// Structured upstream error from the catalogue fetch · null
|
|
2926
|
+
// on success / before first call. When present, the picker
|
|
2927
|
+
// renders a banner with the title/body keys and an optional
|
|
2928
|
+
// CTA link instead of (or above) the voice rows.
|
|
2929
|
+
error: null,
|
|
2930
|
+
};
|
|
2907
2931
|
}
|
|
2908
|
-
return
|
|
2932
|
+
return voicePagerState;
|
|
2933
|
+
}
|
|
2934
|
+
/** Fetch the next page of voices and append to the pager. Idempotent
|
|
2935
|
+
* · concurrent callers SHARE the in-flight promise so the second
|
|
2936
|
+
* call resolves with the same result instead of short-circuiting
|
|
2937
|
+
* to `false`. That sharing was the missing piece: previously the
|
|
2938
|
+
* prefetch fired by `renderVoiceBlock` could be in-flight when the
|
|
2939
|
+
* user clicked the picker; the second `fetchNextVoicePage` saw
|
|
2940
|
+
* `state.loading` and returned immediately, so the picker rendered
|
|
2941
|
+
* an empty list with a loading sentinel and never refreshed once
|
|
2942
|
+
* the prefetch landed. Returns true when new voices were appended. */
|
|
2943
|
+
let voicePageInFlight = null;
|
|
2944
|
+
async function fetchNextVoicePage(pageSize) {
|
|
2945
|
+
if (voicePageInFlight) return voicePageInFlight;
|
|
2946
|
+
const state = getVoicePagerState();
|
|
2947
|
+
if (state.initialised && !state.hasMore) return false;
|
|
2948
|
+
state.loading = true;
|
|
2949
|
+
voicePageInFlight = (async () => {
|
|
2950
|
+
try {
|
|
2951
|
+
const url = new URL("/api/voices", window.location.origin);
|
|
2952
|
+
url.searchParams.set("pageSize", String(pageSize || 30));
|
|
2953
|
+
if (state.cursor) url.searchParams.set("cursor", state.cursor);
|
|
2954
|
+
const r = await fetch(url.toString());
|
|
2955
|
+
const j = r.ok ? await r.json() : {};
|
|
2956
|
+
const newVoices = Array.isArray(j.voices) ? j.voices : [];
|
|
2957
|
+
// De-dupe by (provider | model | voiceId) in case the cursor
|
|
2958
|
+
// round-trip races with a key swap that triggered a reset
|
|
2959
|
+
// mid-fetch. Without this a re-emitted first page would land
|
|
2960
|
+
// on top of an already-rendered first page.
|
|
2961
|
+
const seen = new Set(
|
|
2962
|
+
state.voices.map((v) => `${v.provider}|${v.model || ""}|${v.voiceId || ""}`),
|
|
2963
|
+
);
|
|
2964
|
+
for (const v of newVoices) {
|
|
2965
|
+
const id = `${v.provider}|${v.model || ""}|${v.voiceId || ""}`;
|
|
2966
|
+
if (!seen.has(id)) {
|
|
2967
|
+
state.voices.push(v);
|
|
2968
|
+
seen.add(id);
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
state.cursor = typeof j.nextCursor === "string" ? j.nextCursor : null;
|
|
2972
|
+
state.hasMore = !!j.hasMore;
|
|
2973
|
+
state.provider = typeof j.provider === "string" ? j.provider : null;
|
|
2974
|
+
state.configured = !!j.configured;
|
|
2975
|
+
// Structured upstream error · forwarded as-is from the server.
|
|
2976
|
+
// null / undefined means the fetch succeeded (even if 0 voices
|
|
2977
|
+
// returned · empty + no error is a real "you have no voices"
|
|
2978
|
+
// state, distinct from "fetch failed").
|
|
2979
|
+
state.error = (j.error && typeof j.error === "object") ? j.error : null;
|
|
2980
|
+
state.initialised = true;
|
|
2981
|
+
return newVoices.length > 0;
|
|
2982
|
+
} catch {
|
|
2983
|
+
state.hasMore = false;
|
|
2984
|
+
state.initialised = true;
|
|
2985
|
+
return false;
|
|
2986
|
+
} finally {
|
|
2987
|
+
state.loading = false;
|
|
2988
|
+
voicePageInFlight = null;
|
|
2989
|
+
}
|
|
2990
|
+
})();
|
|
2991
|
+
return voicePageInFlight;
|
|
2992
|
+
}
|
|
2993
|
+
/** Convenience · matches the old `ensureVoiceOptions()` shape so
|
|
2994
|
+
* the renderVoiceBlock prefetch can stay a single-line call. Just
|
|
2995
|
+
* primes the first page. */
|
|
2996
|
+
async function ensureVoiceOptions() {
|
|
2997
|
+
const state = getVoicePagerState();
|
|
2998
|
+
if (state.initialised) return state.voices;
|
|
2999
|
+
await fetchNextVoicePage(30);
|
|
3000
|
+
return state.voices;
|
|
2909
3001
|
}
|
|
2910
3002
|
function voiceForAgent(slug) {
|
|
2911
3003
|
const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
|
|
@@ -3073,26 +3165,119 @@
|
|
|
3073
3165
|
}
|
|
3074
3166
|
}
|
|
3075
3167
|
|
|
3168
|
+
/** Build the upstream-error banner shown inside the voice picker
|
|
3169
|
+
* when the catalogue fetch failed in an actionable way (e.g. the
|
|
3170
|
+
* ElevenLabs API key is missing the `voices_read` scope). Returns
|
|
3171
|
+
* empty string when there's no error · callers can skip the
|
|
3172
|
+
* banner. Includes a CTA link to the provider's settings page
|
|
3173
|
+
* when the structured error carries a `fixUrl`. */
|
|
3174
|
+
function voicePickerErrorHtml(error) {
|
|
3175
|
+
if (!error || typeof error !== "object" || typeof error.code !== "string") return "";
|
|
3176
|
+
// i18n keys per error code · falls back to the generic
|
|
3177
|
+
// fetch-failed copy when an unknown code lands so a future
|
|
3178
|
+
// server-side addition still renders something sensible.
|
|
3179
|
+
const titleKey = `ap_voice_err_${error.code}_title`;
|
|
3180
|
+
const bodyKey = `ap_voice_err_${error.code}_body`;
|
|
3181
|
+
const title = uiT(titleKey) === titleKey ? uiT("ap_voice_err_fetch_failed_title") : uiT(titleKey);
|
|
3182
|
+
const body = uiT(bodyKey) === bodyKey ? uiT("ap_voice_err_fetch_failed_body") : uiT(bodyKey);
|
|
3183
|
+
const upstream = typeof error.message === "string" && error.message.trim()
|
|
3184
|
+
? error.message.trim().slice(0, 200)
|
|
3185
|
+
: "";
|
|
3186
|
+
const cta = typeof error.fixUrl === "string" && error.fixUrl.startsWith("https://")
|
|
3187
|
+
? `<a href="${escape(error.fixUrl)}" target="_blank" rel="noopener" class="ap-voice-picker-err-cta">${escape(uiT("ap_voice_err_fix_cta"))}</a>`
|
|
3188
|
+
: "";
|
|
3189
|
+
return `
|
|
3190
|
+
<div class="ap-voice-picker-err" role="alert">
|
|
3191
|
+
<div class="ap-voice-picker-err-title">${escape(title)}</div>
|
|
3192
|
+
<div class="ap-voice-picker-err-body">${escape(body)}</div>
|
|
3193
|
+
${upstream ? `<div class="ap-voice-picker-err-upstream">${escape(upstream)}</div>` : ""}
|
|
3194
|
+
${cta}
|
|
3195
|
+
</div>`;
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
/** Render the picker's body from the current pager state · used by
|
|
3199
|
+
* both the initial open and every infinite-scroll append. Idempotent
|
|
3200
|
+
* rebuild (full innerHTML rewrite) so we don't have to track which
|
|
3201
|
+
* rows we've already mounted; the popover is small enough that
|
|
3202
|
+
* repaint cost is negligible compared to a scroll-position-preserving
|
|
3203
|
+
* partial update. Returns the trailing sentinel element (loading
|
|
3204
|
+
* indicator or end marker) so the scroll handler can keep its
|
|
3205
|
+
* reference stable across repaints. */
|
|
3206
|
+
function renderVoicePickerBody(pop, slug) {
|
|
3207
|
+
const state = getVoicePagerState();
|
|
3208
|
+
const current = voiceForAgent(slug);
|
|
3209
|
+
const voices = state.voices;
|
|
3210
|
+
|
|
3211
|
+
if (voices.length === 0 && state.initialised) {
|
|
3212
|
+
// Structured upstream error · most common case is the
|
|
3213
|
+
// ElevenLabs `voices_read` permission being missing on the API
|
|
3214
|
+
// key. Render a clear banner with the fix CTA instead of the
|
|
3215
|
+
// generic "no provider configured" fallback so the user knows
|
|
3216
|
+
// exactly what to do.
|
|
3217
|
+
const errHtml = voicePickerErrorHtml(state.error);
|
|
3218
|
+
if (errHtml) {
|
|
3219
|
+
pop.innerHTML = errHtml;
|
|
3220
|
+
return;
|
|
3221
|
+
}
|
|
3222
|
+
pop.innerHTML = `<div class="ap-model-group">${escape(uiT("ap_voice_no_provider"))}</div>`;
|
|
3223
|
+
return;
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
const groups = [];
|
|
3227
|
+
// Error banner above the voice rows · fires when the catalogue
|
|
3228
|
+
// fetch errored but the picker still has at least one fallback
|
|
3229
|
+
// voice (e.g. browser default) so we don't take over the whole
|
|
3230
|
+
// popover. Same treatment as the empty-state error.
|
|
3231
|
+
const errBannerHtml = voicePickerErrorHtml(state.error);
|
|
3232
|
+
if (errBannerHtml) groups.push(errBannerHtml);
|
|
3233
|
+
let last = null;
|
|
3234
|
+
for (const v of voices) {
|
|
3235
|
+
const provider = String(v.provider || "browser");
|
|
3236
|
+
if (provider !== last) {
|
|
3237
|
+
groups.push(`<div class="ap-model-group">${escape(provider)}</div>`);
|
|
3238
|
+
last = provider;
|
|
3239
|
+
}
|
|
3240
|
+
const id = [provider, v.model || "", v.voiceId || ""].join("|");
|
|
3241
|
+
const active = current && current.provider === provider && current.model === v.model && current.voiceId === v.voiceId;
|
|
3242
|
+
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>
|
|
3247
|
+
`);
|
|
3248
|
+
}
|
|
3249
|
+
// Trailing sentinel · either a "loading more" pulse (when we're
|
|
3250
|
+
// mid-fetch or there's known-more to load and the user just
|
|
3251
|
+
// scrolled into range) or nothing when the catalogue is fully
|
|
3252
|
+
// loaded. The scroll handler reads `data-voice-pager-sentinel`
|
|
3253
|
+
// to decide whether to trigger another fetch.
|
|
3254
|
+
if (state.hasMore) {
|
|
3255
|
+
groups.push(`
|
|
3256
|
+
<div class="ap-model-picker-loading" data-voice-pager-sentinel="loading">
|
|
3257
|
+
<span class="ap-loading-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
|
3258
|
+
<span>${escape(uiT("ap_voice_loading"))}</span>
|
|
3259
|
+
</div>`);
|
|
3260
|
+
}
|
|
3261
|
+
pop.innerHTML = groups.join("");
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3076
3264
|
async function openVoicePicker(triggerEl) {
|
|
3077
3265
|
closeVoicePicker();
|
|
3078
3266
|
closeEmotionPicker();
|
|
3079
3267
|
const row = triggerEl.closest("[data-ap-voice-row]");
|
|
3080
3268
|
const slug = row?.getAttribute("data-slug");
|
|
3081
3269
|
if (!slug) return;
|
|
3082
|
-
const current = voiceForAgent(slug);
|
|
3083
3270
|
|
|
3084
3271
|
// Mount the popover shell IMMEDIATELY · the user gets visual
|
|
3085
|
-
// confirmation of their click in the same frame. If the
|
|
3086
|
-
//
|
|
3087
|
-
// round-trips; once it lands we
|
|
3088
|
-
// a cold
|
|
3272
|
+
// confirmation of their click in the same frame. If the pager is
|
|
3273
|
+
// cold, the skeleton holds the place while /api/voices?pageSize=N
|
|
3274
|
+
// round-trips; once it lands we render the rows in. Without this,
|
|
3275
|
+
// a cold pager produced "click → nothing → eventually picker"
|
|
3089
3276
|
// which felt unresponsive on the first open of every session.
|
|
3090
3277
|
const pop = document.createElement("div");
|
|
3091
3278
|
pop.id = "ap-voice-picker";
|
|
3092
3279
|
pop.className = "ap-model-picker";
|
|
3093
3280
|
pop.dataset.slug = slug;
|
|
3094
|
-
// Loading row · animated dot trio + label. Visible feedback so
|
|
3095
|
-
// users don't read a static "loading" line as a frozen popover.
|
|
3096
3281
|
pop.innerHTML = `
|
|
3097
3282
|
<div class="ap-model-picker-loading">
|
|
3098
3283
|
<span class="ap-loading-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
|
@@ -3101,33 +3286,41 @@
|
|
|
3101
3286
|
document.body.appendChild(pop);
|
|
3102
3287
|
placePickerNearTrigger(pop, triggerEl, 280);
|
|
3103
3288
|
|
|
3104
|
-
|
|
3289
|
+
// Prime the pager if cold. Returning state is cached across
|
|
3290
|
+
// re-opens within the session so a user who pages, closes, then
|
|
3291
|
+
// reopens the picker sees the same rendered list instantly.
|
|
3292
|
+
if (!getVoicePagerState().initialised) {
|
|
3293
|
+
await fetchNextVoicePage(30);
|
|
3294
|
+
}
|
|
3105
3295
|
// The user (or a sibling open) may have closed this picker
|
|
3106
3296
|
// during the await · don't write into a detached node.
|
|
3107
3297
|
if (!document.body.contains(pop)) return;
|
|
3108
3298
|
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
const
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3299
|
+
renderVoicePickerBody(pop, slug);
|
|
3300
|
+
|
|
3301
|
+
// Infinite-scroll · when the user scrolls within 80 px of the
|
|
3302
|
+
// bottom AND there's more to fetch AND nothing's in flight, load
|
|
3303
|
+
// the next page. 80 px is one row height plus padding; firing
|
|
3304
|
+
// before the sentinel hits the fold means the next page is
|
|
3305
|
+
// landing while the user is still reading the current bottom row.
|
|
3306
|
+
pop.addEventListener("scroll", async () => {
|
|
3307
|
+
const state = getVoicePagerState();
|
|
3308
|
+
if (state.loading || !state.hasMore) return;
|
|
3309
|
+
const distanceFromBottom = pop.scrollHeight - pop.scrollTop - pop.clientHeight;
|
|
3310
|
+
if (distanceFromBottom > 80) return;
|
|
3311
|
+
const grew = await fetchNextVoicePage(30);
|
|
3312
|
+
// Re-render only after a successful fetch · a fetch that
|
|
3313
|
+
// returned no new rows (network error, race) shouldn't
|
|
3314
|
+
// discard the existing list. The detached-node guard means a
|
|
3315
|
+
// user who closed the picker mid-fetch sees no surprise repaint.
|
|
3316
|
+
if (!grew) return;
|
|
3317
|
+
if (!document.body.contains(pop)) return;
|
|
3318
|
+
// Preserve scroll position across the innerHTML rewrite so the
|
|
3319
|
+
// user's reading flow isn't yanked to the top.
|
|
3320
|
+
const savedScroll = pop.scrollTop;
|
|
3321
|
+
renderVoicePickerBody(pop, slug);
|
|
3322
|
+
pop.scrollTop = savedScroll;
|
|
3323
|
+
}, { passive: true });
|
|
3131
3324
|
}
|
|
3132
3325
|
function closeVoicePicker() {
|
|
3133
3326
|
const el = document.getElementById("ap-voice-picker");
|
|
@@ -3214,6 +3407,12 @@
|
|
|
3214
3407
|
method: "POST",
|
|
3215
3408
|
headers: { "content-type": "application/json" },
|
|
3216
3409
|
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"),
|
|
3217
3416
|
provider: v.provider,
|
|
3218
3417
|
model: v.model,
|
|
3219
3418
|
voiceId: v.voiceId,
|
|
@@ -4993,14 +5192,14 @@
|
|
|
4993
5192
|
window.refreshAgentProfileSkills = function () {
|
|
4994
5193
|
// Voices list is keyed off the user's configured providers · when
|
|
4995
5194
|
// keys change (added / deleted / swapped between minimax ↔
|
|
4996
|
-
// elevenlabs), the cached
|
|
4997
|
-
//
|
|
4998
|
-
// refresh. Invalidate here so the next
|
|
4999
|
-
//
|
|
5000
|
-
// the stale cache so the user doesn't see the wrong list
|
|
5001
|
-
// before it reopens. This function is the canonical "keys
|
|
5002
|
-
// have changed" hook called by user-settings on modal close.
|
|
5003
|
-
|
|
5195
|
+
// elevenlabs), the cached pager state is stale and the picker
|
|
5196
|
+
// would still show the prior provider's voices until a hard
|
|
5197
|
+
// refresh. Invalidate here so the next openVoicePicker() refetches
|
|
5198
|
+
// the first page; also closes any open picker that's painting
|
|
5199
|
+
// from the stale cache so the user doesn't see the wrong list
|
|
5200
|
+
// flash before it reopens. This function is the canonical "keys
|
|
5201
|
+
// may have changed" hook called by user-settings on modal close.
|
|
5202
|
+
invalidateVoicePager();
|
|
5004
5203
|
closeVoicePicker();
|
|
5005
5204
|
closeEmotionPicker();
|
|
5006
5205
|
if (!currentlyOpenSlug) return;
|