@allmodels/dsh-speech 0.1.1 → 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;
@@ -330,7 +404,8 @@ var SpeechController = class {
330
404
  amplitude: 0,
331
405
  metadataLoading: false,
332
406
  microphones: [],
333
- switchingMicrophone: false
407
+ switchingMicrophone: false,
408
+ hasTranscript: false
334
409
  };
335
410
  listeners = /* @__PURE__ */ new Set();
336
411
  blocks;
@@ -408,6 +483,12 @@ var SpeechController = class {
408
483
  }, 5e3);
409
484
  await stoppingCapture;
410
485
  }
486
+ async stopAndSend() {
487
+ const active = this.active;
488
+ if (active === void 0 || active.finished || !this.snapshot.hasTranscript) return;
489
+ active.sendAfterFinish = true;
490
+ await this.stop();
491
+ }
411
492
  cancel() {
412
493
  const active = this.active;
413
494
  if (active === void 0 || active.finished) return;
@@ -473,7 +554,8 @@ var SpeechController = class {
473
554
  microphones: [],
474
555
  activeMicrophoneId: void 0,
475
556
  switchingMicrophone: false,
476
- microphoneError: void 0
557
+ microphoneError: void 0,
558
+ hasTranscript: false
477
559
  });
478
560
  try {
479
561
  await new Promise((resolve, reject) => {
@@ -555,6 +637,7 @@ var SpeechController = class {
555
637
  ...typeof message.languageCode === "string" ? { languageCode: message.languageCode } : {}
556
638
  });
557
639
  active.inputActions.setDraft(transcriptText(active.transcript));
640
+ if (message.text.trim().length > 0 && !this.snapshot.hasTranscript) this.publish({ hasTranscript: true });
558
641
  if (message.type === "final" && this.snapshot.phase === "finalizing") {
559
642
  if (active.finalTimer !== void 0) clearTimeout(active.finalTimer);
560
643
  active.finalTimer = setTimeout(() => {
@@ -595,8 +678,10 @@ var SpeechController = class {
595
678
  activeMicrophoneId: void 0,
596
679
  switchingMicrophone: false,
597
680
  microphoneError: void 0,
681
+ hasTranscript: false,
598
682
  ...error === void 0 ? { error: void 0 } : { error }
599
683
  });
684
+ if (error === void 0 && active.sendAfterFinish === true) active.inputActions.submit();
600
685
  this.ensureMetadata(true);
601
686
  }
602
687
  publish(patch) {
@@ -667,7 +752,9 @@ const styles = {
667
752
  recordingTrack: "dsh-speech-recording-track",
668
753
  recordingCanvas: "dsh-speech-recording-canvas",
669
754
  recordingCancel: "dsh-speech-recording-cancel",
755
+ recordingActions: "dsh-speech-recording-actions",
670
756
  recordingStop: "dsh-speech-recording-stop",
757
+ recordingSend: "dsh-speech-recording-send",
671
758
  recordingProgress: "dsh-speech-recording-progress",
672
759
  deviceDock: "dsh-speech-device-dock",
673
760
  deviceMarker: "dsh-speech-device-marker",
@@ -683,7 +770,21 @@ const styles = {
683
770
  srOnly: "dsh-speech-sr-only",
684
771
  dock: "dsh-speech-dock",
685
772
  dockError: "dsh-speech-dock-error",
686
- 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"
687
788
  };
688
789
  const STYLE_TEXT = String.raw`
689
790
  .dsh-speech-settings{display:grid;gap:16px;max-width:760px;padding:4px 2px 24px;color:var(--color-text,#e8e8e8)}
@@ -696,15 +797,19 @@ const STYLE_TEXT = String.raw`
696
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)}
697
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}
698
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}
699
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}
700
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)}}
701
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)}
702
- :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-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-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)}}
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)}}
703
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}
704
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}
705
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}
706
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}}
707
- @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}}
708
813
  `;
709
814
 
710
815
  //#endregion
@@ -782,6 +887,16 @@ function CloseIcon() {
782
887
  })
783
888
  });
784
889
  }
890
+ function SendIcon() {
891
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
892
+ viewBox: "0 0 16 16",
893
+ "aria-hidden": "true",
894
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
895
+ d: "M8.3125.980183c.35517.072917.66652.223997.9502.452147.22454.18064.46759.4256.71679.67481L14.707 6.83468l-1.414 1.41406L9 3.95577V15.0417H7V3.95577L2.70703 8.24874 1.29297 6.83468l4.72754-4.72754c.2492-.24921.49226-.49417.71679-.67481.23932-.19247.54715-.38831.9502-.452147.2098-.033177.4156-.025023.625 0Z",
896
+ fill: "currentColor"
897
+ })
898
+ });
899
+ }
785
900
  const WAVEFORM_PITCH = 4;
786
901
  const WAVEFORM_BASELINE = .025;
787
902
  function RecordingWaveform({ amplitude }) {
@@ -851,7 +966,7 @@ function RecordingWaveform({ amplitude }) {
851
966
  "aria-hidden": "true"
852
967
  });
853
968
  }
854
- function RecordingTakeover({ amplitude, cancelLabel, disabled, phase, label, onCancel, onStop, stopLabel }) {
969
+ function RecordingTakeover({ amplitude, cancelLabel, disabled, phase, label, onCancel, onSend, onStop, sendDisabled, sendLabel, stopLabel }) {
855
970
  const recording = phase === "recording";
856
971
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
857
972
  className: styles.recordingTakeover,
@@ -875,14 +990,25 @@ function RecordingTakeover({ amplitude, cancelLabel, disabled, phase, label, onC
875
990
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", { "aria-hidden": "true" }), label]
876
991
  })
877
992
  }),
878
- recording ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
879
- type: "button",
880
- className: `${styles.mic} ${styles.recordingStop}`,
881
- disabled,
882
- "aria-label": stopLabel,
883
- title: stopLabel,
884
- onClick: onStop,
885
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MicIcon, { stopped: true })
993
+ recording ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
994
+ className: styles.recordingActions,
995
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
996
+ type: "button",
997
+ className: `${styles.mic} ${styles.recordingStop}`,
998
+ disabled,
999
+ "aria-label": stopLabel,
1000
+ title: stopLabel,
1001
+ onClick: onStop,
1002
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MicIcon, { stopped: true })
1003
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1004
+ type: "button",
1005
+ className: styles.recordingSend,
1006
+ disabled: sendDisabled,
1007
+ "aria-label": sendLabel,
1008
+ title: sendLabel,
1009
+ onClick: onSend,
1010
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SendIcon, {})
1011
+ })]
886
1012
  }) : null,
887
1013
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
888
1014
  className: styles.srOnly,
@@ -943,7 +1069,14 @@ function SpeechMic({ controller, getLocale, sessionId, input, inputActions, t })
943
1069
  onCancel: () => {
944
1070
  controller.cancel();
945
1071
  },
1072
+ onSend: () => {
1073
+ controller.stopAndSend().catch((error) => {
1074
+ controller.reportError(error instanceof Error ? error.message : "Unable to send voice input");
1075
+ });
1076
+ },
946
1077
  onStop: toggle,
1078
+ sendDisabled: disabled || !state.hasTranscript,
1079
+ sendLabel: t("micSend"),
947
1080
  stopLabel: title
948
1081
  });
949
1082
  }
@@ -1073,18 +1206,47 @@ function MicrophoneDockPortal({ controller, state, t }) {
1073
1206
  const markerRef = (0, react.useRef)(null);
1074
1207
  const [target, setTarget] = (0, react.useState)(null);
1075
1208
  (0, react.useLayoutEffect)(() => {
1076
- const marker = markerRef.current;
1077
- const slot = marker?.closest("[data-slot=\"conversation.composer.dock\"]");
1078
- if (!(slot instanceof HTMLElement)) return;
1209
+ let disposed = false;
1210
+ let frame;
1211
+ let observedSlot = null;
1212
+ const observer = new MutationObserver(() => {
1213
+ findTarget();
1214
+ });
1215
+ const retry = () => {
1216
+ if (disposed || frame !== void 0) return;
1217
+ frame = requestAnimationFrame(() => {
1218
+ frame = void 0;
1219
+ findTarget();
1220
+ });
1221
+ };
1079
1222
  const findTarget = () => {
1223
+ if (disposed) return;
1224
+ const marker = markerRef.current;
1225
+ const slot = marker?.closest("[data-slot=\"conversation.composer.dock\"]");
1226
+ if (!(slot instanceof HTMLElement)) {
1227
+ setTarget(null);
1228
+ retry();
1229
+ return;
1230
+ }
1231
+ if (observedSlot !== slot) {
1232
+ observer.disconnect();
1233
+ observedSlot = slot;
1234
+ observer.observe(slot, {
1235
+ childList: true,
1236
+ subtree: true,
1237
+ characterData: true
1238
+ });
1239
+ }
1080
1240
  const metrics = Array.from(slot.children).find((child) => child !== marker && child.textContent?.includes("TTFT"));
1081
- setTarget(metrics instanceof HTMLElement ? metrics : null);
1241
+ const nextTarget = metrics instanceof HTMLElement ? metrics : null;
1242
+ setTarget((current) => current === nextTarget ? current : nextTarget);
1243
+ if (nextTarget === null) retry();
1082
1244
  };
1083
1245
  findTarget();
1084
- const observer = new MutationObserver(findTarget);
1085
- observer.observe(slot, { childList: true });
1086
1246
  return () => {
1247
+ disposed = true;
1087
1248
  observer.disconnect();
1249
+ if (frame !== void 0) cancelAnimationFrame(frame);
1088
1250
  };
1089
1251
  }, []);
1090
1252
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -1202,6 +1364,258 @@ function Field({ label, children, hint }) {
1202
1364
  ]
1203
1365
  });
1204
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
+ }
1205
1619
  function SpeechSettings({ controller, scope, getLocale, t }) {
1206
1620
  const client = (0, react.useSyncExternalStore)(controller.subscribe, controller.getSnapshot);
1207
1621
  const settings = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot());
@@ -1218,6 +1632,8 @@ function SpeechSettings({ controller, scope, getLocale, t }) {
1218
1632
  const [topUpUrl, setTopUpUrl] = (0, react.useState)(null);
1219
1633
  const [languageDraft, setLanguageDraft] = (0, react.useState)("auto");
1220
1634
  const [contextDraft, setContextDraft] = (0, react.useState)("");
1635
+ const [voiceSearch, setVoiceSearch] = (0, react.useState)("");
1636
+ const [modelVoiceSearch, setModelVoiceSearch] = (0, react.useState)("");
1221
1637
  (0, react.useEffect)(() => {
1222
1638
  controller.ensureMetadata();
1223
1639
  }, [controller]);
@@ -1248,6 +1664,33 @@ function SpeechSettings({ controller, scope, getLocale, t }) {
1248
1664
  const selectedProvider = providers.some((binding) => binding.provider === value?.provider) ? value?.provider ?? "" : providers.find((binding) => binding.isProviderDefault)?.provider ?? providers[0]?.provider ?? "";
1249
1665
  const selectedBinding = providers.find((binding) => binding.provider === selectedProvider);
1250
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);
1251
1694
  const remaining = Math.max(0, Math.ceil((codeExpiry - now) / 1e3));
1252
1695
  const connected = client.status?.credential.configured === true;
1253
1696
  const writable = settings.writable;
@@ -1433,23 +1876,24 @@ function SpeechSettings({ controller, scope, getLocale, t }) {
1433
1876
  })
1434
1877
  ] })
1435
1878
  }),
1436
- connected ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1437
- className: styles.card,
1438
- children: [
1439
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: t("settings") }),
1440
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1441
- className: styles.grid,
1442
- children: [
1443
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1444
- className: styles.modelFull,
1445
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
1446
- label: t("model"),
1447
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1448
- 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,
1449
1894
  value: selectedModel,
1450
1895
  disabled: !writable || models.length === 0,
1451
- onChange: (event) => {
1452
- const model = event.target.value;
1896
+ onSelect: (model) => {
1453
1897
  const choices = catalog.filter((binding) => binding.model === model);
1454
1898
  const provider = choices.find((binding) => binding.isProviderDefault)?.provider ?? choices[0]?.provider;
1455
1899
  run("setting:model", async () => {
@@ -1457,137 +1901,278 @@ function SpeechSettings({ controller, scope, getLocale, t }) {
1457
1901
  if (provider !== void 0) await scope.set("provider", provider);
1458
1902
  await controller.ensureMetadata(true);
1459
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);
1460
1915
  },
1461
- children: models.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1462
- value: model,
1463
- children: model
1464
- }, model))
1916
+ children: providers.map((binding) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1917
+ value: binding.provider,
1918
+ children: binding.provider
1919
+ }, binding.provider))
1465
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
+ })]
1466
1943
  })
1467
- }),
1468
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
1469
- label: t("provider"),
1470
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1471
- className: styles.input,
1472
- value: selectedProvider,
1473
- disabled: !writable || providers.length === 0,
1474
- onChange: (event) => {
1475
- write("provider", event.target.value);
1476
- },
1477
- children: providers.map((binding) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1478
- value: binding.provider,
1479
- children: binding.provider
1480
- }, binding.provider))
1481
- })
1482
- }),
1483
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Field, {
1484
- label: t("language"),
1485
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1486
- className: styles.input,
1487
- list: "dsh-speech-languages",
1488
- 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,
1489
1965
  disabled: !writable,
1490
1966
  onChange: (event) => {
1491
- setLanguageDraft(event.target.value);
1967
+ setContextDraft(event.target.value);
1492
1968
  },
1493
- onBlur: commitLanguage,
1494
- onKeyDown: (event) => {
1495
- if (event.key === "Enter") event.currentTarget.blur();
1496
- }
1497
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("datalist", {
1498
- id: "dsh-speech-languages",
1499
- children: LANGUAGES.map(([id, label]) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1500
- value: id,
1501
- children: id === "auto" ? t("auto") : label
1502
- }, id))
1969
+ onBlur: commitContext
1503
1970
  })]
1504
- })
1505
- ]
1506
- }),
1507
- selectedBinding?.contextSupported === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("details", {
1508
- className: styles.contextDetails,
1509
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("summary", {
1510
- className: styles.contextSummary,
1511
- children: [
1512
- t("context"),
1513
- " ",
1514
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("optional") })
1515
- ]
1516
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1517
- className: styles.contextBody,
1518
- 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", {
1519
1981
  className: styles.hint,
1520
- children: t("contextHint")
1521
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
1522
- className: styles.textarea,
1523
- "aria-label": t("context"),
1524
- maxLength: 4e3,
1525
- value: contextDraft,
1526
- disabled: !writable,
1527
- onChange: (event) => {
1528
- setContextDraft(event.target.value);
1529
- },
1530
- 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
+ })]
1531
2022
  })]
1532
- })]
1533
- }) : null
1534
- ]
1535
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1536
- className: styles.card,
1537
- children: [
1538
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: t("balance") }),
1539
- client.status?.balance === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1540
- className: styles.muted,
1541
- children: client.status?.balanceError === void 0 ? t("loading") : t("balanceUnavailable")
1542
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", {
1543
- className: styles.balanceValue,
1544
- children: money(client.status.balance.paidUsd)
1545
- }), client.status.balance.exhausted ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1546
- className: styles.danger,
1547
- children: t("emptyBalance")
1548
- }) : client.status.balance.low ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1549
- className: styles.warning,
1550
- children: t("lowBalance")
1551
- }) : null] }),
1552
- connected ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1553
- className: styles.topUp,
1554
- children: [
1555
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
1556
- label: t("amount"),
1557
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1558
- className: styles.input,
1559
- type: "number",
1560
- min: 5,
1561
- max: 1e3,
1562
- step: 1,
1563
- value: topUpAmount,
1564
- onChange: (event) => {
1565
- setTopUpAmount(Number(event.target.value));
1566
- }
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
+ })
1567
2110
  })
1568
- }),
1569
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1570
- className: styles.primary,
1571
- type: "button",
1572
- disabled: busy !== null || topUpAmount < 5 || topUpAmount > 1e3,
1573
- onClick: () => {
1574
- run("top-up", async () => {
1575
- setTopUpUrl((await speechApi.topUp(topUpAmount)).url);
1576
- });
1577
- },
1578
- children: t("createLink")
1579
- }),
1580
- topUpUrl === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
1581
- className: styles.checkout,
1582
- href: topUpUrl,
1583
- target: "_blank",
1584
- rel: "noreferrer",
1585
- children: t("openCheckout")
1586
- })
1587
- ]
1588
- }) : null
1589
- ]
1590
- })] }) : 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,
1591
2176
  message === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1592
2177
  className: styles.good,
1593
2178
  role: "status",
@@ -1602,6 +2187,813 @@ function SpeechSettings({ controller, scope, getLocale, t }) {
1602
2187
  });
1603
2188
  }
1604
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
+
1605
2997
  //#endregion
1606
2998
  //#region src/client/locales.ts
1607
2999
  const en = {
@@ -1628,6 +3020,8 @@ const en = {
1628
3020
  disconnect: "Disconnect",
1629
3021
  settings: "Recognition",
1630
3022
  model: "STT model",
3023
+ modelSearchPlaceholder: "Search or select a model",
3024
+ modelNoResults: "No matching models",
1631
3025
  provider: "Provider",
1632
3026
  language: "Language",
1633
3027
  invalidLanguage: "Enter Auto or a valid language tag such as en, zh-CN, or ja.",
@@ -1652,6 +3046,7 @@ const en = {
1652
3046
  saved: "Saved",
1653
3047
  micStart: "Start voice input",
1654
3048
  micStop: "Stop voice input",
3049
+ micSend: "Finish voice input and send",
1655
3050
  micCancel: "Cancel voice input",
1656
3051
  starting: "Preparing microphone…",
1657
3052
  listening: "Listening…",
@@ -1664,7 +3059,34 @@ const en = {
1664
3059
  disconnectedMic: "Connect AllModels in Settings → Speech",
1665
3060
  emptyMic: "Top up your AllModels balance in Settings → Speech",
1666
3061
  anotherMic: "The microphone is active in another session",
1667
- 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"
1668
3090
  };
1669
3091
  const zh = {
1670
3092
  nav: "语音",
@@ -1690,6 +3112,8 @@ const zh = {
1690
3112
  disconnect: "断开连接",
1691
3113
  settings: "语音识别",
1692
3114
  model: "STT 模型",
3115
+ modelSearchPlaceholder: "搜索或选择模型",
3116
+ modelNoResults: "没有匹配的模型",
1693
3117
  provider: "服务商",
1694
3118
  language: "语言",
1695
3119
  invalidLanguage: "请输入“自动”或有效的语言标签,例如 en、zh-CN 或 ja。",
@@ -1714,6 +3138,7 @@ const zh = {
1714
3138
  saved: "已保存",
1715
3139
  micStart: "开始语音输入",
1716
3140
  micStop: "停止语音输入",
3141
+ micSend: "完成语音输入并发送",
1717
3142
  micCancel: "取消语音输入",
1718
3143
  starting: "正在准备麦克风…",
1719
3144
  listening: "正在聆听…",
@@ -1726,7 +3151,34 @@ const zh = {
1726
3151
  disconnectedMic: "请前往“设置 → 语音”连接 AllModels",
1727
3152
  emptyMic: "请前往“设置 → 语音”为 AllModels 充值",
1728
3153
  anotherMic: "麦克风正在另一个会话中使用",
1729
- 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: "此回答无法生成语音摘要"
1730
3182
  };
1731
3183
 
1732
3184
  //#endregion
@@ -1738,6 +3190,7 @@ const inject = [
1738
3190
  ];
1739
3191
  function apply(ctx) {
1740
3192
  const controller = new SpeechController();
3193
+ const spoken = new SpokenSummaryController();
1741
3194
  const scope = ctx.settingsScope.bind({ namespace: "dsh-speech" });
1742
3195
  const getLocale = () => ctx.locale.getLocale().active;
1743
3196
  ctx.effect(() => ctx.locale.register("speech", {
@@ -1776,7 +3229,9 @@ function apply(ctx) {
1776
3229
  }, "dsh-speech: settings navigation icon");
1777
3230
  ctx.effect(() => () => {
1778
3231
  controller.dispose();
1779
- }, "dsh-speech: browser controller");
3232
+ spoken.dispose();
3233
+ }, "dsh-speech: browser controllers");
3234
+ controller.ensureMetadata();
1780
3235
  ctx.slots.inject("settings.section", () => ctx.slots.register({
1781
3236
  name: "settings.section",
1782
3237
  id: "speech",
@@ -1822,11 +3277,39 @@ function apply(ctx) {
1822
3277
  getLocale
1823
3278
  })
1824
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);
1825
3306
  return () => {
1826
3307
  controller.detachBlocks(blocks);
1827
3308
  mic();
1828
3309
  dock();
1829
3310
  inputDock();
3311
+ spokenTail();
3312
+ spokenObserver();
1830
3313
  };
1831
3314
  });
1832
3315
  }