@allmodels/dsh-speech 0.1.2 → 0.1.3

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/lib/client.js CHANGED
@@ -216,9 +216,47 @@ async function request(path, init) {
216
216
  }
217
217
  return body;
218
218
  }
219
+ async function audioRequest(input, signal) {
220
+ const response = await fetch("/api/dsh-speech/tts", {
221
+ method: "POST",
222
+ headers: {
223
+ accept: "audio/mpeg",
224
+ "content-type": "application/json"
225
+ },
226
+ body: JSON.stringify(input),
227
+ ...signal === void 0 ? {} : { signal }
228
+ });
229
+ if (!response.ok) {
230
+ const body = await response.json().catch(() => void 0);
231
+ const error = body !== null && typeof body === "object" ? body.error : void 0;
232
+ throw new SpeechApiError(typeof error?.code === "string" ? error.code : `HTTP_${String(response.status)}`, typeof error?.message === "string" ? error.message : "Speech generation failed");
233
+ }
234
+ const blob = await response.blob();
235
+ if (blob.size === 0 || blob.size > 16 * 1024 * 1024) throw new SpeechApiError("INVALID_AUDIO", "The spoken summary audio is invalid");
236
+ return blob.type === "audio/mpeg" ? blob : blob.slice(0, blob.size, "audio/mpeg");
237
+ }
219
238
  const speechApi = {
220
239
  status: () => request("/api/dsh-speech/status"),
221
240
  catalog: (refresh = false) => request(`/api/dsh-speech/catalog${refresh ? "?refresh=1" : ""}`),
241
+ voices: (filters = {}, signal) => {
242
+ const query = new URLSearchParams();
243
+ if (filters.model !== void 0) query.set("model", filters.model);
244
+ if (filters.provider !== void 0) query.set("provider", filters.provider);
245
+ if (filters.q !== void 0) query.set("q", filters.q);
246
+ if (filters.language !== void 0) query.set("language", filters.language);
247
+ return request(`/api/dsh-speech/voices?${query.toString()}`, signal === void 0 ? void 0 : { signal });
248
+ },
249
+ summarize: (input, signal) => request("/api/dsh-speech/summarize", {
250
+ method: "POST",
251
+ body: JSON.stringify(input),
252
+ ...signal === void 0 ? {} : { signal }
253
+ }),
254
+ tts: audioRequest,
255
+ prepareTts: (input, signal) => request("/api/dsh-speech/tts/prepare", {
256
+ method: "POST",
257
+ body: JSON.stringify(input),
258
+ ...signal === void 0 ? {} : { signal }
259
+ }),
222
260
  startAuth: (email) => request("/api/dsh-speech/auth/start", {
223
261
  method: "POST",
224
262
  body: JSON.stringify({ email })
@@ -265,6 +303,42 @@ function writePreferredMicrophone(deviceId) {
265
303
  //#region src/shared.ts
266
304
  const CATALOG_TTL_MS = 300 * 1e3;
267
305
  const AUDIO_FORMAT = "pcm_16000";
306
+ const MAX_SUMMARY_REQUEST_CHARACTERS = 16e3;
307
+ const MAX_SUMMARY_ANSWER_CHARACTERS = 64e3;
308
+ const DEFAULT_ENGLISH_TTS = {
309
+ model: "fish/s2.1-pro",
310
+ provider: "fish",
311
+ voice: "03397b4c4be74759b72533b663fbd001",
312
+ name: "Elon Musk (Noise reduction)"
313
+ };
314
+ const DEFAULT_CHINESE_TTS = {
315
+ model: "minimax/speech-2.8-hd",
316
+ provider: "minimax",
317
+ voice: "Chinese (Mandarin)_HK_Flight_Attendant",
318
+ name: "HK Flight Attendant"
319
+ };
320
+ function selectTtsBinding(bindings, preferred) {
321
+ if (preferred?.model !== void 0) {
322
+ const preferredModel = preferred.model;
323
+ const matchesModel = (binding) => binding.model === preferredModel || binding.canonical === preferredModel || !preferredModel.includes("/") && binding.model.endsWith(`/${preferredModel}`);
324
+ const exact = bindings.find((binding) => matchesModel(binding) && (preferred.provider === void 0 || binding.provider === preferred.provider));
325
+ if (exact !== void 0) return exact;
326
+ const sameModel = bindings.find(matchesModel);
327
+ if (sameModel !== void 0) return sameModel;
328
+ }
329
+ return bindings.find((binding) => binding.isProviderDefault) ?? bindings[0];
330
+ }
331
+ function preferredTtsSelection(locale) {
332
+ return locale.toLowerCase().startsWith("zh") ? DEFAULT_CHINESE_TTS : DEFAULT_ENGLISH_TTS;
333
+ }
334
+ function selectLocalizedTtsBinding(bindings, locale, preferred) {
335
+ if (preferred?.model !== void 0 || preferred?.provider !== void 0) return selectTtsBinding(bindings, preferred);
336
+ const localized = preferredTtsSelection(locale);
337
+ return selectTtsBinding(bindings, {
338
+ model: localized.model,
339
+ provider: localized.provider
340
+ });
341
+ }
268
342
  function selectBinding(bindings, locale, preferred) {
269
343
  if (preferred?.model !== void 0) {
270
344
  const preferredModel = preferred.model;
@@ -696,7 +770,21 @@ const styles = {
696
770
  srOnly: "dsh-speech-sr-only",
697
771
  dock: "dsh-speech-dock",
698
772
  dockError: "dsh-speech-dock-error",
699
- dockDetail: "dsh-speech-dock-detail"
773
+ dockDetail: "dsh-speech-dock-detail",
774
+ switchLabel: "dsh-speech-switch-label",
775
+ switchTrack: "dsh-speech-switch-track",
776
+ summaryPlayer: "dsh-speech-summary-player",
777
+ summaryControl: "dsh-speech-summary-control",
778
+ summaryButton: "dsh-speech-summary-button",
779
+ summaryWaveform: "dsh-speech-summary-waveform",
780
+ summaryLabel: "dsh-speech-summary-label",
781
+ autoplayToggle: "dsh-speech-autoplay-toggle",
782
+ summaryToggles: "dsh-speech-summary-toggles",
783
+ voicePicker: "dsh-speech-voice-picker",
784
+ voiceSelected: "dsh-speech-voice-selected",
785
+ voiceMenu: "dsh-speech-voice-menu",
786
+ voiceOption: "dsh-speech-voice-option",
787
+ voiceNotice: "dsh-speech-voice-notice"
700
788
  };
701
789
  const STYLE_TEXT = String.raw`
702
790
  .dsh-speech-settings{display:grid;gap:16px;max-width:760px;padding:4px 2px 24px;color:var(--color-text,#e8e8e8)}
@@ -709,15 +797,19 @@ const STYLE_TEXT = String.raw`
709
797
  .dsh-speech-model-full{grid-column:1/-1}.dsh-speech-context-details{border-top:1px solid var(--color-border,#343434);padding-top:12px}.dsh-speech-context-summary{width:max-content;color:var(--color-text-secondary,#aaa);cursor:pointer;font-size:13px;font-weight:600}.dsh-speech-context-summary:hover{color:var(--color-text,#eee)}.dsh-speech-context-summary span{margin-left:5px;color:var(--color-text-secondary,#888);font-size:11px;font-weight:400}.dsh-speech-context-body{display:grid;gap:8px;padding-top:12px}.dsh-speech-balance-value{font-size:24px;line-height:1.2;color:var(--color-text,#eee)}
710
798
  .dsh-speech-stack,.dsh-speech-field{display:grid;gap:7px;align-content:start}.dsh-speech-label{color:var(--color-text-secondary,#b2b2b2);font-size:13px;font-weight:600}.dsh-speech-hint,.dsh-speech-muted{margin:0;color:var(--color-text-secondary,#999);font-size:12px;line-height:1.45}
711
799
  .dsh-speech-input,.dsh-speech-textarea{width:100%;box-sizing:border-box;border:1px solid var(--color-border,#444);border-radius:8px;padding:9px 10px;background:var(--color-background,#191919);color:inherit;font:inherit}.dsh-speech-textarea{min-height:84px;resize:vertical}.dsh-speech-input:focus,.dsh-speech-textarea:focus{outline:2px solid color-mix(in srgb,#68a8ff 65%,transparent);outline-offset:1px}.dsh-speech-input:disabled,.dsh-speech-textarea:disabled{opacity:.55}
800
+ .dsh-speech-voice-picker{display:grid;gap:7px;align-content:start;min-width:0}.dsh-speech-voice-selected{display:flex;align-items:baseline;justify-content:space-between;gap:10px;min-width:0;padding:8px 10px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#68a8ff) 28%,var(--color-border,#444));border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-brand-primary,#68a8ff) 7%,transparent)}.dsh-speech-voice-selected strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}.dsh-speech-voice-selected span{flex:none;color:var(--color-text-secondary,#999);font-size:11px}.dsh-speech-voice-menu{max-height:300px;overflow:auto;display:grid;gap:2px;padding:4px;border:1px solid var(--color-border,#444);border-radius:9px;background:var(--color-background-elevated,#202020);box-shadow:0 10px 26px rgba(0,0,0,.22)}.dsh-speech-voice-option{width:100%;min-width:0;display:grid;gap:4px;padding:8px 9px;border:0;border-radius:7px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}.dsh-speech-voice-option>span{display:flex;align-items:baseline;justify-content:space-between;gap:12px;min-width:0}.dsh-speech-voice-option strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}.dsh-speech-voice-option small{color:var(--color-text-secondary,#999);font-size:11px;line-height:1.35}.dsh-speech-voice-option>span small{flex:none}.dsh-speech-voice-option:hover,.dsh-speech-voice-option:focus-visible,.dsh-speech-voice-option[data-active="true"]{outline:0;background:var(--color-background-hover,rgba(255,255,255,.08))}.dsh-speech-voice-option[aria-selected="true"]{box-shadow:inset 2px 0 var(--dsw-alias-brand-primary,#68a8ff)}.dsh-speech-voice-notice{padding:14px 10px;color:var(--color-text-secondary,#999);font-size:12px;text-align:center}
712
801
  .dsh-speech-primary,.dsh-speech-secondary,.dsh-speech-checkout{border:1px solid transparent;border-radius:8px;padding:8px 12px;font:inherit;font-size:13px;font-weight:600;cursor:pointer;text-decoration:none;text-align:center}.dsh-speech-primary{background:#e8e8e8;color:#151515}.dsh-speech-secondary,.dsh-speech-checkout{border-color:var(--color-border,#4a4a4a);background:transparent;color:inherit}.dsh-speech-primary:disabled,.dsh-speech-secondary:disabled{cursor:default;opacity:.45}.dsh-speech-good{color:#65c88a}.dsh-speech-warning{margin:0;color:#efb85c}.dsh-speech-danger,.dsh-speech-dock-error{margin:0;color:#f07878}
713
802
  .dsh-speech-top-up{justify-content:flex-start;align-items:end;flex-wrap:wrap}.dsh-speech-top-up .dsh-speech-field{max-width:180px}
803
+ .dsh-speech-summary-toggles{display:flex;align-items:center;justify-content:flex-end;gap:18px;flex-wrap:wrap}.dsh-speech-switch-label{position:relative;display:flex;align-items:center;gap:8px;color:var(--color-text-secondary,#aaa);font-size:12px;cursor:pointer;white-space:nowrap}.dsh-speech-switch-label input{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}.dsh-speech-switch-track{width:32px;height:18px;display:inline-flex;align-items:center;flex:none;box-sizing:border-box;padding:2px;border:1px solid var(--color-border,#555);border-radius:999px;background:var(--color-background,#242424);transition:background .15s ease,border-color .15s ease}.dsh-speech-switch-track i{width:12px;height:12px;display:block;border-radius:50%;background:var(--color-text-secondary,#aaa);transform:translateX(0);transition:transform .15s ease,background .15s ease}.dsh-speech-switch-label input:checked+.dsh-speech-switch-track,.dsh-speech-autoplay-toggle[aria-checked="true"] .dsh-speech-switch-track{border-color:var(--dsw-alias-brand-primary,#68a8ff);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#68a8ff) 32%,transparent)}.dsh-speech-switch-label input:checked+.dsh-speech-switch-track i,.dsh-speech-autoplay-toggle[aria-checked="true"] .dsh-speech-switch-track i{background:#fff;transform:translateX(14px)}.dsh-speech-switch-label:focus-within .dsh-speech-switch-track{outline:2px solid color-mix(in srgb,#68a8ff 60%,transparent);outline-offset:2px}.dsh-speech-switch-label:has(input:disabled){cursor:default;opacity:.5}
804
+ .dsh-speech-summary-player{width:min(397px,100%);height:34px;display:flex;align-items:center;gap:9px;margin-left:-2px;color:var(--color-text-secondary,#9b9b9b)}.dsh-speech-summary-control{width:194px;height:34px;display:flex;align-items:center;gap:5px;box-sizing:border-box;padding:4px 12px 4px 7px;border:1px solid var(--color-border,#343434);border-radius:999px;background:var(--color-background-secondary,rgba(255,255,255,.025))}.dsh-speech-summary-button{width:24px;height:24px;display:inline-grid;place-items:center;flex:none;padding:0;border:0;border-radius:50%;background:transparent;color:inherit;cursor:pointer}.dsh-speech-summary-button:hover:not(:disabled),.dsh-speech-summary-button:focus-visible{outline:0;background:var(--color-background-hover,rgba(255,255,255,.08));color:var(--color-text,#eee)}.dsh-speech-summary-button:disabled{cursor:wait;opacity:.6}.dsh-speech-summary-button svg{width:15px;height:15px;fill:currentColor}.dsh-speech-summary-waveform{width:144px;height:22px;display:flex;align-items:center;gap:1px;flex:none;overflow:hidden}.dsh-speech-summary-waveform i{width:2px;height:calc(3px + var(--dsh-speech-peak)*16px);display:block;flex:none;border-radius:2px;background:currentColor;opacity:.28;transition:height 45ms linear,opacity .12s ease,color .12s ease}.dsh-speech-summary-waveform[data-playing="true"] i{color:var(--dsw-alias-brand-primary,#68a8ff);opacity:.82}.dsh-speech-summary-waveform[data-preparing="true"] i{animation:dsh-speech-wave-pulse 1s ease-in-out infinite}.dsh-speech-summary-waveform[data-preparing="true"] i:nth-child(4n+2){animation-delay:.12s}.dsh-speech-summary-waveform[data-preparing="true"] i:nth-child(4n+3){animation-delay:.24s}.dsh-speech-summary-waveform[data-preparing="true"] i:nth-child(4n){animation-delay:.36s}.dsh-speech-summary-label{min-width:0;max-width:190px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px}.dsh-speech-autoplay-toggle{height:28px;display:inline-flex;align-items:center;gap:7px;padding:0 8px;border:1px solid transparent;border-radius:7px;background:transparent;color:var(--color-text-secondary,#aaa);font:inherit;font-size:11px;cursor:pointer}.dsh-speech-autoplay-toggle .dsh-speech-switch-track{width:28px;height:16px}.dsh-speech-autoplay-toggle .dsh-speech-switch-track i{width:10px;height:10px}.dsh-speech-autoplay-toggle[aria-checked="true"] .dsh-speech-switch-track i{transform:translateX(12px)}.dsh-speech-autoplay-toggle:hover:not(:disabled),.dsh-speech-autoplay-toggle:focus-visible{outline:0;border-color:var(--color-border,#444);background:var(--color-background-hover,rgba(255,255,255,.06));color:var(--color-text,#eee)}.dsh-speech-autoplay-toggle:disabled{opacity:.45;cursor:default}@keyframes dsh-speech-wave-pulse{0%,100%{opacity:.2;transform:scaleY(.55)}50%{opacity:.78;transform:scaleY(1)}}
714
805
  .dsh-speech-mic-wrap{position:relative;display:inline-flex;align-items:center;gap:5px;order:1}[data-slot="conversation.input.right"]:has(>.dsh-speech-mic-wrap)~button:last-child{order:2}.dsh-speech-mic{width:30px;height:30px;display:inline-grid;place-items:center;padding:0;border:0;border-radius:7px;background:transparent;color:var(--color-text-secondary,#a8a8a8);cursor:pointer}.dsh-speech-mic:hover:not(:disabled),.dsh-speech-mic:focus-visible{background:var(--color-background-hover,rgba(255,255,255,.08));color:var(--color-text,#eee)}.dsh-speech-mic[aria-disabled="true"]{opacity:.48}.dsh-speech-mic:disabled{cursor:default}.dsh-speech-mic svg{width:18px;height:18px;fill:currentColor}.dsh-speech-mic-active{color:#ef6666;background:rgba(239,102,102,.12)}.dsh-speech-mic-tooltip{position:absolute;right:0;bottom:calc(100% + 8px);z-index:40;width:max-content;max-width:260px;padding:6px 9px;border:1px solid var(--color-border,#343434);border-radius:7px;background:var(--color-background-elevated,#202020);color:var(--color-text,#eee);font-size:12px;line-height:1.35;box-shadow:0 6px 18px rgba(0,0,0,.24);opacity:0;visibility:hidden;transform:translateY(2px);pointer-events:none;transition:opacity .12s ease,transform .12s ease,visibility .12s}.dsh-speech-mic-wrap:hover .dsh-speech-mic-tooltip,.dsh-speech-mic-wrap:focus-within .dsh-speech-mic-tooltip,.dsh-speech-mic-wrap[data-explanation-open="true"] .dsh-speech-mic-tooltip{opacity:1;visibility:visible;transform:translateY(0)}
715
806
  :where(div):has(>:where(div)>[data-slot="conversation.input.right"]>.dsh-speech-recording-takeover){position:relative}:where(div):has(>:where(div)>[data-slot="conversation.input.right"]>.dsh-speech-recording-takeover)>*{visibility:hidden}.dsh-speech-recording-takeover{position:absolute;inset:0;z-index:8;visibility:visible;display:flex;align-items:center;gap:10px;padding:4px 8px;box-sizing:border-box;color:var(--color-text-secondary,#a8a8a8)}.dsh-speech-recording-track{height:30px;min-width:0;flex:1;display:flex;align-items:center}.dsh-speech-recording-canvas{display:block;width:100%;height:30px;color:var(--color-text-secondary,#a8a8a8)}.dsh-speech-recording-cancel,.dsh-speech-recording-stop{flex:none;border-radius:999px}.dsh-speech-recording-cancel{color:var(--color-text-secondary,#a8a8a8);background:transparent}.dsh-speech-recording-cancel:hover:not(:disabled),.dsh-speech-recording-cancel:focus-visible{background:var(--color-background-hover,rgba(255,255,255,.08));color:var(--color-text,#eee)}.dsh-speech-recording-actions{display:flex;align-items:center;gap:6px;flex:none}.dsh-speech-recording-stop{color:#ef6666;background:rgba(239,102,102,.12)}.dsh-speech-recording-stop:hover:not(:disabled),.dsh-speech-recording-stop:focus-visible{color:#ff7777;background:rgba(239,102,102,.2)}.dsh-speech-recording-send{width:34px;height:34px;display:inline-grid;place-items:center;flex:none;padding:0;border:0;border-radius:999px;background:var(--dsw-alias-button-info-fill,#679efe);color:#fff;cursor:pointer}.dsh-speech-recording-send:hover:not(:disabled),.dsh-speech-recording-send:focus-visible{outline:0;background:var(--dsw-alias-button-info-hover,#4176e6)}.dsh-speech-recording-send:disabled{cursor:default;opacity:.4}.dsh-speech-recording-send svg{width:16px;height:16px}.dsh-speech-recording-progress{display:flex;align-items:center;justify-content:flex-end;gap:8px;width:100%;color:var(--color-text-secondary,#a8a8a8);font-size:12px}.dsh-speech-recording-takeover[data-phase="starting"] .dsh-speech-recording-progress{justify-content:center}.dsh-speech-recording-progress i{width:13px;height:13px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:dsh-speech-spin .8s linear infinite}.dsh-speech-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@keyframes dsh-speech-spin{to{transform:rotate(360deg)}}
716
807
  .dsh-speech-device-marker{display:none}[data-slot="conversation.composer.dock"]>:has(>.dsh-speech-device-dock){overflow:visible}.dsh-speech-device-fallback{min-height:22px;display:flex;align-items:center;justify-content:flex-start;padding:0 8px;overflow:visible}.dsh-speech-device-fallback .dsh-speech-device-dock{margin-left:0}.dsh-speech-device-separator{margin-left:4px;color:var(--dsw-alias-label-tertiary,var(--color-text-secondary,#888))}.dsh-speech-device-dock{position:relative;display:inline-flex;vertical-align:middle;margin-left:5px;color:var(--dsw-alias-label-secondary,var(--color-text-secondary,#a8a8a8));font:inherit}.dsh-speech-device-trigger{max-width:126px;height:20px;display:inline-flex;align-items:center;gap:4px;padding:0 5px;border:0;border-radius:6px;background:transparent;color:inherit;font:inherit;font-size:11px;line-height:20px;cursor:pointer}.dsh-speech-device-trigger:hover,.dsh-speech-device-trigger:focus-visible,.dsh-speech-device-trigger[aria-expanded="true"]{outline:0;background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08));color:var(--dsw-alias-label-primary,var(--color-text,#eee))}.dsh-speech-device-trigger:disabled{cursor:wait;opacity:.6}.dsh-speech-device-dock[data-error="true"] .dsh-speech-device-trigger{color:var(--dsw-alias-state-error-primary,#f07878)}.dsh-speech-device-icon,.dsh-speech-device-chevron{display:inline-flex;flex:none}.dsh-speech-device-icon svg{width:12px;height:12px}.dsh-speech-device-chevron svg{width:10px;height:10px}.dsh-speech-device-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dsh-speech-device-menu{position:absolute;right:0;bottom:calc(100% + 6px);z-index:80;min-width:240px;max-width:min(320px,calc(100vw - 32px));display:grid;box-sizing:border-box;padding:4px;border:1px solid var(--dsw-alias-border-l2-darkmode-thin,rgba(255,255,255,.06));border-radius:12px;background:var(--dsw-alias-bg-layer-3,#353638);color:var(--dsw-alias-label-primary,#f9fafb);box-shadow:0 0 1px rgba(0,0,0,.2),0 0 4px rgba(0,0,0,.02),0 12px 32px rgba(0,0,0,.08);font:13px/20px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.dsh-speech-device-menu-item{width:100%;min-width:0;height:38px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 10px;border:0;border-radius:10px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}.dsh-speech-device-menu-item:hover,.dsh-speech-device-menu-item:focus-visible{outline:0;background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08))}.dsh-speech-device-menu-item>span:first-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dsh-speech-device-check{width:14px;height:14px;display:inline-flex;flex:none;color:var(--dsw-alias-brand-primary,var(--dsw-alias-label-primary,#f9fafb))}.dsh-speech-device-check svg{width:14px;height:14px}.dsh-speech-device-dock[data-variant="hero"]{margin-left:0;color:var(--dsw-alias-label-primary,var(--color-text,#f9fafb));font:500 13px/20px -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei","Helvetica Neue",Helvetica,Arial,sans-serif}.dsh-speech-device-dock[data-variant="hero"] .dsh-speech-device-trigger{max-width:220px;height:28px;padding:0 8px;gap:4px;border-radius:16px;font:inherit;line-height:20px}.dsh-speech-device-dock[data-variant="hero"] .dsh-speech-device-icon svg{width:16px;height:16px}.dsh-speech-device-dock[data-variant="hero"] .dsh-speech-device-chevron svg{width:14px;height:14px}.dsh-speech-device-dock[data-variant="hero"] .dsh-speech-device-menu{left:0;right:auto;top:calc(100% + 6px);bottom:auto;min-width:218px}
717
808
  [data-dsh-speech-nav]>svg{display:none}[data-dsh-speech-nav]::before{content:"";width:16px;height:16px;flex:none;background:currentColor;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Crect x='7' y='2' width='6' height='11' rx='3' fill='none' stroke='black' stroke-width='1.7'/%3E%3Cpath d='M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v3M7 18h6' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E") center/contain no-repeat}
718
809
  .dsh-speech-dock,.dsh-speech-dock-error{min-height:22px;display:flex;align-items:center;gap:9px;padding:4px 8px 0;font-size:12px}.dsh-speech-dock-detail{color:var(--color-text-secondary,#8d8d8d)}
810
+ .dsh-speech-summary-player{width:100%}.dsh-speech-summary-player>.dsh-speech-autoplay-toggle{margin-left:auto}
719
811
  @media(max-width:680px){.dsh-speech-columns,.dsh-speech-grid,.dsh-speech-balance-grid{grid-template-columns:1fr}.dsh-speech-header{align-items:flex-start}.dsh-speech-device-separator,.dsh-speech-device-dock{display:none}}
720
- @media(prefers-reduced-motion:reduce){.dsh-speech-mic-tooltip{transition:none}.dsh-speech-recording-progress i{animation:none;border-right-color:currentColor}}
812
+ @media(prefers-reduced-motion:reduce){.dsh-speech-mic-tooltip{transition:none}.dsh-speech-recording-progress i{animation:none;border-right-color:currentColor}.dsh-speech-summary-waveform i{transition:none}.dsh-speech-summary-waveform[data-preparing="true"] i{animation:none}}
721
813
  `;
722
814
 
723
815
  //#endregion
@@ -1272,6 +1364,258 @@ function Field({ label, children, hint }) {
1272
1364
  ]
1273
1365
  });
1274
1366
  }
1367
+ function voiceKey(voice) {
1368
+ return `${voice.provider ?? ""}\n${voice.model ?? ""}\n${voice.id}`;
1369
+ }
1370
+ function useVoiceOptions(filters, query, enabled = true) {
1371
+ const [voices, setVoices] = (0, react.useState)([]);
1372
+ const [loading, setLoading] = (0, react.useState)(false);
1373
+ (0, react.useEffect)(() => {
1374
+ if (!enabled) {
1375
+ setVoices([]);
1376
+ setLoading(false);
1377
+ return;
1378
+ }
1379
+ const controller = new AbortController();
1380
+ const timer = setTimeout(() => {
1381
+ setLoading(true);
1382
+ speechApi.voices({
1383
+ ...filters.model === void 0 ? {} : { model: filters.model },
1384
+ ...filters.provider === void 0 ? {} : { provider: filters.provider },
1385
+ ...query.trim().length === 0 ? {} : { q: query.trim() }
1386
+ }, controller.signal).then((result) => {
1387
+ setVoices(result.voices.filter((voice) => voice.model !== void 0 && voice.provider !== void 0));
1388
+ }).catch((cause) => {
1389
+ if (!(cause instanceof DOMException && cause.name === "AbortError")) setVoices([]);
1390
+ }).finally(() => {
1391
+ if (!controller.signal.aborted) setLoading(false);
1392
+ });
1393
+ }, 250);
1394
+ return () => {
1395
+ clearTimeout(timer);
1396
+ controller.abort();
1397
+ };
1398
+ }, [
1399
+ enabled,
1400
+ filters.model,
1401
+ filters.provider,
1402
+ query
1403
+ ]);
1404
+ return {
1405
+ voices,
1406
+ loading
1407
+ };
1408
+ }
1409
+ function VoicePicker({ label, hint, placeholder, loadingLabel, emptyLabel, query, voices, selected, loading, disabled, onQuery, onSelect }) {
1410
+ const inputId = (0, react.useId)();
1411
+ const listId = (0, react.useId)();
1412
+ const [open, setOpen] = (0, react.useState)(false);
1413
+ const [active, setActive] = (0, react.useState)(0);
1414
+ const displayed = voices.slice(0, 24);
1415
+ (0, react.useEffect)(() => {
1416
+ setActive(0);
1417
+ }, [voices]);
1418
+ const keyDown = (event) => {
1419
+ if (event.key === "ArrowDown") {
1420
+ event.preventDefault();
1421
+ setOpen(true);
1422
+ setActive((current) => Math.min(displayed.length - 1, current + 1));
1423
+ } else if (event.key === "ArrowUp") {
1424
+ event.preventDefault();
1425
+ setOpen(true);
1426
+ setActive((current) => Math.max(0, current - 1));
1427
+ } else if (event.key === "Enter" && open && displayed[active] !== void 0) {
1428
+ event.preventDefault();
1429
+ onSelect(displayed[active]);
1430
+ setOpen(false);
1431
+ } else if (event.key === "Escape") setOpen(false);
1432
+ };
1433
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1434
+ className: styles.voicePicker,
1435
+ onBlur: (event) => {
1436
+ if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
1437
+ },
1438
+ children: [
1439
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
1440
+ className: styles.label,
1441
+ htmlFor: inputId,
1442
+ children: label
1443
+ }),
1444
+ selected === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1445
+ className: styles.voiceSelected,
1446
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: selected.name }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
1447
+ selected.providerName ?? selected.provider,
1448
+ " · ",
1449
+ selected.model
1450
+ ] })]
1451
+ }),
1452
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1453
+ id: inputId,
1454
+ className: styles.input,
1455
+ type: "search",
1456
+ role: "combobox",
1457
+ "aria-autocomplete": "list",
1458
+ "aria-controls": listId,
1459
+ "aria-expanded": open,
1460
+ "aria-activedescendant": open && displayed[active] !== void 0 ? `${listId}-${String(active)}` : void 0,
1461
+ value: query,
1462
+ placeholder,
1463
+ disabled,
1464
+ onFocus: () => {
1465
+ setOpen(true);
1466
+ },
1467
+ onChange: (event) => {
1468
+ onQuery(event.target.value);
1469
+ setOpen(true);
1470
+ },
1471
+ onKeyDown: keyDown
1472
+ }),
1473
+ hint === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1474
+ className: styles.hint,
1475
+ children: hint
1476
+ }),
1477
+ !open ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1478
+ id: listId,
1479
+ className: styles.voiceMenu,
1480
+ role: "listbox",
1481
+ children: loading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1482
+ className: styles.voiceNotice,
1483
+ role: "status",
1484
+ children: loadingLabel
1485
+ }) : displayed.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1486
+ className: styles.voiceNotice,
1487
+ children: emptyLabel
1488
+ }) : displayed.map((voice, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1489
+ id: `${listId}-${String(index)}`,
1490
+ className: styles.voiceOption,
1491
+ type: "button",
1492
+ role: "option",
1493
+ "aria-selected": selected !== void 0 && voiceKey(voice) === voiceKey(selected),
1494
+ "data-active": index === active,
1495
+ onMouseDown: (event) => {
1496
+ event.preventDefault();
1497
+ },
1498
+ onMouseEnter: () => {
1499
+ setActive(index);
1500
+ },
1501
+ onClick: () => {
1502
+ onSelect(voice);
1503
+ setOpen(false);
1504
+ },
1505
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: voice.name }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("small", { children: [
1506
+ voice.providerName ?? voice.provider,
1507
+ " · ",
1508
+ voice.model
1509
+ ] })] }), voice.description === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("small", { children: voice.description })]
1510
+ }, voiceKey(voice)))
1511
+ })
1512
+ ]
1513
+ });
1514
+ }
1515
+ function ModelPicker({ label, placeholder, emptyLabel, options, value, disabled, onSelect }) {
1516
+ const inputId = (0, react.useId)();
1517
+ const listId = (0, react.useId)();
1518
+ const [query, setQuery] = (0, react.useState)(value);
1519
+ const [open, setOpen] = (0, react.useState)(false);
1520
+ const [active, setActive] = (0, react.useState)(-1);
1521
+ const normalizedQuery = query.trim().toLocaleLowerCase();
1522
+ const displayed = normalizedQuery.length === 0 || query === value ? options : options.filter((option) => option.toLocaleLowerCase().includes(normalizedQuery));
1523
+ (0, react.useEffect)(() => {
1524
+ setQuery(value);
1525
+ }, [value]);
1526
+ (0, react.useEffect)(() => {
1527
+ setActive(-1);
1528
+ }, [query, options]);
1529
+ const select = (model) => {
1530
+ setQuery(model);
1531
+ setOpen(false);
1532
+ setActive(-1);
1533
+ onSelect(model);
1534
+ };
1535
+ const keyDown = (event) => {
1536
+ if (event.key === "ArrowDown") {
1537
+ event.preventDefault();
1538
+ setOpen(true);
1539
+ setActive((current) => Math.min(displayed.length - 1, current + 1));
1540
+ } else if (event.key === "ArrowUp") {
1541
+ event.preventDefault();
1542
+ setOpen(true);
1543
+ setActive((current) => current < 0 ? displayed.length - 1 : Math.max(0, current - 1));
1544
+ } else if (event.key === "Enter" && open && displayed[active] !== void 0) {
1545
+ event.preventDefault();
1546
+ select(displayed[active]);
1547
+ } else if (event.key === "Escape") {
1548
+ event.stopPropagation();
1549
+ setQuery(value);
1550
+ setOpen(false);
1551
+ setActive(-1);
1552
+ }
1553
+ };
1554
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1555
+ className: styles.voicePicker,
1556
+ onBlur: (event) => {
1557
+ if (!event.currentTarget.contains(event.relatedTarget)) {
1558
+ setQuery(value);
1559
+ setOpen(false);
1560
+ setActive(-1);
1561
+ }
1562
+ },
1563
+ children: [
1564
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
1565
+ className: styles.label,
1566
+ htmlFor: inputId,
1567
+ children: label
1568
+ }),
1569
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1570
+ id: inputId,
1571
+ className: styles.input,
1572
+ type: "search",
1573
+ role: "combobox",
1574
+ "aria-autocomplete": "list",
1575
+ "aria-controls": listId,
1576
+ "aria-expanded": open,
1577
+ "aria-activedescendant": open && displayed[active] !== void 0 ? `${listId}-${String(active)}` : void 0,
1578
+ value: query,
1579
+ placeholder,
1580
+ disabled,
1581
+ onFocus: () => {
1582
+ setOpen(true);
1583
+ },
1584
+ onChange: (event) => {
1585
+ setQuery(event.target.value);
1586
+ setOpen(true);
1587
+ },
1588
+ onKeyDown: keyDown
1589
+ }),
1590
+ !open ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1591
+ id: listId,
1592
+ className: styles.voiceMenu,
1593
+ role: "listbox",
1594
+ children: displayed.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1595
+ className: styles.voiceNotice,
1596
+ children: emptyLabel
1597
+ }) : displayed.map((model, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1598
+ id: `${listId}-${String(index)}`,
1599
+ className: styles.voiceOption,
1600
+ type: "button",
1601
+ role: "option",
1602
+ "aria-selected": model === value,
1603
+ "data-active": index === active,
1604
+ onMouseDown: (event) => {
1605
+ event.preventDefault();
1606
+ },
1607
+ onMouseEnter: () => {
1608
+ setActive(index);
1609
+ },
1610
+ onClick: () => {
1611
+ select(model);
1612
+ },
1613
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: model }) })
1614
+ }, model))
1615
+ })
1616
+ ]
1617
+ });
1618
+ }
1275
1619
  function SpeechSettings({ controller, scope, getLocale, t }) {
1276
1620
  const client = (0, react.useSyncExternalStore)(controller.subscribe, controller.getSnapshot);
1277
1621
  const settings = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot());
@@ -1288,6 +1632,8 @@ function SpeechSettings({ controller, scope, getLocale, t }) {
1288
1632
  const [topUpUrl, setTopUpUrl] = (0, react.useState)(null);
1289
1633
  const [languageDraft, setLanguageDraft] = (0, react.useState)("auto");
1290
1634
  const [contextDraft, setContextDraft] = (0, react.useState)("");
1635
+ const [voiceSearch, setVoiceSearch] = (0, react.useState)("");
1636
+ const [modelVoiceSearch, setModelVoiceSearch] = (0, react.useState)("");
1291
1637
  (0, react.useEffect)(() => {
1292
1638
  controller.ensureMetadata();
1293
1639
  }, [controller]);
@@ -1318,6 +1664,33 @@ function SpeechSettings({ controller, scope, getLocale, t }) {
1318
1664
  const selectedProvider = providers.some((binding) => binding.provider === value?.provider) ? value?.provider ?? "" : providers.find((binding) => binding.isProviderDefault)?.provider ?? providers[0]?.provider ?? "";
1319
1665
  const selectedBinding = providers.find((binding) => binding.provider === selectedProvider);
1320
1666
  const models = (0, react.useMemo)(() => [...new Set(catalog.map((binding) => binding.model))].sort(), [catalog]);
1667
+ const ttsCatalog = client.catalog?.ttsBindings ?? [];
1668
+ const ttsEnabled = value?.ttsEnabled ?? true;
1669
+ const ttsLocale = value?.language !== void 0 && value.language !== "auto" ? value.language : getLocale();
1670
+ const localizedTts = preferredTtsSelection(ttsLocale);
1671
+ const hasSavedVoiceRoute = value?.ttsModel !== void 0 && value.ttsProvider !== void 0 && value.ttsVoice !== void 0;
1672
+ const ttsBinding = selectLocalizedTtsBinding(ttsCatalog, ttsLocale, { ...!hasSavedVoiceRoute ? {} : {
1673
+ model: value.ttsModel,
1674
+ provider: value.ttsProvider
1675
+ } });
1676
+ const selectedTtsModel = ttsBinding?.model ?? value?.ttsModel ?? "";
1677
+ const ttsProviders = ttsCatalog.filter((binding) => binding.model === selectedTtsModel);
1678
+ const selectedTtsProvider = ttsProviders.some((binding) => binding.provider === value?.ttsProvider) ? value?.ttsProvider ?? "" : ttsProviders.find((binding) => binding.isProviderDefault)?.provider ?? ttsProviders[0]?.provider ?? "";
1679
+ const selectedTtsBinding = ttsProviders.find((binding) => binding.provider === selectedTtsProvider) ?? ttsBinding;
1680
+ const ttsModels = (0, react.useMemo)(() => [...new Set(ttsCatalog.map((binding) => binding.model))].sort(), [ttsCatalog]);
1681
+ const selectedVoice = (hasSavedVoiceRoute ? value.ttsVoice : void 0) ?? (selectedTtsModel === localizedTts.model && selectedTtsProvider === localizedTts.provider ? localizedTts.voice : void 0) ?? selectedTtsBinding?.defaultVoice ?? "";
1682
+ const defaultVoiceOption = {
1683
+ id: selectedVoice,
1684
+ name: selectedVoice === localizedTts.voice ? localizedTts.name : selectedVoice,
1685
+ model: selectedTtsModel,
1686
+ provider: selectedTtsProvider
1687
+ };
1688
+ const globalVoiceResults = useVoiceOptions({}, voiceSearch, ttsEnabled);
1689
+ const scopedVoiceResults = useVoiceOptions({
1690
+ model: selectedTtsBinding?.aliases?.[0] ?? selectedTtsModel,
1691
+ provider: selectedTtsProvider
1692
+ }, modelVoiceSearch, ttsEnabled && selectedTtsModel.length > 0 && selectedTtsProvider.length > 0);
1693
+ const selectedVoiceOption = [...scopedVoiceResults.voices, ...globalVoiceResults.voices].find((voice) => voice.id === selectedVoice && voice.model === selectedTtsModel && voice.provider === selectedTtsProvider) ?? (selectedVoice.length === 0 ? void 0 : defaultVoiceOption);
1321
1694
  const remaining = Math.max(0, Math.ceil((codeExpiry - now) / 1e3));
1322
1695
  const connected = client.status?.credential.configured === true;
1323
1696
  const writable = settings.writable;
@@ -1503,23 +1876,24 @@ function SpeechSettings({ controller, scope, getLocale, t }) {
1503
1876
  })
1504
1877
  ] })
1505
1878
  }),
1506
- connected ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1507
- className: styles.card,
1508
- children: [
1509
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: t("settings") }),
1510
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1511
- className: styles.grid,
1512
- children: [
1513
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1514
- className: styles.modelFull,
1515
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
1516
- label: t("model"),
1517
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1518
- className: styles.input,
1879
+ connected ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1880
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1881
+ className: styles.card,
1882
+ children: [
1883
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: t("settings") }),
1884
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1885
+ className: styles.grid,
1886
+ children: [
1887
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1888
+ className: styles.modelFull,
1889
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelPicker, {
1890
+ label: t("model"),
1891
+ placeholder: t("modelSearchPlaceholder"),
1892
+ emptyLabel: t("modelNoResults"),
1893
+ options: models,
1519
1894
  value: selectedModel,
1520
1895
  disabled: !writable || models.length === 0,
1521
- onChange: (event) => {
1522
- const model = event.target.value;
1896
+ onSelect: (model) => {
1523
1897
  const choices = catalog.filter((binding) => binding.model === model);
1524
1898
  const provider = choices.find((binding) => binding.isProviderDefault)?.provider ?? choices[0]?.provider;
1525
1899
  run("setting:model", async () => {
@@ -1527,137 +1901,278 @@ function SpeechSettings({ controller, scope, getLocale, t }) {
1527
1901
  if (provider !== void 0) await scope.set("provider", provider);
1528
1902
  await controller.ensureMetadata(true);
1529
1903
  });
1904
+ }
1905
+ })
1906
+ }),
1907
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
1908
+ label: t("provider"),
1909
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1910
+ className: styles.input,
1911
+ value: selectedProvider,
1912
+ disabled: !writable || providers.length === 0,
1913
+ onChange: (event) => {
1914
+ write("provider", event.target.value);
1530
1915
  },
1531
- children: models.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1532
- value: model,
1533
- children: model
1534
- }, model))
1916
+ children: providers.map((binding) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1917
+ value: binding.provider,
1918
+ children: binding.provider
1919
+ }, binding.provider))
1535
1920
  })
1921
+ }),
1922
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Field, {
1923
+ label: t("language"),
1924
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1925
+ className: styles.input,
1926
+ list: "dsh-speech-languages",
1927
+ value: languageDraft,
1928
+ disabled: !writable,
1929
+ onChange: (event) => {
1930
+ setLanguageDraft(event.target.value);
1931
+ },
1932
+ onBlur: commitLanguage,
1933
+ onKeyDown: (event) => {
1934
+ if (event.key === "Enter") event.currentTarget.blur();
1935
+ }
1936
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("datalist", {
1937
+ id: "dsh-speech-languages",
1938
+ children: LANGUAGES.map(([id, label]) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1939
+ value: id,
1940
+ children: id === "auto" ? t("auto") : label
1941
+ }, id))
1942
+ })]
1536
1943
  })
1537
- }),
1538
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
1539
- label: t("provider"),
1540
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1541
- className: styles.input,
1542
- value: selectedProvider,
1543
- disabled: !writable || providers.length === 0,
1544
- onChange: (event) => {
1545
- write("provider", event.target.value);
1546
- },
1547
- children: providers.map((binding) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1548
- value: binding.provider,
1549
- children: binding.provider
1550
- }, binding.provider))
1551
- })
1552
- }),
1553
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Field, {
1554
- label: t("language"),
1555
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1556
- className: styles.input,
1557
- list: "dsh-speech-languages",
1558
- value: languageDraft,
1944
+ ]
1945
+ }),
1946
+ selectedBinding?.contextSupported === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("details", {
1947
+ className: styles.contextDetails,
1948
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("summary", {
1949
+ className: styles.contextSummary,
1950
+ children: [
1951
+ t("context"),
1952
+ " ",
1953
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("optional") })
1954
+ ]
1955
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1956
+ className: styles.contextBody,
1957
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1958
+ className: styles.hint,
1959
+ children: t("contextHint")
1960
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
1961
+ className: styles.textarea,
1962
+ "aria-label": t("context"),
1963
+ maxLength: 4e3,
1964
+ value: contextDraft,
1559
1965
  disabled: !writable,
1560
1966
  onChange: (event) => {
1561
- setLanguageDraft(event.target.value);
1967
+ setContextDraft(event.target.value);
1562
1968
  },
1563
- onBlur: commitLanguage,
1564
- onKeyDown: (event) => {
1565
- if (event.key === "Enter") event.currentTarget.blur();
1566
- }
1567
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("datalist", {
1568
- id: "dsh-speech-languages",
1569
- children: LANGUAGES.map(([id, label]) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1570
- value: id,
1571
- children: id === "auto" ? t("auto") : label
1572
- }, id))
1969
+ onBlur: commitContext
1573
1970
  })]
1574
- })
1575
- ]
1576
- }),
1577
- selectedBinding?.contextSupported === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("details", {
1578
- className: styles.contextDetails,
1579
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("summary", {
1580
- className: styles.contextSummary,
1581
- children: [
1582
- t("context"),
1583
- " ",
1584
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("optional") })
1585
- ]
1586
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1587
- className: styles.contextBody,
1588
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1971
+ })]
1972
+ }) : null
1973
+ ]
1974
+ }),
1975
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1976
+ className: styles.card,
1977
+ children: [
1978
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1979
+ className: styles.cardTitle,
1980
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: t("spokenSummaries") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1589
1981
  className: styles.hint,
1590
- children: t("contextHint")
1591
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
1592
- className: styles.textarea,
1593
- "aria-label": t("context"),
1594
- maxLength: 4e3,
1595
- value: contextDraft,
1596
- disabled: !writable,
1597
- onChange: (event) => {
1598
- setContextDraft(event.target.value);
1599
- },
1600
- onBlur: commitContext
1982
+ children: t("spokenSummariesHint")
1983
+ })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1984
+ className: styles.summaryToggles,
1985
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1986
+ className: styles.switchLabel,
1987
+ children: [
1988
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1989
+ type: "checkbox",
1990
+ checked: ttsEnabled,
1991
+ disabled: !writable,
1992
+ onChange: (event) => {
1993
+ write("ttsEnabled", event.target.checked);
1994
+ }
1995
+ }),
1996
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1997
+ className: styles.switchTrack,
1998
+ "aria-hidden": "true",
1999
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {})
2000
+ }),
2001
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("ttsEnabled") })
2002
+ ]
2003
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2004
+ className: styles.switchLabel,
2005
+ children: [
2006
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2007
+ type: "checkbox",
2008
+ checked: value?.autoPlay ?? true,
2009
+ disabled: !writable || !ttsEnabled,
2010
+ onChange: (event) => {
2011
+ write("autoPlay", event.target.checked);
2012
+ }
2013
+ }),
2014
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2015
+ className: styles.switchTrack,
2016
+ "aria-hidden": "true",
2017
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {})
2018
+ }),
2019
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("autoplayGlobal") })
2020
+ ]
2021
+ })]
1601
2022
  })]
1602
- })]
1603
- }) : null
1604
- ]
1605
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1606
- className: styles.card,
1607
- children: [
1608
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: t("balance") }),
1609
- client.status?.balance === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1610
- className: styles.muted,
1611
- children: client.status?.balanceError === void 0 ? t("loading") : t("balanceUnavailable")
1612
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", {
1613
- className: styles.balanceValue,
1614
- children: money(client.status.balance.paidUsd)
1615
- }), client.status.balance.exhausted ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1616
- className: styles.danger,
1617
- children: t("emptyBalance")
1618
- }) : client.status.balance.low ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1619
- className: styles.warning,
1620
- children: t("lowBalance")
1621
- }) : null] }),
1622
- connected ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1623
- className: styles.topUp,
1624
- children: [
1625
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
1626
- label: t("amount"),
1627
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1628
- className: styles.input,
1629
- type: "number",
1630
- min: 5,
1631
- max: 1e3,
1632
- step: 1,
1633
- value: topUpAmount,
1634
- onChange: (event) => {
1635
- setTopUpAmount(Number(event.target.value));
1636
- }
2023
+ }),
2024
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2025
+ className: styles.grid,
2026
+ children: [
2027
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2028
+ className: styles.modelFull,
2029
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VoicePicker, {
2030
+ label: t("voiceSearch"),
2031
+ hint: t("voiceSearchHint"),
2032
+ placeholder: t("voiceSearchPlaceholder"),
2033
+ loadingLabel: t("loading"),
2034
+ emptyLabel: t("voiceNoResults"),
2035
+ query: voiceSearch,
2036
+ voices: globalVoiceResults.voices,
2037
+ selected: selectedVoiceOption,
2038
+ loading: globalVoiceResults.loading,
2039
+ disabled: !writable || !ttsEnabled,
2040
+ onQuery: setVoiceSearch,
2041
+ onSelect: (voice) => {
2042
+ if (voice.model === void 0 || voice.provider === void 0) return;
2043
+ setVoiceSearch(voice.name);
2044
+ setModelVoiceSearch("");
2045
+ run("setting:ttsVoice", async () => {
2046
+ await scope.set("ttsVoice", voice.id);
2047
+ await scope.set("ttsModel", voice.model);
2048
+ await scope.set("ttsProvider", voice.provider);
2049
+ });
2050
+ }
2051
+ })
2052
+ }),
2053
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2054
+ className: styles.modelFull,
2055
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelPicker, {
2056
+ label: t("ttsModel"),
2057
+ placeholder: t("modelSearchPlaceholder"),
2058
+ emptyLabel: t("modelNoResults"),
2059
+ options: ttsModels,
2060
+ value: selectedTtsModel,
2061
+ disabled: !writable || !ttsEnabled || ttsModels.length === 0,
2062
+ onSelect: (model) => {
2063
+ const choices = ttsCatalog.filter((binding) => binding.model === model);
2064
+ const provider = choices.find((binding) => binding.isProviderDefault)?.provider ?? choices[0]?.provider;
2065
+ run("setting:ttsModel", async () => {
2066
+ await scope.set("ttsModel", model);
2067
+ if (provider !== void 0) await scope.set("ttsProvider", provider);
2068
+ });
2069
+ }
2070
+ })
2071
+ }),
2072
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
2073
+ label: t("ttsProvider"),
2074
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
2075
+ className: styles.input,
2076
+ value: selectedTtsProvider,
2077
+ disabled: !writable || !ttsEnabled || ttsProviders.length === 0,
2078
+ onChange: (event) => {
2079
+ write("ttsProvider", event.target.value);
2080
+ },
2081
+ children: ttsProviders.map((binding) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2082
+ value: binding.provider,
2083
+ children: binding.provider
2084
+ }, binding.provider))
2085
+ })
2086
+ }),
2087
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2088
+ className: styles.modelFull,
2089
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VoicePicker, {
2090
+ label: t("ttsVoice"),
2091
+ hint: t("modelVoiceSearchHint", { model: selectedTtsModel }),
2092
+ placeholder: t("modelVoiceSearchPlaceholder"),
2093
+ loadingLabel: t("loading"),
2094
+ emptyLabel: t("voiceNoResults"),
2095
+ query: modelVoiceSearch,
2096
+ voices: scopedVoiceResults.voices,
2097
+ selected: selectedVoiceOption,
2098
+ loading: scopedVoiceResults.loading,
2099
+ disabled: !writable || !ttsEnabled || selectedTtsBinding === void 0,
2100
+ onQuery: setModelVoiceSearch,
2101
+ onSelect: (voice) => {
2102
+ setModelVoiceSearch(voice.name);
2103
+ run("setting:ttsVoice", async () => {
2104
+ await scope.set("ttsVoice", voice.id);
2105
+ await scope.set("ttsModel", selectedTtsModel);
2106
+ await scope.set("ttsProvider", selectedTtsProvider);
2107
+ });
2108
+ }
2109
+ })
1637
2110
  })
1638
- }),
1639
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1640
- className: styles.primary,
1641
- type: "button",
1642
- disabled: busy !== null || topUpAmount < 5 || topUpAmount > 1e3,
1643
- onClick: () => {
1644
- run("top-up", async () => {
1645
- setTopUpUrl((await speechApi.topUp(topUpAmount)).url);
1646
- });
1647
- },
1648
- children: t("createLink")
1649
- }),
1650
- topUpUrl === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
1651
- className: styles.checkout,
1652
- href: topUpUrl,
1653
- target: "_blank",
1654
- rel: "noreferrer",
1655
- children: t("openCheckout")
1656
- })
1657
- ]
1658
- }) : null
1659
- ]
1660
- })] }) : null,
2111
+ ]
2112
+ }),
2113
+ ttsCatalog.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2114
+ className: styles.muted,
2115
+ children: t("ttsUnavailable")
2116
+ }) : null
2117
+ ]
2118
+ }),
2119
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2120
+ className: styles.card,
2121
+ children: [
2122
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: t("balance") }),
2123
+ client.status?.balance === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2124
+ className: styles.muted,
2125
+ children: client.status?.balanceError === void 0 ? t("loading") : t("balanceUnavailable")
2126
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", {
2127
+ className: styles.balanceValue,
2128
+ children: money(client.status.balance.paidUsd)
2129
+ }), client.status.balance.exhausted ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2130
+ className: styles.danger,
2131
+ children: t("emptyBalance")
2132
+ }) : client.status.balance.low ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2133
+ className: styles.warning,
2134
+ children: t("lowBalance")
2135
+ }) : null] }),
2136
+ connected ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2137
+ className: styles.topUp,
2138
+ children: [
2139
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
2140
+ label: t("amount"),
2141
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2142
+ className: styles.input,
2143
+ type: "number",
2144
+ min: 5,
2145
+ max: 1e3,
2146
+ step: 1,
2147
+ value: topUpAmount,
2148
+ onChange: (event) => {
2149
+ setTopUpAmount(Number(event.target.value));
2150
+ }
2151
+ })
2152
+ }),
2153
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2154
+ className: styles.primary,
2155
+ type: "button",
2156
+ disabled: busy !== null || topUpAmount < 5 || topUpAmount > 1e3,
2157
+ onClick: () => {
2158
+ run("top-up", async () => {
2159
+ setTopUpUrl((await speechApi.topUp(topUpAmount)).url);
2160
+ });
2161
+ },
2162
+ children: t("createLink")
2163
+ }),
2164
+ topUpUrl === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2165
+ className: styles.checkout,
2166
+ href: topUpUrl,
2167
+ target: "_blank",
2168
+ rel: "noreferrer",
2169
+ children: t("openCheckout")
2170
+ })
2171
+ ]
2172
+ }) : null
2173
+ ]
2174
+ })
2175
+ ] }) : null,
1661
2176
  message === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1662
2177
  className: styles.good,
1663
2178
  role: "status",
@@ -1672,6 +2187,813 @@ function SpeechSettings({ controller, scope, getLocale, t }) {
1672
2187
  });
1673
2188
  }
1674
2189
 
2190
+ //#endregion
2191
+ //#region src/client/spoken-controller.ts
2192
+ const EMPTY_PEAKS = Object.freeze(Array.from({ length: 48 }, (_, index) => .15 + .08 * Math.sin(index * .73) ** 2));
2193
+ function contentText(value) {
2194
+ if (!Array.isArray(value)) return "";
2195
+ return value.flatMap((block) => {
2196
+ if (block === null || typeof block !== "object") return [];
2197
+ const candidate = block;
2198
+ return candidate.type === "text" && typeof candidate.text === "string" ? [candidate.text] : [];
2199
+ }).join("\n").trim();
2200
+ }
2201
+ function boundSource(text, maximum) {
2202
+ if (text.length <= maximum) return text;
2203
+ const marker = "\n\n[...middle omitted for spoken-summary input...]\n\n";
2204
+ const remaining = maximum - 51;
2205
+ const beginning = Math.ceil(remaining / 2);
2206
+ return `${text.slice(0, beginning)}${marker}${text.slice(text.length - (remaining - beginning))}`;
2207
+ }
2208
+ /** Derive only the closing finalized assistant message for each completed turn. */
2209
+ function spokenSources(nodes, locale, requests = []) {
2210
+ const closings = /* @__PURE__ */ new Map();
2211
+ for (const node of nodes) {
2212
+ if (node.kind !== "assistant" || node.messageId === void 0 || node.interrupted === true) continue;
2213
+ const current = closings.get(node.turn);
2214
+ if (current === void 0 || node.seq > current.seq) closings.set(node.turn, node);
2215
+ }
2216
+ const result = [];
2217
+ for (const assistant of [...closings.values()].sort((a, b) => a.seq - b.seq)) {
2218
+ const answer = assistant.blocks.filter((block) => block.kind === "text").map((block) => block.text).join("\n").trim();
2219
+ if (answer.length === 0 || assistant.messageId === void 0) continue;
2220
+ let request$1 = "";
2221
+ for (let index = nodes.length - 1; index >= 0; index -= 1) {
2222
+ const candidate = nodes[index];
2223
+ if (candidate === void 0 || candidate.seq >= assistant.seq) continue;
2224
+ if (candidate.kind === "user" || candidate.kind === "steering") {
2225
+ request$1 = contentText(candidate.content);
2226
+ if (request$1.length > 0) break;
2227
+ }
2228
+ }
2229
+ const inspection = requests.find((request$2) => request$2.purpose === "assistant" && request$2.resultSeq === assistant.seq) ?? [...requests].reverse().find((request$2) => request$2.purpose === "assistant" && request$2.turn === assistant.turn && request$2.step === assistant.step && request$2.status === "complete");
2230
+ const recorded = assistant.requestConfig ?? assistant.provenance ?? inspection?.requestConfig ?? inspection?.provenance;
2231
+ const route = recorded === void 0 ? void 0 : {
2232
+ provider: recorded.provider,
2233
+ model: recorded.model,
2234
+ ..."reasoningEffort" in recorded && typeof recorded.reasoningEffort === "string" ? { reasoningEffort: recorded.reasoningEffort } : {}
2235
+ };
2236
+ result.push({
2237
+ messageId: String(assistant.messageId),
2238
+ request: boundSource(request$1, MAX_SUMMARY_REQUEST_CHARACTERS),
2239
+ answer: boundSource(answer, MAX_SUMMARY_ANSWER_CHARACTERS),
2240
+ locale,
2241
+ ...route === void 0 ? {} : { route }
2242
+ });
2243
+ }
2244
+ return result;
2245
+ }
2246
+ const INTERACTION_CUES = {
2247
+ en: "I need some feedback to keep going.",
2248
+ zh: "我需要你的反馈才能继续。",
2249
+ ja: "続けるには、フィードバックが必要です。",
2250
+ ko: "계속하려면 피드백이 필요해요.",
2251
+ es: "Necesito tus comentarios para continuar.",
2252
+ fr: "J’ai besoin de votre avis pour continuer.",
2253
+ de: "Ich brauche Ihre Rückmeldung, um fortzufahren.",
2254
+ pt: "Preciso do seu feedback para continuar.",
2255
+ it: "Ho bisogno del tuo feedback per continuare.",
2256
+ ru: "Мне нужна ваша обратная связь, чтобы продолжить.",
2257
+ ar: "أحتاج إلى ملاحظاتك لكي أتابع.",
2258
+ hi: "आगे बढ़ने के लिए मुझे आपकी प्रतिक्रिया चाहिए।",
2259
+ id: "Saya perlu masukan Anda untuk melanjutkan.",
2260
+ vi: "Tôi cần phản hồi của bạn để tiếp tục."
2261
+ };
2262
+ function interactionCue(locale) {
2263
+ return INTERACTION_CUES[locale.toLowerCase().split("-")[0] ?? ""] ?? INTERACTION_CUES.en;
2264
+ }
2265
+ function revokeAudioUrl(url) {
2266
+ if (url?.startsWith("blob:") === true) URL.revokeObjectURL(url);
2267
+ }
2268
+ function browserAudio() {
2269
+ const audio = new Audio();
2270
+ audio.preload = "metadata";
2271
+ return audio;
2272
+ }
2273
+ var SpokenSummaryController = class {
2274
+ audio;
2275
+ listeners = /* @__PURE__ */ new Set();
2276
+ states = /* @__PURE__ */ new Map();
2277
+ observed = /* @__PURE__ */ new Map();
2278
+ observedInteractions = /* @__PURE__ */ new Set();
2279
+ voiceCache = /* @__PURE__ */ new Map();
2280
+ activeMessageId;
2281
+ loadedMessageId;
2282
+ playbackGeneration = 0;
2283
+ snapshot = { messages: /* @__PURE__ */ new Map() };
2284
+ enabled = true;
2285
+ disposed = false;
2286
+ analyserContext;
2287
+ analyserSource;
2288
+ analyser;
2289
+ analyserData;
2290
+ analysisFrame;
2291
+ analysisTick = 0;
2292
+ pauseResolutionTimer;
2293
+ constructor(audioFactory = browserAudio) {
2294
+ this.audio = audioFactory();
2295
+ }
2296
+ subscribe = (listener) => {
2297
+ this.listeners.add(listener);
2298
+ return () => {
2299
+ this.listeners.delete(listener);
2300
+ };
2301
+ };
2302
+ getSnapshot = () => this.snapshot;
2303
+ setEnabled(enabled) {
2304
+ if (this.disposed || this.enabled === enabled) return;
2305
+ this.enabled = enabled;
2306
+ if (enabled) return;
2307
+ this.stopActive();
2308
+ this.clearAudioOwnership(true);
2309
+ for (const [messageId, state] of this.states) {
2310
+ state.abort?.abort();
2311
+ state.abort = void 0;
2312
+ state.requestGeneration += 1;
2313
+ revokeAudioUrl(state.audioUrl);
2314
+ if (state.ephemeral === true) {
2315
+ this.states.delete(messageId);
2316
+ continue;
2317
+ }
2318
+ Object.assign(state, {
2319
+ phase: "idle",
2320
+ summary: void 0,
2321
+ audioUrl: void 0,
2322
+ error: void 0,
2323
+ duration: 0,
2324
+ progress: 0,
2325
+ peaks: EMPTY_PEAKS,
2326
+ ended: false
2327
+ });
2328
+ }
2329
+ this.voiceCache.clear();
2330
+ this.publish();
2331
+ }
2332
+ mount(messageId) {
2333
+ const state = this.ensure(messageId);
2334
+ state.mounts += 1;
2335
+ state.releaseGeneration += 1;
2336
+ return () => {
2337
+ state.mounts = Math.max(0, state.mounts - 1);
2338
+ const releaseGeneration = ++state.releaseGeneration;
2339
+ queueMicrotask(() => {
2340
+ if (state.mounts === 0 && state.releaseGeneration === releaseGeneration) this.release(messageId);
2341
+ });
2342
+ };
2343
+ }
2344
+ observeSession(sessionKey, sources, settings) {
2345
+ if (this.disposed) return;
2346
+ this.setEnabled(settings.ttsEnabled);
2347
+ const ids = new Set(sources.map((source) => source.messageId));
2348
+ const previous = this.observed.get(sessionKey);
2349
+ if (previous === void 0) {
2350
+ this.observed.set(sessionKey, ids);
2351
+ for (const id of ids) this.ensure(id);
2352
+ this.publish();
2353
+ return;
2354
+ }
2355
+ this.observed.set(sessionKey, new Set([...previous, ...ids]));
2356
+ for (const source of sources) {
2357
+ if (previous.has(source.messageId)) continue;
2358
+ previous.add(source.messageId);
2359
+ if (settings.ttsEnabled) this.prepare(source, settings, settings.autoPlay);
2360
+ else this.ensure(source.messageId);
2361
+ }
2362
+ if (!settings.ttsEnabled) this.publish();
2363
+ }
2364
+ observeInteractions(sessionKey, interactionKeys, locale, settings) {
2365
+ if (this.disposed) return;
2366
+ this.setEnabled(settings.ttsEnabled);
2367
+ for (const key of interactionKeys) {
2368
+ const identity = `${sessionKey}\n${key}`;
2369
+ if (this.observedInteractions.has(identity)) continue;
2370
+ this.observedInteractions.add(identity);
2371
+ if (settings.ttsEnabled && settings.autoPlay) this.prepareCue(`interaction:${identity}`, interactionCue(locale), settings);
2372
+ }
2373
+ }
2374
+ async prepare(source, settings, autoPlay = false) {
2375
+ if (!this.enabled || !settings.ttsEnabled) return;
2376
+ const state = this.ensure(source.messageId);
2377
+ if (state.phase === "preparing" || state.phase === "ready" || state.phase === "playing") {
2378
+ if (state.phase === "ready" && !autoPlay) await this.play(source.messageId, true);
2379
+ return;
2380
+ }
2381
+ if (source.route === void 0) {
2382
+ this.fail(source.messageId, "The completed answer has no recorded LLM route.");
2383
+ return;
2384
+ }
2385
+ const binding = selectLocalizedTtsBinding(settings.bindings, source.locale, {
2386
+ ...settings.ttsModel === void 0 ? {} : { model: settings.ttsModel },
2387
+ ...settings.ttsProvider === void 0 ? {} : { provider: settings.ttsProvider }
2388
+ });
2389
+ if (binding === void 0) {
2390
+ this.fail(source.messageId, "No compatible synchronous text-to-speech model is available.");
2391
+ return;
2392
+ }
2393
+ const abort = new AbortController();
2394
+ state.abort?.abort();
2395
+ state.abort = abort;
2396
+ const requestGeneration = ++state.requestGeneration;
2397
+ this.patch(source.messageId, {
2398
+ phase: "preparing",
2399
+ error: void 0,
2400
+ progress: 0
2401
+ });
2402
+ try {
2403
+ const voice = await this.voiceFor(binding, settings.ttsVoice);
2404
+ if (voice === void 0) throw new Error("No compatible voice is available for this text-to-speech route.");
2405
+ const { summary } = await speechApi.summarize({
2406
+ request: source.request || "No preceding user prose was available.",
2407
+ answer: source.answer,
2408
+ locale: source.locale,
2409
+ route: source.route
2410
+ }, abort.signal);
2411
+ const prepared = await speechApi.prepareTts({
2412
+ text: summary,
2413
+ model: binding.model,
2414
+ provider: binding.provider,
2415
+ voice
2416
+ }, abort.signal);
2417
+ if (this.disposed || abort.signal.aborted || state.requestGeneration !== requestGeneration) return;
2418
+ state.abort = void 0;
2419
+ this.patch(source.messageId, {
2420
+ phase: "ready",
2421
+ summary,
2422
+ audioUrl: prepared.url,
2423
+ duration: 0,
2424
+ progress: 0,
2425
+ peaks: EMPTY_PEAKS,
2426
+ error: void 0,
2427
+ ended: false
2428
+ });
2429
+ if (autoPlay && this.activeMessageId === void 0) await this.play(source.messageId, false);
2430
+ } catch (error) {
2431
+ if (abort.signal.aborted || state.requestGeneration !== requestGeneration) return;
2432
+ state.abort = void 0;
2433
+ this.fail(source.messageId, error instanceof Error ? error.message : "Could not prepare the spoken summary.");
2434
+ }
2435
+ }
2436
+ async prepareCue(messageId, text, settings) {
2437
+ if (!this.enabled || !settings.ttsEnabled) return;
2438
+ const state = this.ensure(messageId);
2439
+ state.ephemeral = true;
2440
+ const binding = selectLocalizedTtsBinding(settings.bindings, text === INTERACTION_CUES.zh ? "zh" : "en", {
2441
+ ...settings.ttsModel === void 0 ? {} : { model: settings.ttsModel },
2442
+ ...settings.ttsProvider === void 0 ? {} : { provider: settings.ttsProvider }
2443
+ });
2444
+ if (binding === void 0) {
2445
+ this.release(messageId);
2446
+ return;
2447
+ }
2448
+ const abort = new AbortController();
2449
+ state.abort = abort;
2450
+ const requestGeneration = ++state.requestGeneration;
2451
+ try {
2452
+ const voice = await this.voiceFor(binding, settings.ttsVoice);
2453
+ if (voice === void 0) throw new Error("No compatible voice is available.");
2454
+ const prepared = await speechApi.prepareTts({
2455
+ text,
2456
+ model: binding.model,
2457
+ provider: binding.provider,
2458
+ voice
2459
+ }, abort.signal);
2460
+ if (this.disposed || abort.signal.aborted || state.requestGeneration !== requestGeneration) return;
2461
+ state.abort = void 0;
2462
+ this.patch(messageId, {
2463
+ phase: "ready",
2464
+ audioUrl: prepared.url,
2465
+ duration: 0,
2466
+ progress: 0,
2467
+ peaks: EMPTY_PEAKS,
2468
+ ended: false
2469
+ });
2470
+ if (this.activeMessageId !== void 0) {
2471
+ this.release(messageId);
2472
+ return;
2473
+ }
2474
+ await this.play(messageId, false);
2475
+ if (this.activeMessageId !== messageId) this.release(messageId);
2476
+ } catch {
2477
+ if (!abort.signal.aborted && state.requestGeneration === requestGeneration) this.release(messageId);
2478
+ }
2479
+ }
2480
+ async play(messageId, explicit) {
2481
+ const state = this.states.get(messageId);
2482
+ if (state?.audioUrl === void 0 || this.disposed || !this.enabled) return;
2483
+ if (!explicit && this.activeMessageId !== void 0 && this.activeMessageId !== messageId) return;
2484
+ if (this.activeMessageId !== void 0 && this.activeMessageId !== messageId) this.stopActive();
2485
+ const generation = ++this.playbackGeneration;
2486
+ this.activeMessageId = messageId;
2487
+ this.clearPauseResolution();
2488
+ if (this.loadedMessageId !== messageId) {
2489
+ this.audio.pause();
2490
+ this.audio.src = state.audioUrl;
2491
+ this.loadedMessageId = messageId;
2492
+ this.audio.currentTime = state.progress > 0 && (state.duration <= 0 || state.progress < state.duration * .98) ? state.progress : 0;
2493
+ } else if (state.ended === true) this.audio.currentTime = 0;
2494
+ this.audio.onended = () => {
2495
+ this.clearPauseResolution();
2496
+ this.finishPlayback(generation, messageId, true);
2497
+ };
2498
+ this.audio.onerror = () => {
2499
+ if (!this.owns(generation, messageId)) return;
2500
+ this.fail(messageId, "The spoken summary could not be played.");
2501
+ this.clearAudioOwnership(true);
2502
+ };
2503
+ this.audio.onpause = () => {
2504
+ if (!this.owns(generation, messageId)) return;
2505
+ this.clearPauseResolution();
2506
+ this.pauseResolutionTimer = setTimeout(() => {
2507
+ this.pauseResolutionTimer = void 0;
2508
+ this.finishPlayback(generation, messageId, this.audio.ended);
2509
+ }, 80);
2510
+ };
2511
+ this.audio.ontimeupdate = () => {
2512
+ if (!this.owns(generation, messageId)) return;
2513
+ const duration = Number.isFinite(this.audio.duration) ? this.audio.duration : state.duration;
2514
+ this.patch(messageId, {
2515
+ progress: Math.max(0, this.audio.currentTime),
2516
+ duration: Math.max(0, duration)
2517
+ });
2518
+ };
2519
+ this.patch(messageId, {
2520
+ phase: "playing",
2521
+ ended: false
2522
+ });
2523
+ try {
2524
+ await this.audio.play();
2525
+ if (!this.owns(generation, messageId)) return;
2526
+ this.startAnalysis(messageId, generation);
2527
+ } catch {
2528
+ if (!this.owns(generation, messageId)) return;
2529
+ this.patch(messageId, { phase: "ready" });
2530
+ this.clearAudioOwnership(false);
2531
+ }
2532
+ }
2533
+ pause(messageId) {
2534
+ if (this.activeMessageId !== messageId) return;
2535
+ this.patch(messageId, {
2536
+ phase: "ready",
2537
+ progress: Math.max(0, this.audio.currentTime),
2538
+ ended: false
2539
+ });
2540
+ ++this.playbackGeneration;
2541
+ this.audio.pause();
2542
+ this.clearAudioOwnership(false);
2543
+ }
2544
+ retry(source, settings) {
2545
+ if (!this.enabled || !settings.ttsEnabled) return;
2546
+ const state = this.ensure(source.messageId);
2547
+ if (this.loadedMessageId === source.messageId) this.clearAudioOwnership(true);
2548
+ state.abort?.abort();
2549
+ state.requestGeneration += 1;
2550
+ revokeAudioUrl(state.audioUrl);
2551
+ Object.assign(state, {
2552
+ phase: "idle",
2553
+ audioUrl: void 0,
2554
+ summary: void 0,
2555
+ error: void 0,
2556
+ progress: 0,
2557
+ duration: 0,
2558
+ peaks: EMPTY_PEAKS,
2559
+ ended: false
2560
+ });
2561
+ this.publish();
2562
+ this.prepare(source, settings, false).then(() => this.play(source.messageId, true));
2563
+ }
2564
+ dispose() {
2565
+ if (this.disposed) return;
2566
+ this.disposed = true;
2567
+ this.stopActive();
2568
+ this.clearAudioOwnership(true);
2569
+ for (const state of this.states.values()) {
2570
+ state.abort?.abort();
2571
+ revokeAudioUrl(state.audioUrl);
2572
+ }
2573
+ this.states.clear();
2574
+ this.observed.clear();
2575
+ this.observedInteractions.clear();
2576
+ this.voiceCache.clear();
2577
+ this.analyserContext?.close().catch(() => {});
2578
+ this.analyserContext = void 0;
2579
+ this.analyserSource = void 0;
2580
+ this.analyser = void 0;
2581
+ this.analyserData = void 0;
2582
+ this.listeners.clear();
2583
+ }
2584
+ async voiceFor(binding, preferred) {
2585
+ if (preferred !== void 0 && preferred.length > 0) return preferred;
2586
+ const key = `${binding.provider}\n${binding.model}`;
2587
+ let promise = this.voiceCache.get(key);
2588
+ if (promise === void 0) {
2589
+ promise = speechApi.voices({
2590
+ model: binding.aliases?.[0] ?? binding.model,
2591
+ provider: binding.provider
2592
+ }).then((result) => result.voices.map((voice) => voice.id)).catch((error) => {
2593
+ this.voiceCache.delete(key);
2594
+ throw error;
2595
+ });
2596
+ this.voiceCache.set(key, promise);
2597
+ }
2598
+ const voices = await promise;
2599
+ const localized = preferredTtsSelection(binding.model.startsWith("minimax/") ? "zh" : "en");
2600
+ return binding.model === localized.model && binding.provider === localized.provider && voices.includes(localized.voice) ? localized.voice : binding.defaultVoice !== void 0 ? binding.defaultVoice : voices[0];
2601
+ }
2602
+ startAnalysis(messageId, generation) {
2603
+ this.stopAnalysis();
2604
+ this.resetAnalyser();
2605
+ this.ensureAnalyser();
2606
+ if (typeof requestAnimationFrame === "undefined") return;
2607
+ const frame = (time) => {
2608
+ if (!this.owns(generation, messageId)) return;
2609
+ if (time - this.analysisTick >= 45) {
2610
+ this.analysisTick = time;
2611
+ this.patch(messageId, { peaks: this.readAmplitude(time) });
2612
+ }
2613
+ this.analysisFrame = requestAnimationFrame(frame);
2614
+ };
2615
+ this.analysisFrame = requestAnimationFrame(frame);
2616
+ }
2617
+ ensureAnalyser() {
2618
+ if (this.analyser !== void 0) {
2619
+ this.analyserContext?.resume().catch(() => {});
2620
+ return;
2621
+ }
2622
+ if (typeof AudioContext === "undefined" || this.audio.captureStream === void 0) return;
2623
+ try {
2624
+ const context = new AudioContext();
2625
+ const analyser = context.createAnalyser();
2626
+ analyser.fftSize = 128;
2627
+ analyser.smoothingTimeConstant = .62;
2628
+ const source = context.createMediaStreamSource(this.audio.captureStream());
2629
+ source.connect(analyser);
2630
+ this.analyserContext = context;
2631
+ this.analyserSource = source;
2632
+ this.analyser = analyser;
2633
+ this.analyserData = new Uint8Array(analyser.fftSize);
2634
+ context.resume().catch(() => {});
2635
+ } catch {
2636
+ this.analyserContext = void 0;
2637
+ this.analyserSource = void 0;
2638
+ this.analyser = void 0;
2639
+ this.analyserData = void 0;
2640
+ }
2641
+ }
2642
+ readAmplitude(time) {
2643
+ if (this.analyser !== void 0 && this.analyserData !== void 0) {
2644
+ this.analyser.getByteTimeDomainData(this.analyserData);
2645
+ let signalPeak = 0;
2646
+ const peaks = Array.from({ length: 48 }, (_, index) => {
2647
+ const start = Math.floor(index * this.analyserData.length / 48);
2648
+ const end = Math.max(start + 1, Math.floor((index + 1) * this.analyserData.length / 48));
2649
+ let amplitude = 0;
2650
+ for (let sample = start; sample < end; sample += 1) amplitude = Math.max(amplitude, Math.abs((this.analyserData[sample] ?? 128) - 128) / 128);
2651
+ signalPeak = Math.max(signalPeak, amplitude);
2652
+ return Math.min(1, .06 + Math.sqrt(amplitude) * 2.4);
2653
+ });
2654
+ if (signalPeak > .004) return peaks;
2655
+ }
2656
+ return this.activityPeaks(time);
2657
+ }
2658
+ activityPeaks(time) {
2659
+ return Array.from({ length: 48 }, (_, index) => {
2660
+ const carrier = .5 + .5 * Math.sin(time * .014 + index * 1.37);
2661
+ const envelope = .5 + .5 * Math.sin(time * .0037 + index * .29);
2662
+ return .1 + .52 * carrier * envelope;
2663
+ });
2664
+ }
2665
+ stopAnalysis() {
2666
+ if (this.analysisFrame !== void 0 && typeof cancelAnimationFrame !== "undefined") cancelAnimationFrame(this.analysisFrame);
2667
+ this.analysisFrame = void 0;
2668
+ this.analysisTick = 0;
2669
+ }
2670
+ clearPauseResolution() {
2671
+ if (this.pauseResolutionTimer !== void 0) clearTimeout(this.pauseResolutionTimer);
2672
+ this.pauseResolutionTimer = void 0;
2673
+ }
2674
+ resetAnalyser() {
2675
+ this.analyserContext?.close().catch(() => {});
2676
+ this.analyserContext = void 0;
2677
+ this.analyserSource = void 0;
2678
+ this.analyser = void 0;
2679
+ this.analyserData = void 0;
2680
+ }
2681
+ ensure(messageId) {
2682
+ let state = this.states.get(messageId);
2683
+ if (state === void 0) {
2684
+ state = {
2685
+ phase: "idle",
2686
+ duration: 0,
2687
+ progress: 0,
2688
+ peaks: EMPTY_PEAKS,
2689
+ requestGeneration: 0,
2690
+ mounts: 0,
2691
+ releaseGeneration: 0
2692
+ };
2693
+ this.states.set(messageId, state);
2694
+ }
2695
+ return state;
2696
+ }
2697
+ patch(messageId, patch) {
2698
+ Object.assign(this.ensure(messageId), patch);
2699
+ this.publish();
2700
+ }
2701
+ fail(messageId, message) {
2702
+ this.patch(messageId, {
2703
+ phase: "error",
2704
+ error: message
2705
+ });
2706
+ }
2707
+ owns(generation, messageId) {
2708
+ return this.playbackGeneration === generation && this.activeMessageId === messageId;
2709
+ }
2710
+ finishPlayback(generation, messageId, ended) {
2711
+ if (!this.owns(generation, messageId)) return;
2712
+ const state = this.states.get(messageId);
2713
+ const measuredDuration = Number.isFinite(this.audio.duration) ? Math.max(0, this.audio.duration) : state?.duration ?? 0;
2714
+ if (state !== void 0) Object.assign(state, {
2715
+ phase: "ready",
2716
+ duration: measuredDuration,
2717
+ progress: ended ? measuredDuration : this.audio.currentTime,
2718
+ ended
2719
+ });
2720
+ ++this.playbackGeneration;
2721
+ if (state?.ephemeral === true) {
2722
+ this.clearAudioOwnership(true);
2723
+ revokeAudioUrl(state.audioUrl);
2724
+ this.states.delete(messageId);
2725
+ } else this.clearAudioOwnership(false);
2726
+ this.publish();
2727
+ }
2728
+ stopActive() {
2729
+ const messageId = this.activeMessageId;
2730
+ if (messageId === void 0) return;
2731
+ const state = this.states.get(messageId);
2732
+ if (state !== void 0 && state.phase === "playing") Object.assign(state, {
2733
+ phase: "ready",
2734
+ progress: Math.max(0, this.audio.currentTime),
2735
+ ended: false
2736
+ });
2737
+ ++this.playbackGeneration;
2738
+ this.audio.pause();
2739
+ this.clearAudioOwnership(false);
2740
+ this.publish();
2741
+ }
2742
+ clearAudioOwnership(unload) {
2743
+ this.stopAnalysis();
2744
+ this.clearPauseResolution();
2745
+ this.activeMessageId = void 0;
2746
+ this.audio.onended = null;
2747
+ this.audio.onerror = null;
2748
+ this.audio.onpause = null;
2749
+ this.audio.ontimeupdate = null;
2750
+ if (unload) {
2751
+ this.loadedMessageId = void 0;
2752
+ this.audio.removeAttribute("src");
2753
+ this.audio.load();
2754
+ }
2755
+ this.publish();
2756
+ }
2757
+ release(messageId) {
2758
+ const state = this.states.get(messageId);
2759
+ if (state === void 0) return;
2760
+ if (this.activeMessageId === messageId) this.stopActive();
2761
+ if (this.loadedMessageId === messageId) this.clearAudioOwnership(true);
2762
+ state.abort?.abort();
2763
+ state.requestGeneration += 1;
2764
+ revokeAudioUrl(state.audioUrl);
2765
+ this.states.delete(messageId);
2766
+ this.publish();
2767
+ }
2768
+ publish() {
2769
+ this.snapshot = {
2770
+ messages: new Map([...this.states].map(([id, state]) => [id, {
2771
+ phase: state.phase,
2772
+ ...state.summary === void 0 ? {} : { summary: state.summary },
2773
+ ...state.audioUrl === void 0 ? {} : { audioUrl: state.audioUrl },
2774
+ duration: state.duration,
2775
+ progress: state.progress,
2776
+ peaks: state.peaks,
2777
+ ...state.error === void 0 ? {} : { error: state.error },
2778
+ ...state.ended === void 0 ? {} : { ended: state.ended }
2779
+ }])),
2780
+ ...this.activeMessageId === void 0 ? {} : { activeMessageId: this.activeMessageId }
2781
+ };
2782
+ for (const listener of this.listeners) listener();
2783
+ }
2784
+ };
2785
+
2786
+ //#endregion
2787
+ //#region src/client/SpokenSummary.tsx
2788
+ const EMPTY_REQUESTS = [];
2789
+ function preparationSettings(controller, settings, locale) {
2790
+ const fallback = preferredTtsSelection(locale);
2791
+ const selection = settings?.ttsModel !== void 0 && settings.ttsProvider !== void 0 && settings.ttsVoice !== void 0 ? {
2792
+ model: settings.ttsModel,
2793
+ provider: settings.ttsProvider,
2794
+ voice: settings.ttsVoice
2795
+ } : fallback;
2796
+ return {
2797
+ ttsEnabled: settings?.ttsEnabled ?? true,
2798
+ autoPlay: settings?.autoPlay ?? true,
2799
+ bindings: controller.getSnapshot().catalog?.ttsBindings ?? [],
2800
+ ttsModel: selection.model,
2801
+ ttsProvider: selection.provider,
2802
+ ttsVoice: selection.voice
2803
+ };
2804
+ }
2805
+ function PlayIcon({ pause = false }) {
2806
+ return pause ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
2807
+ viewBox: "0 0 20 20",
2808
+ "aria-hidden": "true",
2809
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M6 5h3v10H6zM11 5h3v10h-3z" })
2810
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
2811
+ viewBox: "0 0 20 20",
2812
+ "aria-hidden": "true",
2813
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m7 4 9 6-9 6z" })
2814
+ });
2815
+ }
2816
+ function Waveform({ peaks, preparing, playing }) {
2817
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2818
+ className: styles.summaryWaveform,
2819
+ "aria-hidden": "true",
2820
+ "data-preparing": preparing ? "true" : void 0,
2821
+ "data-playing": playing ? "true" : void 0,
2822
+ children: peaks.map((peak, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", { style: { "--dsh-speech-peak": String(peak) } }, index))
2823
+ });
2824
+ }
2825
+ function SpokenSummaryTail({ controller, spoken, scope, getLocale, seq, useSession, t }) {
2826
+ const nodes = useSession((snapshot) => snapshot.nodes);
2827
+ const requests = useSession((snapshot) => {
2828
+ const views = snapshot.views;
2829
+ if (views?.get === void 0) return EMPTY_REQUESTS;
2830
+ return views.get("trajectory")?.requests ?? EMPTY_REQUESTS;
2831
+ });
2832
+ const client = (0, react.useSyncExternalStore)(controller.subscribe, controller.getSnapshot);
2833
+ const scopeState = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot());
2834
+ const spokenState = (0, react.useSyncExternalStore)(spoken.subscribe, spoken.getSnapshot);
2835
+ const locale = getLocale();
2836
+ const sources = (0, react.useMemo)(() => spokenSources(nodes, locale, requests), [
2837
+ nodes,
2838
+ locale,
2839
+ requests
2840
+ ]);
2841
+ const closing = nodes.find((node) => node.kind === "assistant" && node.seq === seq);
2842
+ const id = closing?.kind === "assistant" && closing.messageId !== void 0 ? String(closing.messageId) : `turn-tail:${String(seq)}`;
2843
+ const source = sources.find((candidate) => candidate.messageId === id);
2844
+ const settings = (0, react.useMemo)(() => preparationSettings(controller, scopeState.value, locale), [
2845
+ controller,
2846
+ scopeState.value,
2847
+ client.catalog,
2848
+ locale
2849
+ ]);
2850
+ const state = spokenState.messages.get(id) ?? {
2851
+ phase: "idle",
2852
+ duration: 0,
2853
+ progress: 0,
2854
+ peaks: []
2855
+ };
2856
+ const latestAudioMessageId = [...sources].reverse().find((candidate) => spokenState.messages.get(candidate.messageId)?.audioUrl !== void 0)?.messageId;
2857
+ const latestSourceMessageId = sources.at(-1)?.messageId;
2858
+ const legacyAutoplayWasUsed = scopeState.user.autoPlay !== void 0;
2859
+ const autoplayInlineRevealed = scopeState.value?.autoplayInlineRevealed === true || legacyAutoplayWasUsed;
2860
+ const autoplayHostMessageId = latestAudioMessageId ?? (autoplayInlineRevealed ? latestSourceMessageId : void 0);
2861
+ (0, react.useEffect)(() => spoken.mount(id), [spoken, id]);
2862
+ (0, react.useEffect)(() => {
2863
+ if (autoplayHostMessageId !== id || scopeState.value?.autoplayInlineRevealed === true || !scopeState.writable) return;
2864
+ scope.set("autoplayInlineRevealed", true);
2865
+ }, [
2866
+ autoplayHostMessageId,
2867
+ id,
2868
+ scope,
2869
+ scopeState.value?.autoplayInlineRevealed,
2870
+ scopeState.writable
2871
+ ]);
2872
+ if (!settings.ttsEnabled || source === void 0) return null;
2873
+ const preparing = state.phase === "preparing";
2874
+ const unavailable = source.route === void 0 || client.catalog !== void 0 && settings.bindings.length === 0;
2875
+ const replay = state.phase === "ready" && state.ended === true;
2876
+ const paused = state.phase === "ready" && state.progress > 0 && !replay;
2877
+ const label = unavailable ? t("summaryUnavailable") : state.phase === "playing" ? t("summaryPause") : preparing ? t("summaryPreparing") : state.phase === "error" ? t("summaryRetry") : replay ? t("summaryReplay") : paused ? t("summaryResume") : state.audioUrl === void 0 ? t("summaryGenerate") : t("summaryPlay");
2878
+ const activate = () => {
2879
+ if (unavailable) return;
2880
+ if (state.phase === "playing") {
2881
+ spoken.pause(id);
2882
+ return;
2883
+ }
2884
+ if (state.phase === "ready") {
2885
+ spoken.play(id, true);
2886
+ return;
2887
+ }
2888
+ if (state.phase === "error") {
2889
+ spoken.retry(source, settings);
2890
+ return;
2891
+ }
2892
+ spoken.prepare(source, settings, false).then(() => spoken.play(id, true));
2893
+ };
2894
+ const showLabel = state.phase === "idle" || state.phase === "error" || unavailable;
2895
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2896
+ className: styles.summaryPlayer,
2897
+ "data-phase": state.phase,
2898
+ children: [
2899
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2900
+ className: styles.summaryControl,
2901
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2902
+ type: "button",
2903
+ className: styles.summaryButton,
2904
+ disabled: preparing || unavailable,
2905
+ "aria-label": label,
2906
+ title: label,
2907
+ onClick: activate,
2908
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PlayIcon, { pause: state.phase === "playing" })
2909
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Waveform, {
2910
+ peaks: state.peaks.length === 48 ? state.peaks : Array.from({ length: 48 }, () => .12),
2911
+ preparing,
2912
+ playing: state.phase === "playing"
2913
+ })]
2914
+ }),
2915
+ showLabel ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2916
+ className: styles.summaryLabel,
2917
+ children: label
2918
+ }) : null,
2919
+ autoplayHostMessageId !== id ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2920
+ type: "button",
2921
+ className: styles.autoplayToggle,
2922
+ role: "switch",
2923
+ "aria-checked": scopeState.value?.autoPlay ?? true,
2924
+ "aria-label": `${t("autoplayInline")}: ${scopeState.value?.autoPlay ?? true ? t("autoplayOn") : t("autoplayOff")}`,
2925
+ title: `${t("autoplayInline")}: ${scopeState.value?.autoPlay ?? true ? t("autoplayOn") : t("autoplayOff")}`,
2926
+ disabled: !scopeState.writable,
2927
+ onClick: () => {
2928
+ scope.set("autoPlay", !(scopeState.value?.autoPlay ?? true));
2929
+ },
2930
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2931
+ className: styles.switchTrack,
2932
+ "aria-hidden": "true",
2933
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", {})
2934
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("autoplayInline") })]
2935
+ }),
2936
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2937
+ className: styles.srOnly,
2938
+ role: "status",
2939
+ "aria-live": "polite",
2940
+ children: state.phase === "error" ? state.error ?? t("summaryError") : label
2941
+ })
2942
+ ]
2943
+ });
2944
+ }
2945
+ function SpokenSessionObserver({ controller, spoken, scope, getLocale, sessionId, useSession }) {
2946
+ const state = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot());
2947
+ const client = (0, react.useSyncExternalStore)(controller.subscribe, controller.getSnapshot);
2948
+ const pending = useSession((snapshot) => snapshot.pending);
2949
+ const nodes = useSession((snapshot) => snapshot.nodes);
2950
+ const requests = useSession((snapshot) => {
2951
+ const views = snapshot.views;
2952
+ if (views?.get === void 0) return EMPTY_REQUESTS;
2953
+ return views.get("trajectory")?.requests ?? EMPTY_REQUESTS;
2954
+ });
2955
+ const speechLocale = state.value?.language === void 0 || state.value.language === "auto" ? getLocale() : state.value.language;
2956
+ const settings = (0, react.useMemo)(() => preparationSettings(controller, state.value, speechLocale), [
2957
+ client.catalog,
2958
+ controller,
2959
+ speechLocale,
2960
+ state.value
2961
+ ]);
2962
+ const sources = (0, react.useMemo)(() => spokenSources(nodes, speechLocale, requests), [
2963
+ nodes,
2964
+ requests,
2965
+ speechLocale
2966
+ ]);
2967
+ (0, react.useEffect)(() => {
2968
+ controller.ensureMetadata();
2969
+ }, [controller]);
2970
+ (0, react.useEffect)(() => {
2971
+ spoken.setEnabled(settings.ttsEnabled);
2972
+ }, [settings.ttsEnabled, spoken]);
2973
+ (0, react.useEffect)(() => {
2974
+ if (client.catalog === void 0) return;
2975
+ spoken.observeSession(String(sessionId), sources, settings);
2976
+ }, [
2977
+ client.catalog,
2978
+ sessionId,
2979
+ settings,
2980
+ sources,
2981
+ spoken
2982
+ ]);
2983
+ (0, react.useEffect)(() => {
2984
+ if (client.catalog === void 0) return;
2985
+ spoken.observeInteractions(String(sessionId), pending.map((item) => item.key), speechLocale, settings);
2986
+ }, [
2987
+ client.catalog,
2988
+ pending,
2989
+ sessionId,
2990
+ settings,
2991
+ speechLocale,
2992
+ spoken
2993
+ ]);
2994
+ return null;
2995
+ }
2996
+
1675
2997
  //#endregion
1676
2998
  //#region src/client/locales.ts
1677
2999
  const en = {
@@ -1698,6 +3020,8 @@ const en = {
1698
3020
  disconnect: "Disconnect",
1699
3021
  settings: "Recognition",
1700
3022
  model: "STT model",
3023
+ modelSearchPlaceholder: "Search or select a model",
3024
+ modelNoResults: "No matching models",
1701
3025
  provider: "Provider",
1702
3026
  language: "Language",
1703
3027
  invalidLanguage: "Enter Auto or a valid language tag such as en, zh-CN, or ja.",
@@ -1735,7 +3059,34 @@ const en = {
1735
3059
  disconnectedMic: "Connect AllModels in Settings → Speech",
1736
3060
  emptyMic: "Top up your AllModels balance in Settings → Speech",
1737
3061
  anotherMic: "The microphone is active in another session",
1738
- liveModel: "{provider} · {model}"
3062
+ liveModel: "{provider} · {model}",
3063
+ spokenSummaries: "Spoken summaries",
3064
+ spokenSummariesHint: "Completed answers are summarized with their recorded LLM route, then spoken through AllModels. Nothing is added to the conversation.",
3065
+ ttsEnabled: "Text-to-speech summaries",
3066
+ ttsModel: "TTS model",
3067
+ ttsProvider: "TTS provider",
3068
+ ttsVoice: "Voice",
3069
+ voiceSearch: "Search AllModels voices",
3070
+ voiceSearchHint: "Optional. Choosing a voice automatically selects its compatible model and provider.",
3071
+ voiceSearchPlaceholder: "Search by name, style, accent, or description",
3072
+ modelVoiceSearchHint: "Search only voices compatible with {model}.",
3073
+ modelVoiceSearchPlaceholder: "Type to search voices for this model",
3074
+ voiceNoResults: "No matching voices",
3075
+ ttsUnavailable: "No compatible synchronous MP3 text-to-speech route is currently advertised.",
3076
+ autoplayGlobal: "Autoplay",
3077
+ autoplayShort: "Spoken summaries",
3078
+ autoplayInline: "Auto play",
3079
+ autoplayOn: "Autoplay on",
3080
+ autoplayOff: "Autoplay off",
3081
+ summaryPlay: "Play summary",
3082
+ summaryGenerate: "Generate and play summary",
3083
+ summaryPause: "Pause summary",
3084
+ summaryResume: "Resume summary",
3085
+ summaryReplay: "Replay summary",
3086
+ summaryRetry: "Retry spoken summary",
3087
+ summaryPreparing: "Preparing spoken summary…",
3088
+ summaryError: "Could not prepare the spoken summary.",
3089
+ summaryUnavailable: "Spoken summary unavailable for this answer"
1739
3090
  };
1740
3091
  const zh = {
1741
3092
  nav: "语音",
@@ -1761,6 +3112,8 @@ const zh = {
1761
3112
  disconnect: "断开连接",
1762
3113
  settings: "语音识别",
1763
3114
  model: "STT 模型",
3115
+ modelSearchPlaceholder: "搜索或选择模型",
3116
+ modelNoResults: "没有匹配的模型",
1764
3117
  provider: "服务商",
1765
3118
  language: "语言",
1766
3119
  invalidLanguage: "请输入“自动”或有效的语言标签,例如 en、zh-CN 或 ja。",
@@ -1798,7 +3151,34 @@ const zh = {
1798
3151
  disconnectedMic: "请前往“设置 → 语音”连接 AllModels",
1799
3152
  emptyMic: "请前往“设置 → 语音”为 AllModels 充值",
1800
3153
  anotherMic: "麦克风正在另一个会话中使用",
1801
- liveModel: "{provider} · {model}"
3154
+ liveModel: "{provider} · {model}",
3155
+ spokenSummaries: "语音摘要",
3156
+ spokenSummariesHint: "使用回答中记录的 LLM 路由生成简短摘要,再通过 AllModels 播放。不会向对话添加任何内容。",
3157
+ ttsEnabled: "文本转语音摘要",
3158
+ ttsModel: "TTS 模型",
3159
+ ttsProvider: "TTS 服务商",
3160
+ ttsVoice: "声音",
3161
+ voiceSearch: "搜索 AllModels 声音",
3162
+ voiceSearchHint: "可选。选择声音后会自动设置兼容的模型和服务商。",
3163
+ voiceSearchPlaceholder: "按名称、风格、口音或描述搜索",
3164
+ modelVoiceSearchHint: "仅搜索与 {model} 兼容的声音。",
3165
+ modelVoiceSearchPlaceholder: "输入关键词搜索此模型的声音",
3166
+ voiceNoResults: "没有匹配的声音",
3167
+ ttsUnavailable: "目前没有兼容的同步 MP3 文本转语音路由。",
3168
+ autoplayGlobal: "自动播放",
3169
+ autoplayShort: "语音摘要",
3170
+ autoplayInline: "自动播放",
3171
+ autoplayOn: "自动播放已开启",
3172
+ autoplayOff: "自动播放已关闭",
3173
+ summaryPlay: "播放摘要",
3174
+ summaryGenerate: "生成并播放摘要",
3175
+ summaryPause: "暂停摘要",
3176
+ summaryResume: "继续播放摘要",
3177
+ summaryReplay: "重新播放摘要",
3178
+ summaryRetry: "重试语音摘要",
3179
+ summaryPreparing: "正在准备语音摘要…",
3180
+ summaryError: "无法准备语音摘要。",
3181
+ summaryUnavailable: "此回答无法生成语音摘要"
1802
3182
  };
1803
3183
 
1804
3184
  //#endregion
@@ -1810,6 +3190,7 @@ const inject = [
1810
3190
  ];
1811
3191
  function apply(ctx) {
1812
3192
  const controller = new SpeechController();
3193
+ const spoken = new SpokenSummaryController();
1813
3194
  const scope = ctx.settingsScope.bind({ namespace: "dsh-speech" });
1814
3195
  const getLocale = () => ctx.locale.getLocale().active;
1815
3196
  ctx.effect(() => ctx.locale.register("speech", {
@@ -1848,7 +3229,9 @@ function apply(ctx) {
1848
3229
  }, "dsh-speech: settings navigation icon");
1849
3230
  ctx.effect(() => () => {
1850
3231
  controller.dispose();
1851
- }, "dsh-speech: browser controller");
3232
+ spoken.dispose();
3233
+ }, "dsh-speech: browser controllers");
3234
+ controller.ensureMetadata();
1852
3235
  ctx.slots.inject("settings.section", () => ctx.slots.register({
1853
3236
  name: "settings.section",
1854
3237
  id: "speech",
@@ -1894,11 +3277,39 @@ function apply(ctx) {
1894
3277
  getLocale
1895
3278
  })
1896
3279
  }, SpeechInputDock);
3280
+ const spokenTail = scoped.slots.register({
3281
+ name: "conversation.chat.turnTail",
3282
+ priority: 20,
3283
+ select: (owner) => ({
3284
+ turn: owner.turn.turn,
3285
+ seq: owner.seq
3286
+ }),
3287
+ locale: "speech",
3288
+ inject: () => ({
3289
+ controller,
3290
+ spoken,
3291
+ scope,
3292
+ getLocale
3293
+ })
3294
+ }, SpokenSummaryTail);
3295
+ const spokenObserver = scoped.slots.register({
3296
+ name: "conversation.composer.dock",
3297
+ id: "spoken-summary-observer",
3298
+ order: 110,
3299
+ inject: () => ({
3300
+ controller,
3301
+ spoken,
3302
+ scope,
3303
+ getLocale
3304
+ })
3305
+ }, SpokenSessionObserver);
1897
3306
  return () => {
1898
3307
  controller.detachBlocks(blocks);
1899
3308
  mic();
1900
3309
  dock();
1901
3310
  inputDock();
3311
+ spokenTail();
3312
+ spokenObserver();
1902
3313
  };
1903
3314
  });
1904
3315
  }