@allmodels/dsh-speech 0.1.0

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 ADDED
@@ -0,0 +1,1803 @@
1
+ window.__ModuleLoader__.load({ id: "@allmodels/dsh-speech", factory: (require) => {
2
+ var module = { exports: {} }; var exports = module.exports;
3
+ //#region rolldown:runtime
4
+ var __create = Object.create;
5
+ var __defProp = Object.defineProperty;
6
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
7
+ var __getOwnPropNames = Object.getOwnPropertyNames;
8
+ var __getProtoOf = Object.getPrototypeOf;
9
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
12
+ key = keys[i];
13
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
14
+ get: ((k) => from[k]).bind(null, key),
15
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
16
+ });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
21
+ value: mod,
22
+ enumerable: true
23
+ }) : target, mod));
24
+
25
+ //#endregion
26
+ let react = require("react");
27
+ react = __toESM(react);
28
+ let react_dom = require("react-dom");
29
+ react_dom = __toESM(react_dom);
30
+ let react_jsx_runtime = require("react/jsx-runtime");
31
+ react_jsx_runtime = __toESM(react_jsx_runtime);
32
+
33
+ //#region src/client/audio.ts
34
+ const WORKLET_SOURCE = String.raw`
35
+ class DshSpeechPcmProcessor extends AudioWorkletProcessor {
36
+ constructor() {
37
+ super()
38
+ this.ratio = 16000 / sampleRate
39
+ this.credit = 0
40
+ this.samples = new Int16Array(1600)
41
+ this.offset = 0
42
+ this.energy = 0
43
+ this.energySamples = 0
44
+ }
45
+
46
+ process(inputs) {
47
+ const channel = inputs[0] && inputs[0][0]
48
+ if (!channel) return true
49
+ for (let index = 0; index < channel.length; index += 1) {
50
+ const raw = channel[index]
51
+ this.energy += raw * raw
52
+ this.energySamples += 1
53
+ this.credit += this.ratio
54
+ if (this.credit < 1) continue
55
+ this.credit -= 1
56
+ const sample = Math.max(-1, Math.min(1, raw))
57
+ this.samples[this.offset++] = sample < 0 ? sample * 0x8000 : sample * 0x7fff
58
+ if (this.offset === this.samples.length) {
59
+ const pcm = this.samples.buffer
60
+ this.port.postMessage({ type: 'pcm', pcm }, [pcm])
61
+ this.samples = new Int16Array(1600)
62
+ this.offset = 0
63
+ }
64
+ }
65
+ if (this.energySamples >= 2048) {
66
+ this.port.postMessage({ type: 'amplitude', value: Math.sqrt(this.energy / this.energySamples) })
67
+ this.energy = 0
68
+ this.energySamples = 0
69
+ }
70
+ return true
71
+ }
72
+ }
73
+ registerProcessor('dsh-speech-pcm', DshSpeechPcmProcessor)
74
+ `;
75
+ function microphoneConstraints(deviceId) {
76
+ return {
77
+ channelCount: 1,
78
+ echoCancellation: true,
79
+ noiseSuppression: true,
80
+ autoGainControl: true,
81
+ ...deviceId === void 0 || deviceId === "" ? {} : { deviceId: { exact: deviceId } }
82
+ };
83
+ }
84
+ var AudioCapture = class {
85
+ context;
86
+ stream;
87
+ source;
88
+ worklet;
89
+ sink;
90
+ moduleUrl;
91
+ deviceSwitchVersion = 0;
92
+ selectedDeviceId = "";
93
+ async start(onFrame, onAmplitude, preferredDeviceId = "") {
94
+ if (!navigator.mediaDevices?.getUserMedia || typeof AudioWorkletNode === "undefined") throw new Error("This browser does not support live microphone transcription");
95
+ try {
96
+ this.stream = await navigator.mediaDevices.getUserMedia({ audio: microphoneConstraints(preferredDeviceId) });
97
+ this.selectedDeviceId = preferredDeviceId;
98
+ } catch (error) {
99
+ const name = error !== null && typeof error === "object" && "name" in error ? String(error.name) : "";
100
+ if (preferredDeviceId === "" || name !== "NotFoundError" && name !== "OverconstrainedError") throw error;
101
+ this.stream = await navigator.mediaDevices.getUserMedia({ audio: microphoneConstraints() });
102
+ this.selectedDeviceId = "";
103
+ }
104
+ const context = new AudioContext({ latencyHint: "interactive" });
105
+ this.context = context;
106
+ this.moduleUrl = URL.createObjectURL(new Blob([WORKLET_SOURCE], { type: "text/javascript" }));
107
+ await context.audioWorklet.addModule(this.moduleUrl);
108
+ this.source = context.createMediaStreamSource(this.stream);
109
+ this.worklet = new AudioWorkletNode(context, "dsh-speech-pcm", {
110
+ numberOfInputs: 1,
111
+ numberOfOutputs: 1,
112
+ outputChannelCount: [1]
113
+ });
114
+ this.sink = context.createGain();
115
+ this.sink.gain.value = 0;
116
+ this.worklet.port.onmessage = (event) => {
117
+ if (event.data === null || typeof event.data !== "object") return;
118
+ const message = event.data;
119
+ if (message.type === "pcm" && message.pcm instanceof ArrayBuffer) onFrame(message.pcm);
120
+ if (message.type === "amplitude" && typeof message.value === "number") onAmplitude(message.value);
121
+ };
122
+ this.source.connect(this.worklet);
123
+ this.worklet.connect(this.sink);
124
+ this.sink.connect(context.destination);
125
+ await context.resume();
126
+ }
127
+ async microphones() {
128
+ const activeTrack = this.stream?.getAudioTracks()[0];
129
+ const activeDeviceId = activeTrack?.getSettings().deviceId;
130
+ const inputs = typeof navigator.mediaDevices?.enumerateDevices === "function" ? (await navigator.mediaDevices.enumerateDevices()).filter((device) => device.kind === "audioinput") : [];
131
+ const seen = /* @__PURE__ */ new Set();
132
+ const devices = [{
133
+ deviceId: "",
134
+ label: "System default",
135
+ systemDefault: true
136
+ }];
137
+ for (const [index, device] of inputs.entries()) {
138
+ if (device.deviceId === "" || device.deviceId === "default" || seen.has(device.deviceId)) continue;
139
+ seen.add(device.deviceId);
140
+ devices.push({
141
+ deviceId: device.deviceId,
142
+ label: device.label.trim() || `Microphone ${String(index + 1)}`
143
+ });
144
+ }
145
+ if (this.selectedDeviceId !== "" && activeDeviceId !== void 0 && activeDeviceId !== "" && !seen.has(this.selectedDeviceId)) devices.push({
146
+ deviceId: this.selectedDeviceId,
147
+ label: activeTrack?.label.trim() || "Current microphone"
148
+ });
149
+ return {
150
+ devices,
151
+ activeDeviceId: this.selectedDeviceId
152
+ };
153
+ }
154
+ async switchMicrophone(deviceId) {
155
+ const context = this.context;
156
+ const worklet = this.worklet;
157
+ if (context === void 0 || worklet === void 0 || this.stream === void 0) throw new Error("The microphone is not currently recording");
158
+ const version = ++this.deviceSwitchVersion;
159
+ const nextStream = await navigator.mediaDevices.getUserMedia({ audio: microphoneConstraints(deviceId) });
160
+ if (version !== this.deviceSwitchVersion || this.context !== context || this.worklet !== worklet) {
161
+ for (const track of nextStream.getTracks()) track.stop();
162
+ throw new Error("The microphone changed before the previous selection finished");
163
+ }
164
+ const nextSource = context.createMediaStreamSource(nextStream);
165
+ nextSource.connect(worklet);
166
+ const previousSource = this.source;
167
+ const previousStream = this.stream;
168
+ this.source = nextSource;
169
+ this.stream = nextStream;
170
+ this.selectedDeviceId = deviceId;
171
+ previousSource?.disconnect();
172
+ for (const track of previousStream.getTracks()) track.stop();
173
+ return this.microphones();
174
+ }
175
+ async stop() {
176
+ this.deviceSwitchVersion += 1;
177
+ this.worklet?.disconnect();
178
+ this.source?.disconnect();
179
+ this.sink?.disconnect();
180
+ for (const track of this.stream?.getTracks() ?? []) track.stop();
181
+ if (this.context !== void 0 && this.context.state !== "closed") await this.context.close().catch(() => {});
182
+ if (this.moduleUrl !== void 0) URL.revokeObjectURL(this.moduleUrl);
183
+ this.context = void 0;
184
+ this.stream = void 0;
185
+ this.source = void 0;
186
+ this.worklet = void 0;
187
+ this.sink = void 0;
188
+ this.moduleUrl = void 0;
189
+ this.selectedDeviceId = "";
190
+ }
191
+ };
192
+
193
+ //#endregion
194
+ //#region src/client/api.ts
195
+ var SpeechApiError = class extends Error {
196
+ code;
197
+ constructor(code, message) {
198
+ super(message);
199
+ this.name = "SpeechApiError";
200
+ this.code = code;
201
+ }
202
+ };
203
+ async function request(path, init) {
204
+ const response = await fetch(path, {
205
+ ...init,
206
+ headers: {
207
+ accept: "application/json",
208
+ ...init?.body === void 0 ? {} : { "content-type": "application/json" },
209
+ ...init?.headers
210
+ }
211
+ });
212
+ const body = await response.json().catch(() => void 0);
213
+ if (!response.ok) {
214
+ const error = body !== null && typeof body === "object" ? body.error : void 0;
215
+ throw new SpeechApiError(typeof error?.code === "string" ? error.code : `HTTP_${String(response.status)}`, typeof error?.message === "string" ? error.message : "Speech request failed");
216
+ }
217
+ return body;
218
+ }
219
+ const speechApi = {
220
+ status: () => request("/api/dsh-speech/status"),
221
+ catalog: (refresh = false) => request(`/api/dsh-speech/catalog${refresh ? "?refresh=1" : ""}`),
222
+ startAuth: (email) => request("/api/dsh-speech/auth/start", {
223
+ method: "POST",
224
+ body: JSON.stringify({ email })
225
+ }),
226
+ verifyAuth: (email, code) => request("/api/dsh-speech/auth/verify", {
227
+ method: "POST",
228
+ body: JSON.stringify({
229
+ email,
230
+ code
231
+ })
232
+ }),
233
+ setKey: (apiKey) => request("/api/dsh-speech/auth/key", {
234
+ method: "POST",
235
+ body: JSON.stringify({ apiKey })
236
+ }),
237
+ logout: () => request("/api/dsh-speech/auth/logout", {
238
+ method: "POST",
239
+ body: "{}"
240
+ }),
241
+ topUp: (amountUsd) => request("/api/dsh-speech/top-up", {
242
+ method: "POST",
243
+ body: JSON.stringify({ amountUsd })
244
+ })
245
+ };
246
+
247
+ //#endregion
248
+ //#region src/client/microphonePreference.ts
249
+ const PREFERRED_MICROPHONE_KEY = "dsh-speech.preferred-microphone";
250
+ function readPreferredMicrophone() {
251
+ try {
252
+ return window.localStorage.getItem(PREFERRED_MICROPHONE_KEY) ?? "";
253
+ } catch {
254
+ return "";
255
+ }
256
+ }
257
+ function writePreferredMicrophone(deviceId) {
258
+ try {
259
+ if (deviceId === "") window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY);
260
+ else window.localStorage.setItem(PREFERRED_MICROPHONE_KEY, deviceId);
261
+ } catch {}
262
+ }
263
+
264
+ //#endregion
265
+ //#region src/shared.ts
266
+ const CATALOG_TTL_MS = 300 * 1e3;
267
+ const AUDIO_FORMAT = "pcm_16000";
268
+ function selectBinding(bindings, locale, preferred) {
269
+ if (preferred?.model !== void 0) {
270
+ const preferredModel = preferred.model;
271
+ const matchesModel = (binding) => binding.model === preferredModel || binding.canonical === preferredModel || !preferredModel.includes("/") && binding.model.endsWith(`/${preferredModel}`);
272
+ const exact = bindings.find((binding) => matchesModel(binding) && (preferred.provider === void 0 || binding.provider === preferred.provider));
273
+ if (exact !== void 0) return exact;
274
+ const sameModel = bindings.find(matchesModel);
275
+ if (sameModel !== void 0) return sameModel;
276
+ }
277
+ const preferredProvider = locale.toLowerCase().startsWith("zh") ? "soniox" : "assemblyai";
278
+ return bindings.find((binding) => binding.provider === preferredProvider && binding.isProviderDefault) ?? bindings.find((binding) => binding.provider === preferredProvider) ?? bindings[0];
279
+ }
280
+ const CJK = /^(zh|ja|ko)(-|$)/i;
281
+ function transcriptSeparator(left, right, language) {
282
+ if (left.length === 0 || right.length === 0) return "";
283
+ if (language !== void 0 && CJK.test(language)) return "";
284
+ if (/\s$/u.test(left) || /^\s/u.test(right)) return "";
285
+ if (/^[,.;:!?\u3000-\u303f\uff00-\uff65]/u.test(right)) return "";
286
+ return " ";
287
+ }
288
+ function appendTranscript(left, right, language) {
289
+ return `${left}${transcriptSeparator(left, right, language)}${right}`;
290
+ }
291
+ function createTranscript(base) {
292
+ return {
293
+ base,
294
+ finals: "",
295
+ partial: "",
296
+ lastSequence: -1
297
+ };
298
+ }
299
+ function transcriptText(state) {
300
+ return appendTranscript(appendTranscript(state.base, state.finals, state.language), state.partial, state.language);
301
+ }
302
+ function applyTranscriptEvent(state, event) {
303
+ if (event.sequence <= state.lastSequence) return state;
304
+ const language = event.languageCode ?? state.language;
305
+ if (event.kind === "partial") return {
306
+ ...state,
307
+ partial: event.text,
308
+ ...language === void 0 ? {} : { language },
309
+ lastSequence: event.sequence
310
+ };
311
+ return {
312
+ ...state,
313
+ finals: appendTranscript(state.finals, event.text, language),
314
+ partial: "",
315
+ ...language === void 0 ? {} : { language },
316
+ lastSequence: event.sequence
317
+ };
318
+ }
319
+
320
+ //#endregion
321
+ //#region src/client/controller.ts
322
+ function socketUrl() {
323
+ const url = new URL("/api/dsh-speech/stt", window.location.href);
324
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
325
+ return url.toString();
326
+ }
327
+ var SpeechController = class {
328
+ snapshot = {
329
+ phase: "idle",
330
+ amplitude: 0,
331
+ metadataLoading: false,
332
+ microphones: [],
333
+ switchingMicrophone: false
334
+ };
335
+ listeners = /* @__PURE__ */ new Set();
336
+ blocks;
337
+ active;
338
+ metadataPromise;
339
+ preferredMicrophoneId = readPreferredMicrophone();
340
+ getSnapshot = () => this.snapshot;
341
+ subscribe = (listener) => {
342
+ this.listeners.add(listener);
343
+ return () => {
344
+ this.listeners.delete(listener);
345
+ };
346
+ };
347
+ attachBlocks(blocks) {
348
+ this.blocks = blocks;
349
+ }
350
+ detachBlocks(blocks) {
351
+ if (this.blocks !== blocks) return;
352
+ const active = this.active;
353
+ if (active !== void 0) this.finish(active);
354
+ this.blocks = void 0;
355
+ }
356
+ reportError(message) {
357
+ this.publish({
358
+ phase: "error",
359
+ error: message
360
+ });
361
+ }
362
+ async ensureMetadata(refresh = false) {
363
+ if (!refresh && this.metadataPromise !== void 0) return this.metadataPromise;
364
+ this.publish({
365
+ metadataLoading: true,
366
+ ...refresh ? { error: void 0 } : {}
367
+ });
368
+ const operation = Promise.allSettled([speechApi.status(), speechApi.catalog(refresh)]).then(([status, catalog]) => {
369
+ const errors = [status, catalog].filter((result) => result.status === "rejected").map((result) => result.reason instanceof Error ? result.reason.message : "Unable to load speech settings");
370
+ this.publish({
371
+ ...status.status === "fulfilled" ? { status: status.value } : {},
372
+ ...catalog.status === "fulfilled" ? { catalog: catalog.value } : {},
373
+ metadataLoading: false,
374
+ ...errors.length === 0 ? { error: void 0 } : { error: errors.join(" · ") }
375
+ });
376
+ }).finally(() => {
377
+ if (this.metadataPromise === operation) this.metadataPromise = void 0;
378
+ });
379
+ this.metadataPromise = operation;
380
+ return operation;
381
+ }
382
+ async toggle(options) {
383
+ if (this.active?.sessionId === options.sessionId) {
384
+ await this.stop();
385
+ return;
386
+ }
387
+ if (this.active !== void 0) throw new Error("A microphone is already active in another session");
388
+ await this.start(options);
389
+ }
390
+ release(sessionId) {
391
+ const active = this.active;
392
+ if (active?.sessionId === sessionId) this.finish(active);
393
+ }
394
+ async stop() {
395
+ const active = this.active;
396
+ if (active === void 0 || active.finished) return;
397
+ this.publish({
398
+ phase: "finalizing",
399
+ amplitude: 0
400
+ });
401
+ const stoppingCapture = active.stoppingCapture ??= active.capture.stop();
402
+ if (active.socket.readyState === WebSocket.OPEN) {
403
+ active.socket.send(JSON.stringify({ type: "commit" }));
404
+ active.socket.send(JSON.stringify({ type: "close" }));
405
+ }
406
+ active.finalTimer = setTimeout(() => {
407
+ this.finish(active);
408
+ }, 5e3);
409
+ await stoppingCapture;
410
+ }
411
+ async switchMicrophone(deviceId) {
412
+ const active = this.active;
413
+ if (active === void 0 || active.finished || this.snapshot.phase !== "recording") return;
414
+ const previousDeviceId = this.snapshot.activeMicrophoneId;
415
+ this.publish({
416
+ activeMicrophoneId: deviceId,
417
+ switchingMicrophone: true,
418
+ microphoneError: void 0
419
+ });
420
+ try {
421
+ const microphones = await active.capture.switchMicrophone(deviceId);
422
+ if (this.active !== active || active.finished) return;
423
+ this.publish({
424
+ microphones: microphones.devices,
425
+ activeMicrophoneId: microphones.activeDeviceId,
426
+ switchingMicrophone: false
427
+ });
428
+ this.preferredMicrophoneId = microphones.activeDeviceId ?? "";
429
+ writePreferredMicrophone(this.preferredMicrophoneId);
430
+ } catch (error) {
431
+ if (this.active !== active || active.finished) return;
432
+ this.publish({
433
+ activeMicrophoneId: previousDeviceId,
434
+ switchingMicrophone: false,
435
+ microphoneError: error instanceof Error ? error.message : "Unable to switch microphone"
436
+ });
437
+ }
438
+ }
439
+ dispose() {
440
+ const active = this.active;
441
+ if (active !== void 0) this.finish(active);
442
+ this.listeners.clear();
443
+ }
444
+ async start(options) {
445
+ await this.ensureMetadata();
446
+ const status = this.snapshot.status;
447
+ if (status?.credential.configured !== true) throw new Error("Connect AllModels in Settings → Speech");
448
+ if (status.balance?.exhausted === true) throw new Error("Your AllModels balance is empty");
449
+ const socket = new WebSocket(socketUrl());
450
+ const capture = new AudioCapture();
451
+ const active = {
452
+ ...options,
453
+ socket,
454
+ capture,
455
+ transcript: createTranscript(options.draft),
456
+ finished: false
457
+ };
458
+ this.active = active;
459
+ this.blocks?.set(options.sessionId, { reason: options.listeningReason });
460
+ this.publish({
461
+ phase: "starting",
462
+ activeSessionId: options.sessionId,
463
+ amplitude: 0,
464
+ error: void 0,
465
+ activeModel: void 0,
466
+ activeProvider: void 0,
467
+ microphones: [],
468
+ activeMicrophoneId: void 0,
469
+ switchingMicrophone: false,
470
+ microphoneError: void 0
471
+ });
472
+ try {
473
+ await new Promise((resolve, reject) => {
474
+ const timeout = setTimeout(() => {
475
+ reject(/* @__PURE__ */ new Error("Speech connection timed out"));
476
+ }, 1e4);
477
+ let settled = false;
478
+ const succeed = () => {
479
+ if (settled) return;
480
+ settled = true;
481
+ clearTimeout(timeout);
482
+ resolve();
483
+ };
484
+ const fail = (message) => {
485
+ if (settled) return;
486
+ settled = true;
487
+ clearTimeout(timeout);
488
+ reject(new Error(message));
489
+ };
490
+ socket.addEventListener("open", () => {
491
+ socket.send(JSON.stringify({
492
+ type: "start",
493
+ locale: options.locale,
494
+ audioFormat: AUDIO_FORMAT
495
+ }));
496
+ }, { once: true });
497
+ socket.addEventListener("message", (event) => {
498
+ if (typeof event.data !== "string") return;
499
+ try {
500
+ const message = JSON.parse(event.data);
501
+ if (message.type === "ready") {
502
+ this.publish({
503
+ activeModel: typeof message.model === "string" ? message.model : void 0,
504
+ activeProvider: typeof message.provider === "string" ? message.provider : void 0
505
+ });
506
+ succeed();
507
+ }
508
+ if (message.type === "error") fail(typeof message.message === "string" ? message.message : "Unable to start speech recognition");
509
+ } catch {}
510
+ });
511
+ socket.addEventListener("error", () => {
512
+ fail("Unable to connect to speech recognition");
513
+ }, { once: true });
514
+ socket.addEventListener("close", () => {
515
+ fail("The speech connection closed before it was ready");
516
+ }, { once: true });
517
+ });
518
+ this.bindSocket(active);
519
+ await capture.start((frame) => {
520
+ if (socket.readyState === WebSocket.OPEN && this.active === active) socket.send(frame);
521
+ }, (amplitude) => {
522
+ if (this.active === active) this.publish({ amplitude: Math.min(1, amplitude * 4) });
523
+ }, this.preferredMicrophoneId);
524
+ if (this.active !== active) return;
525
+ this.publish({ phase: "recording" });
526
+ this.refreshMicrophones(active);
527
+ if (typeof navigator.mediaDevices?.addEventListener === "function") {
528
+ active.deviceChangeHandler = () => {
529
+ this.refreshMicrophones(active);
530
+ };
531
+ navigator.mediaDevices.addEventListener("devicechange", active.deviceChangeHandler);
532
+ }
533
+ } catch (error) {
534
+ await capture.stop();
535
+ this.fail(active, error instanceof Error ? error.message : "Unable to start microphone");
536
+ throw error;
537
+ }
538
+ }
539
+ bindSocket(active) {
540
+ active.socket.addEventListener("message", (event) => {
541
+ if (this.active !== active || typeof event.data !== "string") return;
542
+ try {
543
+ const message = JSON.parse(event.data);
544
+ if ((message.type === "partial" || message.type === "final") && typeof message.text === "string" && typeof message.sequence === "number") {
545
+ active.transcript = applyTranscriptEvent(active.transcript, {
546
+ kind: message.type,
547
+ sequence: message.sequence,
548
+ text: message.text,
549
+ ...typeof message.languageCode === "string" ? { languageCode: message.languageCode } : {}
550
+ });
551
+ active.inputActions.setDraft(transcriptText(active.transcript));
552
+ if (message.type === "final" && this.snapshot.phase === "finalizing") {
553
+ if (active.finalTimer !== void 0) clearTimeout(active.finalTimer);
554
+ active.finalTimer = setTimeout(() => {
555
+ this.finish(active);
556
+ }, 120);
557
+ }
558
+ }
559
+ if (message.type === "error") this.fail(active, typeof message.message === "string" ? message.message : "Speech recognition failed");
560
+ if (message.type === "ended") this.finish(active);
561
+ } catch {
562
+ this.fail(active, "The speech service returned an invalid event");
563
+ }
564
+ });
565
+ active.socket.addEventListener("close", () => {
566
+ if (this.active === active && this.snapshot.phase !== "starting") this.finish(active);
567
+ });
568
+ }
569
+ fail(active, message) {
570
+ if (active.finished) return;
571
+ active.inputActions.setDraft(transcriptText(active.transcript));
572
+ this.finish(active, message);
573
+ }
574
+ finish(active, error) {
575
+ if (active.finished) return;
576
+ active.finished = true;
577
+ if (active.finalTimer !== void 0) clearTimeout(active.finalTimer);
578
+ if (active.deviceChangeHandler !== void 0 && typeof navigator.mediaDevices?.removeEventListener === "function") navigator.mediaDevices.removeEventListener("devicechange", active.deviceChangeHandler);
579
+ active.stoppingCapture ??= active.capture.stop();
580
+ if (active.socket.readyState === WebSocket.OPEN) active.socket.send(JSON.stringify({ type: "close" }));
581
+ if (active.socket.readyState < WebSocket.CLOSING) active.socket.close(1e3, "recording complete");
582
+ this.blocks?.set(active.sessionId, void 0);
583
+ if (this.active === active) this.active = void 0;
584
+ this.publish({
585
+ phase: error === void 0 ? "idle" : "error",
586
+ activeSessionId: void 0,
587
+ amplitude: 0,
588
+ microphones: [],
589
+ activeMicrophoneId: void 0,
590
+ switchingMicrophone: false,
591
+ microphoneError: void 0,
592
+ ...error === void 0 ? { error: void 0 } : { error }
593
+ });
594
+ this.ensureMetadata(true);
595
+ }
596
+ publish(patch) {
597
+ this.snapshot = {
598
+ ...this.snapshot,
599
+ ...patch
600
+ };
601
+ for (const listener of this.listeners) listener();
602
+ }
603
+ async refreshMicrophones(active) {
604
+ try {
605
+ const microphones = await active.capture.microphones();
606
+ if (this.active !== active || active.finished) return;
607
+ this.publish({
608
+ microphones: microphones.devices,
609
+ activeMicrophoneId: microphones.activeDeviceId
610
+ });
611
+ const activeDeviceId = microphones.activeDeviceId ?? "";
612
+ if (activeDeviceId !== this.preferredMicrophoneId) {
613
+ this.preferredMicrophoneId = activeDeviceId;
614
+ writePreferredMicrophone(activeDeviceId);
615
+ }
616
+ } catch {}
617
+ }
618
+ };
619
+
620
+ //#endregion
621
+ //#region src/client/styles.ts
622
+ const styles = {
623
+ settings: "dsh-speech-settings",
624
+ header: "dsh-speech-header",
625
+ cardTitle: "dsh-speech-card-title",
626
+ accountWelcome: "dsh-speech-account-welcome",
627
+ accountIntro: "dsh-speech-account-intro",
628
+ freeCredit: "dsh-speech-free-credit",
629
+ accountHint: "dsh-speech-account-hint",
630
+ apiKeyDetails: "dsh-speech-api-key-details",
631
+ apiKeySummary: "dsh-speech-api-key-summary",
632
+ apiKeyBody: "dsh-speech-api-key-body",
633
+ topUp: "dsh-speech-top-up",
634
+ card: "dsh-speech-card",
635
+ columns: "dsh-speech-columns",
636
+ grid: "dsh-speech-grid",
637
+ modelFull: "dsh-speech-model-full",
638
+ contextDetails: "dsh-speech-context-details",
639
+ contextSummary: "dsh-speech-context-summary",
640
+ contextBody: "dsh-speech-context-body",
641
+ balanceGrid: "dsh-speech-balance-grid",
642
+ balanceValue: "dsh-speech-balance-value",
643
+ stack: "dsh-speech-stack",
644
+ field: "dsh-speech-field",
645
+ label: "dsh-speech-label",
646
+ hint: "dsh-speech-hint",
647
+ muted: "dsh-speech-muted",
648
+ input: "dsh-speech-input",
649
+ textarea: "dsh-speech-textarea",
650
+ primary: "dsh-speech-primary",
651
+ secondary: "dsh-speech-secondary",
652
+ checkout: "dsh-speech-checkout",
653
+ good: "dsh-speech-good",
654
+ warning: "dsh-speech-warning",
655
+ danger: "dsh-speech-danger",
656
+ micWrap: "dsh-speech-mic-wrap",
657
+ mic: "dsh-speech-mic",
658
+ micActive: "dsh-speech-mic-active",
659
+ micTooltip: "dsh-speech-mic-tooltip",
660
+ recordingTakeover: "dsh-speech-recording-takeover",
661
+ recordingTrack: "dsh-speech-recording-track",
662
+ recordingCanvas: "dsh-speech-recording-canvas",
663
+ recordingStop: "dsh-speech-recording-stop",
664
+ recordingProgress: "dsh-speech-recording-progress",
665
+ deviceDock: "dsh-speech-device-dock",
666
+ deviceMarker: "dsh-speech-device-marker",
667
+ deviceFallback: "dsh-speech-device-fallback",
668
+ deviceSeparator: "dsh-speech-device-separator",
669
+ deviceIcon: "dsh-speech-device-icon",
670
+ deviceTrigger: "dsh-speech-device-trigger",
671
+ deviceLabel: "dsh-speech-device-label",
672
+ deviceChevron: "dsh-speech-device-chevron",
673
+ deviceMenu: "dsh-speech-device-menu",
674
+ deviceMenuItem: "dsh-speech-device-menu-item",
675
+ deviceCheck: "dsh-speech-device-check",
676
+ srOnly: "dsh-speech-sr-only",
677
+ dock: "dsh-speech-dock",
678
+ dockError: "dsh-speech-dock-error",
679
+ dockDetail: "dsh-speech-dock-detail"
680
+ };
681
+ const STYLE_TEXT = String.raw`
682
+ .dsh-speech-settings{display:grid;gap:16px;max-width:760px;padding:4px 2px 24px;color:var(--color-text,#e8e8e8)}
683
+ .dsh-speech-header,.dsh-speech-card-title,.dsh-speech-top-up{display:flex;align-items:center;justify-content:space-between;gap:12px}
684
+ .dsh-speech-header h2,.dsh-speech-card h3{margin:0}.dsh-speech-header p{margin:5px 0 0;color:var(--color-text-secondary,#a0a0a0);line-height:1.45}
685
+ .dsh-speech-card{display:grid;gap:14px;padding:16px;border:1px solid var(--color-border,#343434);border-radius:12px;background:var(--color-background-secondary,rgba(255,255,255,.025))}
686
+ .dsh-speech-account-welcome{padding:20px}.dsh-speech-account-intro{display:grid;gap:6px}.dsh-speech-account-intro p{margin:0;color:var(--color-text-secondary,#a0a0a0);line-height:1.45}.dsh-speech-free-credit{white-space:nowrap;border:1px solid color-mix(in srgb,#65c88a 45%,transparent);border-radius:999px;padding:4px 9px;background:color-mix(in srgb,#65c88a 12%,transparent);color:#65c88a;font-size:12px;font-weight:650}.dsh-speech-account-hint{margin:0;color:var(--color-text-secondary,#999);font-size:12px;line-height:1.45}.dsh-speech-api-key-details{border-top:1px solid var(--color-border,#343434);padding-top:12px}.dsh-speech-api-key-summary{width:max-content;color:var(--color-text-secondary,#aaa);cursor:pointer;font-size:12px;list-style-position:outside}.dsh-speech-api-key-summary:hover{color:var(--color-text,#eee)}.dsh-speech-api-key-body{display:grid;gap:10px;max-width:440px;padding-top:12px}
687
+ .dsh-speech-columns,.dsh-speech-grid,.dsh-speech-balance-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.dsh-speech-balance-grid{grid-template-columns:repeat(3,minmax(0,1fr))}
688
+ .dsh-speech-balance-grid span{display:grid;gap:5px;color:var(--color-text-secondary,#aaa)}.dsh-speech-balance-grid strong{color:var(--color-text,#eee);font-size:17px}
689
+ .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)}
690
+ .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}
691
+ .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}
692
+ .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}
693
+ .dsh-speech-top-up{justify-content:flex-start;align-items:end;flex-wrap:wrap}.dsh-speech-top-up .dsh-speech-field{max-width:180px}
694
+ .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)}
695
+ :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-stop{flex:none;border-radius:999px;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)}}
696
+ .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}
697
+ [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}
698
+ .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)}
699
+ @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}}
700
+ @media(prefers-reduced-motion:reduce){.dsh-speech-mic-tooltip{transition:none}.dsh-speech-recording-progress i{animation:none;border-right-color:currentColor}}
701
+ `;
702
+
703
+ //#endregion
704
+ //#region src/client/SpeechComposer.tsx
705
+ function MicIcon({ stopped }) {
706
+ return stopped ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
707
+ viewBox: "0 0 20 20",
708
+ "aria-hidden": "true",
709
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
710
+ x: "5",
711
+ y: "5",
712
+ width: "10",
713
+ height: "10",
714
+ rx: "2"
715
+ })
716
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
717
+ viewBox: "0 0 20 20",
718
+ "aria-hidden": "true",
719
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
720
+ x: "7",
721
+ y: "2",
722
+ width: "6",
723
+ height: "11",
724
+ rx: "3",
725
+ fill: "none",
726
+ stroke: "currentColor",
727
+ strokeWidth: "1.7"
728
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
729
+ d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v3M7 18h6",
730
+ fill: "none",
731
+ stroke: "currentColor",
732
+ strokeWidth: "1.7",
733
+ strokeLinecap: "round"
734
+ })]
735
+ });
736
+ }
737
+ function ChevronIcon() {
738
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
739
+ viewBox: "0 0 14 14",
740
+ "aria-hidden": "true",
741
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
742
+ d: "m3.5 5.25 3.5 3.5 3.5-3.5",
743
+ fill: "none",
744
+ stroke: "currentColor",
745
+ strokeWidth: "1.2",
746
+ strokeLinecap: "round",
747
+ strokeLinejoin: "round"
748
+ })
749
+ });
750
+ }
751
+ function CheckIcon() {
752
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
753
+ viewBox: "0 0 14 14",
754
+ "aria-hidden": "true",
755
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
756
+ d: "m2.75 7.2 2.6 2.6 5.9-5.9",
757
+ fill: "none",
758
+ stroke: "currentColor",
759
+ strokeWidth: "1.4",
760
+ strokeLinecap: "round",
761
+ strokeLinejoin: "round"
762
+ })
763
+ });
764
+ }
765
+ const WAVEFORM_PITCH = 4;
766
+ const WAVEFORM_BASELINE = .025;
767
+ function RecordingWaveform({ amplitude }) {
768
+ const canvasRef = (0, react.useRef)(null);
769
+ const samplesRef = (0, react.useRef)([]);
770
+ const reducedMotionRef = (0, react.useRef)(false);
771
+ const draw = (0, react.useCallback)(() => {
772
+ const canvas = canvasRef.current;
773
+ if (canvas === null) return;
774
+ const context = canvas.getContext("2d");
775
+ if (context === null || canvas.clientWidth === 0 || canvas.clientHeight === 0) return;
776
+ const ratio = window.devicePixelRatio || 1;
777
+ const width = Math.floor(canvas.clientWidth * ratio);
778
+ const height = Math.floor(canvas.clientHeight * ratio);
779
+ if (canvas.width !== width || canvas.height !== height) {
780
+ canvas.width = width;
781
+ canvas.height = height;
782
+ }
783
+ const barCount = Math.max(1, Math.floor(canvas.clientWidth / WAVEFORM_PITCH));
784
+ if (samplesRef.current.length !== barCount) {
785
+ const existing = samplesRef.current.slice(-barCount);
786
+ samplesRef.current = [...Array.from({ length: Math.max(0, barCount - existing.length) }, () => WAVEFORM_BASELINE), ...existing];
787
+ }
788
+ context.setTransform(1, 0, 0, 1, 0, 0);
789
+ context.clearRect(0, 0, width, height);
790
+ context.fillStyle = getComputedStyle(canvas).color || "#a8a8a8";
791
+ const pitch = width / barCount;
792
+ const barWidth = Math.max(1, pitch / 2);
793
+ const center = height / 2;
794
+ for (let index = 0; index < barCount; index += 1) {
795
+ const sample = samplesRef.current[index] ?? WAVEFORM_BASELINE;
796
+ const barHeight = Math.max(1.5 * ratio, sample * height * .88);
797
+ context.globalAlpha = sample <= WAVEFORM_BASELINE ? .24 : .92;
798
+ context.fillRect(index * pitch, center - barHeight / 2, barWidth, barHeight);
799
+ }
800
+ context.globalAlpha = 1;
801
+ }, []);
802
+ (0, react.useEffect)(() => {
803
+ reducedMotionRef.current = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
804
+ const canvas = canvasRef.current;
805
+ if (canvas === null) return;
806
+ draw();
807
+ if (typeof ResizeObserver === "undefined") return;
808
+ const observer = new ResizeObserver(draw);
809
+ observer.observe(canvas);
810
+ return () => {
811
+ observer.disconnect();
812
+ };
813
+ }, [draw]);
814
+ (0, react.useEffect)(() => {
815
+ const normalized = Math.max(WAVEFORM_BASELINE, Math.min(1, Math.pow(amplitude, .72)));
816
+ const samples = samplesRef.current;
817
+ if (samples.length === 0) {
818
+ draw();
819
+ return;
820
+ }
821
+ if (reducedMotionRef.current) samples.fill(normalized);
822
+ else {
823
+ samples.push(normalized);
824
+ samples.shift();
825
+ }
826
+ draw();
827
+ }, [amplitude, draw]);
828
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("canvas", {
829
+ ref: canvasRef,
830
+ className: styles.recordingCanvas,
831
+ "aria-hidden": "true"
832
+ });
833
+ }
834
+ function RecordingTakeover({ amplitude, disabled, phase, label, onStop, stopLabel }) {
835
+ const recording = phase === "recording";
836
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
837
+ className: styles.recordingTakeover,
838
+ "data-phase": phase,
839
+ children: [
840
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
841
+ className: styles.recordingTrack,
842
+ children: recording ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RecordingWaveform, { amplitude }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
843
+ className: styles.recordingProgress,
844
+ role: "status",
845
+ "aria-live": "polite",
846
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("i", { "aria-hidden": "true" }), label]
847
+ })
848
+ }),
849
+ recording ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
850
+ type: "button",
851
+ className: `${styles.mic} ${styles.recordingStop}`,
852
+ disabled,
853
+ "aria-label": stopLabel,
854
+ title: stopLabel,
855
+ onClick: onStop,
856
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MicIcon, { stopped: true })
857
+ }) : null,
858
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
859
+ className: styles.srOnly,
860
+ role: "status",
861
+ "aria-live": "polite",
862
+ children: label
863
+ })
864
+ ]
865
+ });
866
+ }
867
+ function SpeechMic({ controller, getLocale, sessionId, input, inputActions, t }) {
868
+ const state = (0, react.useSyncExternalStore)(controller.subscribe, controller.getSnapshot);
869
+ const tooltipId = (0, react.useId)();
870
+ const [explanationOpen, setExplanationOpen] = (0, react.useState)(false);
871
+ const explanationTimer = (0, react.useRef)(void 0);
872
+ const activeHere = state.activeSessionId === sessionId;
873
+ const activeElsewhere = state.activeSessionId !== void 0 && !activeHere;
874
+ const disconnected = state.status?.credential.configured !== true;
875
+ const exhausted = state.status?.balance?.exhausted === true;
876
+ const unavailable = !activeHere && (disconnected || exhausted || activeElsewhere);
877
+ const disabled = !activeHere && input.phase !== "plain" || state.phase === "starting" || state.phase === "finalizing";
878
+ const title = activeHere ? t("micStop") : disconnected ? t("disconnectedMic") : exhausted ? t("emptyMic") : activeElsewhere ? t("anotherMic") : t("micStart");
879
+ (0, react.useEffect)(() => {
880
+ controller.ensureMetadata();
881
+ return () => {
882
+ if (explanationTimer.current !== void 0) clearTimeout(explanationTimer.current);
883
+ controller.release(sessionId);
884
+ };
885
+ }, [controller, sessionId]);
886
+ const toggle = () => {
887
+ if (unavailable) {
888
+ controller.reportError(title);
889
+ setExplanationOpen(true);
890
+ if (explanationTimer.current !== void 0) clearTimeout(explanationTimer.current);
891
+ explanationTimer.current = setTimeout(() => {
892
+ setExplanationOpen(false);
893
+ }, 4e3);
894
+ return;
895
+ }
896
+ controller.toggle({
897
+ sessionId,
898
+ draft: input.draft,
899
+ inputActions,
900
+ locale: getLocale(),
901
+ listeningReason: t("listening")
902
+ }).catch((error) => {
903
+ controller.reportError(error instanceof Error ? error.message : "Unable to start microphone");
904
+ });
905
+ };
906
+ if (activeHere) {
907
+ const takeoverPhase = state.phase === "starting" || state.phase === "finalizing" ? state.phase : "recording";
908
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RecordingTakeover, {
909
+ amplitude: state.amplitude,
910
+ disabled,
911
+ phase: takeoverPhase,
912
+ label: state.phase === "starting" ? t("starting") : state.phase === "finalizing" ? t("finalizing") : t("listening"),
913
+ onStop: toggle,
914
+ stopLabel: title
915
+ });
916
+ }
917
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
918
+ className: styles.micWrap,
919
+ "data-explanation-open": explanationOpen || void 0,
920
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
921
+ type: "button",
922
+ className: `${styles.mic} ${activeHere ? styles.micActive : ""}`,
923
+ disabled,
924
+ "aria-disabled": disabled || unavailable,
925
+ "aria-describedby": tooltipId,
926
+ "aria-label": title,
927
+ "aria-pressed": activeHere,
928
+ onClick: toggle,
929
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MicIcon, { stopped: activeHere })
930
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
931
+ id: tooltipId,
932
+ className: styles.micTooltip,
933
+ role: "tooltip",
934
+ children: title
935
+ })]
936
+ });
937
+ }
938
+ function MicrophoneSelector({ controller, state, t, variant = "metrics" }) {
939
+ const menuId = (0, react.useId)();
940
+ const rootRef = (0, react.useRef)(null);
941
+ const triggerRef = (0, react.useRef)(null);
942
+ const [open, setOpen] = (0, react.useState)(false);
943
+ const deviceLabel = (device) => device.systemDefault === true ? t("systemDefaultMicrophone") : device.label;
944
+ const current = state.microphones.find((device) => device.deviceId === state.activeMicrophoneId);
945
+ const label = current === void 0 ? state.microphones.length === 0 ? t("loadingMicrophones") : t("microphone") : deviceLabel(current);
946
+ const deviceError = state.microphoneError === void 0 ? void 0 : t("microphoneError", { message: state.microphoneError });
947
+ (0, react.useEffect)(() => {
948
+ if (!open) return;
949
+ const closeOutside = (event) => {
950
+ if (event.target instanceof Node && rootRef.current?.contains(event.target) !== true) setOpen(false);
951
+ };
952
+ const closeEscape = (event) => {
953
+ if (event.key !== "Escape") return;
954
+ setOpen(false);
955
+ triggerRef.current?.focus();
956
+ };
957
+ document.addEventListener("pointerdown", closeOutside);
958
+ document.addEventListener("keydown", closeEscape);
959
+ return () => {
960
+ document.removeEventListener("pointerdown", closeOutside);
961
+ document.removeEventListener("keydown", closeEscape);
962
+ };
963
+ }, [open]);
964
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
965
+ ref: rootRef,
966
+ className: styles.deviceDock,
967
+ "data-variant": variant,
968
+ "data-error": deviceError === void 0 ? void 0 : true,
969
+ "aria-busy": state.switchingMicrophone,
970
+ children: [
971
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
972
+ ref: triggerRef,
973
+ type: "button",
974
+ className: styles.deviceTrigger,
975
+ "aria-label": `${t("microphone")}: ${label}`,
976
+ "aria-haspopup": "menu",
977
+ "aria-controls": menuId,
978
+ "aria-expanded": open,
979
+ disabled: state.switchingMicrophone || state.microphones.length === 0,
980
+ title: deviceError ?? (state.switchingMicrophone ? t("switchingMicrophone") : label),
981
+ onClick: () => {
982
+ setOpen((value) => !value);
983
+ },
984
+ onKeyDown: (event) => {
985
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
986
+ event.preventDefault();
987
+ setOpen(true);
988
+ }
989
+ },
990
+ children: [
991
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
992
+ className: styles.deviceIcon,
993
+ "aria-hidden": "true",
994
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MicIcon, { stopped: false })
995
+ }),
996
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
997
+ className: styles.deviceLabel,
998
+ children: label
999
+ }),
1000
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1001
+ className: styles.deviceChevron,
1002
+ "aria-hidden": "true",
1003
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronIcon, {})
1004
+ })
1005
+ ]
1006
+ }),
1007
+ open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1008
+ id: menuId,
1009
+ className: styles.deviceMenu,
1010
+ role: "menu",
1011
+ "aria-label": t("microphone"),
1012
+ children: state.microphones.map((device) => {
1013
+ const selected = device.deviceId === state.activeMicrophoneId;
1014
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1015
+ type: "button",
1016
+ className: styles.deviceMenuItem,
1017
+ role: "menuitemradio",
1018
+ "aria-checked": selected,
1019
+ onClick: () => {
1020
+ setOpen(false);
1021
+ if (!selected) controller.switchMicrophone(device.deviceId);
1022
+ },
1023
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: deviceLabel(device) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1024
+ className: styles.deviceCheck,
1025
+ "aria-hidden": "true",
1026
+ children: selected ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckIcon, {}) : null
1027
+ })]
1028
+ }, device.deviceId);
1029
+ })
1030
+ }) : null,
1031
+ deviceError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1032
+ className: styles.srOnly,
1033
+ role: "alert",
1034
+ children: deviceError
1035
+ })
1036
+ ]
1037
+ });
1038
+ }
1039
+ function MicrophoneDockPortal({ controller, state, t }) {
1040
+ const markerRef = (0, react.useRef)(null);
1041
+ const [target, setTarget] = (0, react.useState)(null);
1042
+ (0, react.useLayoutEffect)(() => {
1043
+ const marker = markerRef.current;
1044
+ const slot = marker?.closest("[data-slot=\"conversation.composer.dock\"]");
1045
+ if (!(slot instanceof HTMLElement)) return;
1046
+ const findTarget = () => {
1047
+ const metrics = Array.from(slot.children).find((child) => child !== marker && child.textContent?.includes("TTFT"));
1048
+ setTarget(metrics instanceof HTMLElement ? metrics : null);
1049
+ };
1050
+ findTarget();
1051
+ const observer = new MutationObserver(findTarget);
1052
+ observer.observe(slot, { childList: true });
1053
+ return () => {
1054
+ observer.disconnect();
1055
+ };
1056
+ }, []);
1057
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1058
+ ref: markerRef,
1059
+ className: styles.deviceMarker,
1060
+ "aria-hidden": "true"
1061
+ }), target === null ? null : (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1062
+ className: styles.deviceSeparator,
1063
+ "aria-hidden": "true",
1064
+ children: "|"
1065
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MicrophoneSelector, {
1066
+ controller,
1067
+ state,
1068
+ t
1069
+ })] }), target)] });
1070
+ }
1071
+ function MicrophoneInputDock({ controller, state, t }) {
1072
+ const markerRef = (0, react.useRef)(null);
1073
+ const [heroTarget, setHeroTarget] = (0, react.useState)(null);
1074
+ (0, react.useLayoutEffect)(() => {
1075
+ const composer = markerRef.current?.closest("[data-slot=\"conversation.composer\"]");
1076
+ if (!(composer instanceof HTMLElement)) return;
1077
+ const update = () => {
1078
+ if (composer.querySelector("[data-slot=\"conversation.composer.dock\"]") !== null) {
1079
+ setHeroTarget(null);
1080
+ return;
1081
+ }
1082
+ const presetSlot = composer.querySelector("[data-slot=\"conversation.hero.agentPreset\"]");
1083
+ setHeroTarget(presetSlot?.parentElement instanceof HTMLElement ? presetSlot.parentElement : null);
1084
+ };
1085
+ update();
1086
+ const observer = new MutationObserver(update);
1087
+ observer.observe(composer, {
1088
+ childList: true,
1089
+ subtree: true
1090
+ });
1091
+ return () => {
1092
+ observer.disconnect();
1093
+ };
1094
+ }, []);
1095
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1096
+ ref: markerRef,
1097
+ className: styles.deviceMarker,
1098
+ "aria-hidden": "true"
1099
+ }), heroTarget === null ? null : (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(MicrophoneSelector, {
1100
+ controller,
1101
+ state,
1102
+ t,
1103
+ variant: "hero"
1104
+ }), heroTarget)] });
1105
+ }
1106
+ function SpeechDock({ controller, sessionId, t }) {
1107
+ const state = (0, react.useSyncExternalStore)(controller.subscribe, controller.getSnapshot);
1108
+ if (state.activeSessionId === sessionId && state.phase === "recording") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MicrophoneDockPortal, {
1109
+ controller,
1110
+ state,
1111
+ t
1112
+ });
1113
+ if (state.activeSessionId === sessionId || state.error === void 0) return null;
1114
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1115
+ className: styles.dockError,
1116
+ role: "alert",
1117
+ children: state.error
1118
+ });
1119
+ }
1120
+ function SpeechInputDock({ controller, sessionId, t }) {
1121
+ const state = (0, react.useSyncExternalStore)(controller.subscribe, controller.getSnapshot);
1122
+ if (state.activeSessionId !== sessionId || state.phase !== "recording") return null;
1123
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MicrophoneInputDock, {
1124
+ controller,
1125
+ state,
1126
+ t
1127
+ });
1128
+ }
1129
+
1130
+ //#endregion
1131
+ //#region src/client/SpeechSettings.tsx
1132
+ const LANGUAGES = [
1133
+ ["auto", "Auto"],
1134
+ ["en", "English"],
1135
+ ["zh", "中文"],
1136
+ ["ja", "日本語"],
1137
+ ["ko", "한국어"],
1138
+ ["es", "Español"],
1139
+ ["fr", "Français"],
1140
+ ["de", "Deutsch"],
1141
+ ["pt", "Português"],
1142
+ ["it", "Italiano"],
1143
+ ["ru", "Русский"],
1144
+ ["ar", "العربية"],
1145
+ ["hi", "हिन्दी"],
1146
+ ["id", "Bahasa Indonesia"],
1147
+ ["vi", "Tiếng Việt"]
1148
+ ];
1149
+ function money(value) {
1150
+ return new Intl.NumberFormat(void 0, {
1151
+ style: "currency",
1152
+ currency: "USD",
1153
+ minimumFractionDigits: 2
1154
+ }).format(value);
1155
+ }
1156
+ function Field({ label, children, hint }) {
1157
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1158
+ className: styles.field,
1159
+ children: [
1160
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1161
+ className: styles.label,
1162
+ children: label
1163
+ }),
1164
+ children,
1165
+ hint === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1166
+ className: styles.hint,
1167
+ children: hint
1168
+ })
1169
+ ]
1170
+ });
1171
+ }
1172
+ function SpeechSettings({ controller, scope, getLocale, t }) {
1173
+ const client = (0, react.useSyncExternalStore)(controller.subscribe, controller.getSnapshot);
1174
+ const settings = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot());
1175
+ const value = settings.value;
1176
+ const [email, setEmail] = (0, react.useState)("");
1177
+ const [code, setCode] = (0, react.useState)("");
1178
+ const [apiKey, setApiKey] = (0, react.useState)("");
1179
+ const [codeExpiry, setCodeExpiry] = (0, react.useState)(0);
1180
+ const [now, setNow] = (0, react.useState)(Date.now());
1181
+ const [busy, setBusy] = (0, react.useState)(null);
1182
+ const [message, setMessage] = (0, react.useState)(null);
1183
+ const [error, setError] = (0, react.useState)(null);
1184
+ const [topUpAmount, setTopUpAmount] = (0, react.useState)(10);
1185
+ const [topUpUrl, setTopUpUrl] = (0, react.useState)(null);
1186
+ const [languageDraft, setLanguageDraft] = (0, react.useState)("auto");
1187
+ const [contextDraft, setContextDraft] = (0, react.useState)("");
1188
+ (0, react.useEffect)(() => {
1189
+ controller.ensureMetadata();
1190
+ }, [controller]);
1191
+ (0, react.useEffect)(() => {
1192
+ if (client.status?.settings.defaultTopUpUsd !== void 0) setTopUpAmount(client.status.settings.defaultTopUpUsd);
1193
+ }, [client.status?.settings.defaultTopUpUsd]);
1194
+ (0, react.useEffect)(() => {
1195
+ setLanguageDraft(value?.language ?? "auto");
1196
+ }, [value?.language]);
1197
+ (0, react.useEffect)(() => {
1198
+ setContextDraft(value?.context ?? "");
1199
+ }, [value?.context]);
1200
+ (0, react.useEffect)(() => {
1201
+ if (codeExpiry <= Date.now()) return;
1202
+ const timer = setInterval(() => {
1203
+ setNow(Date.now());
1204
+ }, 1e3);
1205
+ return () => {
1206
+ clearInterval(timer);
1207
+ };
1208
+ }, [codeExpiry]);
1209
+ const catalog = client.catalog?.bindings ?? [];
1210
+ const selectedModel = selectBinding(catalog, getLocale(), {
1211
+ ...value?.model === void 0 ? {} : { model: value.model },
1212
+ ...value?.provider === void 0 ? {} : { provider: value.provider }
1213
+ })?.model ?? value?.model ?? "";
1214
+ const providers = catalog.filter((binding) => binding.model === selectedModel);
1215
+ const selectedProvider = providers.some((binding) => binding.provider === value?.provider) ? value?.provider ?? "" : providers.find((binding) => binding.isProviderDefault)?.provider ?? providers[0]?.provider ?? "";
1216
+ const selectedBinding = providers.find((binding) => binding.provider === selectedProvider);
1217
+ const models = (0, react.useMemo)(() => [...new Set(catalog.map((binding) => binding.model))].sort(), [catalog]);
1218
+ const remaining = Math.max(0, Math.ceil((codeExpiry - now) / 1e3));
1219
+ const connected = client.status?.credential.configured === true;
1220
+ const writable = settings.writable;
1221
+ const run = async (key, action) => {
1222
+ setBusy(key);
1223
+ setError(null);
1224
+ setMessage(null);
1225
+ try {
1226
+ await action();
1227
+ } catch (cause) {
1228
+ setError(cause instanceof Error ? cause.message : "Request failed");
1229
+ } finally {
1230
+ setBusy(null);
1231
+ }
1232
+ };
1233
+ const write = (field, next) => {
1234
+ run(`setting:${field}`, async () => {
1235
+ await scope.set(field, next);
1236
+ await controller.ensureMetadata(true);
1237
+ setMessage(t("saved"));
1238
+ });
1239
+ };
1240
+ const commitLanguage = () => {
1241
+ const next = languageDraft.trim() || "auto";
1242
+ if (next !== "auto" && !/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u.test(next)) {
1243
+ setError(t("invalidLanguage"));
1244
+ return;
1245
+ }
1246
+ if (next !== (value?.language ?? "auto")) write("language", next);
1247
+ };
1248
+ const commitContext = () => {
1249
+ if (contextDraft !== (value?.context ?? "")) write("context", contextDraft);
1250
+ };
1251
+ const sendCode = () => {
1252
+ run("send-code", async () => {
1253
+ const response = await speechApi.startAuth(email);
1254
+ setCodeExpiry(Date.now() + response.expiresInSeconds * 1e3);
1255
+ setNow(Date.now());
1256
+ setMessage(t("codeSent"));
1257
+ });
1258
+ };
1259
+ const verify = () => {
1260
+ run("verify", async () => {
1261
+ await speechApi.verifyAuth(email, code);
1262
+ setCode("");
1263
+ setCodeExpiry(0);
1264
+ await controller.ensureMetadata(true);
1265
+ });
1266
+ };
1267
+ const setKey = () => {
1268
+ run("api-key", async () => {
1269
+ await speechApi.setKey(apiKey);
1270
+ setApiKey("");
1271
+ await controller.ensureMetadata(true);
1272
+ });
1273
+ };
1274
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1275
+ className: styles.settings,
1276
+ children: [
1277
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
1278
+ className: styles.header,
1279
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", { children: t("title") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("subtitle") })] }), connected ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1280
+ className: styles.secondary,
1281
+ type: "button",
1282
+ disabled: client.metadataLoading,
1283
+ onClick: () => {
1284
+ controller.ensureMetadata(true);
1285
+ },
1286
+ children: client.metadataLoading ? t("loading") : t("refresh")
1287
+ }) : null]
1288
+ }),
1289
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
1290
+ className: `${styles.card} ${connected ? "" : styles.accountWelcome}`,
1291
+ children: connected ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1292
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1293
+ className: styles.cardTitle,
1294
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: t("account") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1295
+ className: styles.good,
1296
+ children: t("connected")
1297
+ })]
1298
+ }),
1299
+ client.status?.credential.source === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1300
+ className: styles.hint,
1301
+ children: t("managedBy", { source: client.status.credential.source })
1302
+ }),
1303
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1304
+ className: styles.secondary,
1305
+ type: "button",
1306
+ disabled: busy !== null || client.status?.credential.writable !== true,
1307
+ onClick: () => {
1308
+ run("logout", async () => {
1309
+ await speechApi.logout();
1310
+ await controller.ensureMetadata(true);
1311
+ });
1312
+ },
1313
+ children: t("disconnect")
1314
+ })
1315
+ ] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1316
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1317
+ className: styles.cardTitle,
1318
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1319
+ className: styles.accountIntro,
1320
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: t("signupTitle") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("signupLead") })]
1321
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1322
+ className: styles.freeCredit,
1323
+ children: t("freeCredit")
1324
+ })]
1325
+ }),
1326
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1327
+ className: styles.stack,
1328
+ children: [
1329
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
1330
+ label: t("email"),
1331
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1332
+ className: styles.input,
1333
+ type: "email",
1334
+ autoComplete: "email",
1335
+ value: email,
1336
+ onChange: (event) => {
1337
+ setEmail(event.target.value);
1338
+ }
1339
+ })
1340
+ }),
1341
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1342
+ className: styles.primary,
1343
+ type: "button",
1344
+ disabled: busy !== null || email.length === 0,
1345
+ onClick: sendCode,
1346
+ children: remaining > 0 ? t("resend") : t("sendCode")
1347
+ }),
1348
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1349
+ className: styles.accountHint,
1350
+ children: t("emailFlowHint")
1351
+ }),
1352
+ codeExpiry === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
1353
+ label: t("code"),
1354
+ hint: t("expires", { seconds: remaining }),
1355
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1356
+ className: styles.input,
1357
+ inputMode: "numeric",
1358
+ autoComplete: "one-time-code",
1359
+ maxLength: 6,
1360
+ value: code,
1361
+ onChange: (event) => {
1362
+ setCode(event.target.value.replace(/\D/gu, "").slice(0, 6));
1363
+ }
1364
+ })
1365
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1366
+ className: styles.primary,
1367
+ type: "button",
1368
+ disabled: busy !== null || code.length !== 6 || remaining === 0,
1369
+ onClick: verify,
1370
+ children: t("verify")
1371
+ })] })
1372
+ ]
1373
+ }),
1374
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("details", {
1375
+ className: styles.apiKeyDetails,
1376
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("summary", {
1377
+ className: styles.apiKeySummary,
1378
+ children: t("apiKeySignIn")
1379
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1380
+ className: styles.apiKeyBody,
1381
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
1382
+ label: t("apiKey"),
1383
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1384
+ className: styles.input,
1385
+ type: "password",
1386
+ autoComplete: "off",
1387
+ value: apiKey,
1388
+ onChange: (event) => {
1389
+ setApiKey(event.target.value);
1390
+ }
1391
+ })
1392
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1393
+ className: styles.secondary,
1394
+ type: "button",
1395
+ disabled: busy !== null || apiKey.length < 8,
1396
+ onClick: setKey,
1397
+ children: t("connect")
1398
+ })]
1399
+ })]
1400
+ })
1401
+ ] })
1402
+ }),
1403
+ connected ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1404
+ className: styles.card,
1405
+ children: [
1406
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: t("settings") }),
1407
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1408
+ className: styles.grid,
1409
+ children: [
1410
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1411
+ className: styles.modelFull,
1412
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
1413
+ label: t("model"),
1414
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1415
+ className: styles.input,
1416
+ value: selectedModel,
1417
+ disabled: !writable || models.length === 0,
1418
+ onChange: (event) => {
1419
+ const model = event.target.value;
1420
+ const choices = catalog.filter((binding) => binding.model === model);
1421
+ const provider = choices.find((binding) => binding.isProviderDefault)?.provider ?? choices[0]?.provider;
1422
+ run("setting:model", async () => {
1423
+ await scope.set("model", model);
1424
+ if (provider !== void 0) await scope.set("provider", provider);
1425
+ await controller.ensureMetadata(true);
1426
+ });
1427
+ },
1428
+ children: models.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1429
+ value: model,
1430
+ children: model
1431
+ }, model))
1432
+ })
1433
+ })
1434
+ }),
1435
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
1436
+ label: t("provider"),
1437
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1438
+ className: styles.input,
1439
+ value: selectedProvider,
1440
+ disabled: !writable || providers.length === 0,
1441
+ onChange: (event) => {
1442
+ write("provider", event.target.value);
1443
+ },
1444
+ children: providers.map((binding) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1445
+ value: binding.provider,
1446
+ children: binding.provider
1447
+ }, binding.provider))
1448
+ })
1449
+ }),
1450
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Field, {
1451
+ label: t("language"),
1452
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1453
+ className: styles.input,
1454
+ list: "dsh-speech-languages",
1455
+ value: languageDraft,
1456
+ disabled: !writable,
1457
+ onChange: (event) => {
1458
+ setLanguageDraft(event.target.value);
1459
+ },
1460
+ onBlur: commitLanguage,
1461
+ onKeyDown: (event) => {
1462
+ if (event.key === "Enter") event.currentTarget.blur();
1463
+ }
1464
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("datalist", {
1465
+ id: "dsh-speech-languages",
1466
+ children: LANGUAGES.map(([id, label]) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1467
+ value: id,
1468
+ children: id === "auto" ? t("auto") : label
1469
+ }, id))
1470
+ })]
1471
+ })
1472
+ ]
1473
+ }),
1474
+ selectedBinding?.contextSupported === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("details", {
1475
+ className: styles.contextDetails,
1476
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("summary", {
1477
+ className: styles.contextSummary,
1478
+ children: [
1479
+ t("context"),
1480
+ " ",
1481
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("optional") })
1482
+ ]
1483
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1484
+ className: styles.contextBody,
1485
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1486
+ className: styles.hint,
1487
+ children: t("contextHint")
1488
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
1489
+ className: styles.textarea,
1490
+ "aria-label": t("context"),
1491
+ maxLength: 4e3,
1492
+ value: contextDraft,
1493
+ disabled: !writable,
1494
+ onChange: (event) => {
1495
+ setContextDraft(event.target.value);
1496
+ },
1497
+ onBlur: commitContext
1498
+ })]
1499
+ })]
1500
+ }) : null
1501
+ ]
1502
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1503
+ className: styles.card,
1504
+ children: [
1505
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: t("balance") }),
1506
+ client.status?.balance === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1507
+ className: styles.muted,
1508
+ children: client.status?.balanceError === void 0 ? t("loading") : t("balanceUnavailable")
1509
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", {
1510
+ className: styles.balanceValue,
1511
+ children: money(client.status.balance.paidUsd)
1512
+ }), client.status.balance.exhausted ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1513
+ className: styles.danger,
1514
+ children: t("emptyBalance")
1515
+ }) : client.status.balance.low ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1516
+ className: styles.warning,
1517
+ children: t("lowBalance")
1518
+ }) : null] }),
1519
+ connected ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1520
+ className: styles.topUp,
1521
+ children: [
1522
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
1523
+ label: t("amount"),
1524
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1525
+ className: styles.input,
1526
+ type: "number",
1527
+ min: 5,
1528
+ max: 1e3,
1529
+ step: 1,
1530
+ value: topUpAmount,
1531
+ onChange: (event) => {
1532
+ setTopUpAmount(Number(event.target.value));
1533
+ }
1534
+ })
1535
+ }),
1536
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1537
+ className: styles.primary,
1538
+ type: "button",
1539
+ disabled: busy !== null || topUpAmount < 5 || topUpAmount > 1e3,
1540
+ onClick: () => {
1541
+ run("top-up", async () => {
1542
+ setTopUpUrl((await speechApi.topUp(topUpAmount)).url);
1543
+ });
1544
+ },
1545
+ children: t("createLink")
1546
+ }),
1547
+ topUpUrl === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
1548
+ className: styles.checkout,
1549
+ href: topUpUrl,
1550
+ target: "_blank",
1551
+ rel: "noreferrer",
1552
+ children: t("openCheckout")
1553
+ })
1554
+ ]
1555
+ }) : null
1556
+ ]
1557
+ })] }) : null,
1558
+ message === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1559
+ className: styles.good,
1560
+ role: "status",
1561
+ children: message
1562
+ }),
1563
+ error === null && client.error === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1564
+ className: styles.danger,
1565
+ role: "alert",
1566
+ children: error ?? client.error
1567
+ })
1568
+ ]
1569
+ });
1570
+ }
1571
+
1572
+ //#endregion
1573
+ //#region src/client/locales.ts
1574
+ const en = {
1575
+ nav: "Speech",
1576
+ title: "Speech",
1577
+ subtitle: "Live transcription through AllModels.io. Audio is relayed only while the microphone is active.",
1578
+ account: "AllModels account",
1579
+ signupTitle: "Sign in or sign up free",
1580
+ signupLead: "Enter your email to sign in or create a free AllModels account.",
1581
+ freeCredit: "$1 free credit",
1582
+ emailFlowHint: "No password needed. New accounts receive $1 in free credit.",
1583
+ connected: "Connected",
1584
+ notConnected: "Not connected",
1585
+ managedBy: "Credential source: {source}",
1586
+ email: "Email address",
1587
+ sendCode: "Send sign-in code",
1588
+ code: "Six-digit code",
1589
+ verify: "Verify and connect",
1590
+ resend: "Resend code",
1591
+ expires: "Code expires in {seconds}s",
1592
+ apiKeySignIn: "Connect with an API key instead",
1593
+ apiKey: "AllModels API key",
1594
+ connect: "Connect",
1595
+ disconnect: "Disconnect",
1596
+ settings: "Recognition",
1597
+ model: "STT model",
1598
+ provider: "Provider",
1599
+ language: "Language",
1600
+ invalidLanguage: "Enter Auto or a valid language tag such as en, zh-CN, or ja.",
1601
+ auto: "Auto-detect",
1602
+ context: "Recognition context",
1603
+ optional: "Optional",
1604
+ contextHint: "Names, jargon, or a short description that helps recognition.",
1605
+ contextUnsupported: "This provider does not accept context. The saved value is preserved but will not be sent.",
1606
+ balance: "Balance",
1607
+ paid: "Paid",
1608
+ promotion: "Promotion",
1609
+ usable: "Usable",
1610
+ lowBalance: "Balance is below $0.50. Top up soon to avoid interruption.",
1611
+ emptyBalance: "Balance is empty. Add funds before recording.",
1612
+ balanceUnavailable: "Balance is temporarily unavailable; recording remains available.",
1613
+ amount: "Top-up amount (USD)",
1614
+ createLink: "Create top-up link",
1615
+ openCheckout: "Open secure checkout",
1616
+ refresh: "Refresh",
1617
+ loading: "Loading…",
1618
+ codeSent: "A sign-in code was sent. It remains valid for five minutes.",
1619
+ saved: "Saved",
1620
+ micStart: "Start voice input",
1621
+ micStop: "Stop voice input",
1622
+ starting: "Preparing microphone…",
1623
+ listening: "Listening…",
1624
+ finalizing: "Finishing transcription…",
1625
+ microphone: "Microphone",
1626
+ systemDefaultMicrophone: "System default",
1627
+ loadingMicrophones: "Loading microphones…",
1628
+ switchingMicrophone: "Switching microphone…",
1629
+ microphoneError: "Could not switch microphone: {message}",
1630
+ disconnectedMic: "Connect AllModels in Settings → Speech",
1631
+ emptyMic: "Top up your AllModels balance in Settings → Speech",
1632
+ anotherMic: "The microphone is active in another session",
1633
+ liveModel: "{provider} · {model}"
1634
+ };
1635
+ const zh = {
1636
+ nav: "语音",
1637
+ title: "语音",
1638
+ subtitle: "通过 AllModels.io 实时转写。仅在麦克风开启时转发音频,不保存录音。",
1639
+ account: "AllModels 账户",
1640
+ signupTitle: "登录或免费注册",
1641
+ signupLead: "输入邮箱即可登录或创建免费的 AllModels 账户。",
1642
+ freeCredit: "赠送 1 美元额度",
1643
+ emailFlowHint: "无需密码。新账户将获得 1 美元免费额度。",
1644
+ connected: "已连接",
1645
+ notConnected: "未连接",
1646
+ managedBy: "凭据来源:{source}",
1647
+ email: "电子邮箱",
1648
+ sendCode: "发送登录验证码",
1649
+ code: "六位验证码",
1650
+ verify: "验证并连接",
1651
+ resend: "重新发送验证码",
1652
+ expires: "验证码将在 {seconds} 秒后过期",
1653
+ apiKeySignIn: "改用 API 密钥连接",
1654
+ apiKey: "AllModels API 密钥",
1655
+ connect: "连接",
1656
+ disconnect: "断开连接",
1657
+ settings: "语音识别",
1658
+ model: "STT 模型",
1659
+ provider: "服务商",
1660
+ language: "语言",
1661
+ invalidLanguage: "请输入“自动”或有效的语言标签,例如 en、zh-CN 或 ja。",
1662
+ auto: "自动检测",
1663
+ context: "识别上下文",
1664
+ optional: "可选",
1665
+ contextHint: "填写名称、术语或简短说明,以帮助提高识别准确率。",
1666
+ contextUnsupported: "当前服务商不支持上下文。已保存的内容会保留,但不会发送。",
1667
+ balance: "余额",
1668
+ paid: "付费余额",
1669
+ promotion: "赠送余额",
1670
+ usable: "可用余额",
1671
+ lowBalance: "余额低于 0.50 美元,请及时充值以免中断。",
1672
+ emptyBalance: "余额不足,请充值后再录音。",
1673
+ balanceUnavailable: "暂时无法查询余额;仍可尝试录音。",
1674
+ amount: "充值金额(美元)",
1675
+ createLink: "创建充值链接",
1676
+ openCheckout: "打开安全支付页面",
1677
+ refresh: "刷新",
1678
+ loading: "加载中…",
1679
+ codeSent: "登录验证码已发送,五分钟内有效。",
1680
+ saved: "已保存",
1681
+ micStart: "开始语音输入",
1682
+ micStop: "停止语音输入",
1683
+ starting: "正在准备麦克风…",
1684
+ listening: "正在聆听…",
1685
+ finalizing: "正在完成转写…",
1686
+ microphone: "麦克风",
1687
+ systemDefaultMicrophone: "系统默认",
1688
+ loadingMicrophones: "正在加载麦克风…",
1689
+ switchingMicrophone: "正在切换麦克风…",
1690
+ microphoneError: "无法切换麦克风:{message}",
1691
+ disconnectedMic: "请前往“设置 → 语音”连接 AllModels",
1692
+ emptyMic: "请前往“设置 → 语音”为 AllModels 充值",
1693
+ anotherMic: "麦克风正在另一个会话中使用",
1694
+ liveModel: "{provider} · {model}"
1695
+ };
1696
+
1697
+ //#endregion
1698
+ //#region src/client/index.tsx
1699
+ const inject = [
1700
+ "slots",
1701
+ "locale",
1702
+ "settingsScope"
1703
+ ];
1704
+ function apply(ctx) {
1705
+ const controller = new SpeechController();
1706
+ const scope = ctx.settingsScope.bind({ namespace: "dsh-speech" });
1707
+ const getLocale = () => ctx.locale.getLocale().active;
1708
+ ctx.effect(() => ctx.locale.register("speech", {
1709
+ en,
1710
+ zh
1711
+ }), "dsh-speech: locale dictionaries");
1712
+ ctx.effect(() => {
1713
+ if (typeof document === "undefined") return () => {};
1714
+ const style = document.createElement("style");
1715
+ style.dataset.plugin = "dsh-speech";
1716
+ style.dataset.pluginCss = "dsh-speech";
1717
+ style.textContent = STYLE_TEXT;
1718
+ document.head.append(style);
1719
+ return () => {
1720
+ style.remove();
1721
+ };
1722
+ }, "dsh-speech: client styles");
1723
+ ctx.effect(() => {
1724
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {};
1725
+ const labels = new Set([en.nav, zh.nav]);
1726
+ const markSpeechNavigation = () => {
1727
+ for (const button of document.querySelectorAll("[role=\"dialog\"] nav button")) if (labels.has(button.textContent?.trim() ?? "")) button.dataset.dshSpeechNav = "";
1728
+ else delete button.dataset.dshSpeechNav;
1729
+ };
1730
+ const observer = new MutationObserver(markSpeechNavigation);
1731
+ observer.observe(document.body, {
1732
+ childList: true,
1733
+ subtree: true,
1734
+ characterData: true
1735
+ });
1736
+ markSpeechNavigation();
1737
+ return () => {
1738
+ observer.disconnect();
1739
+ for (const button of document.querySelectorAll("[data-dsh-speech-nav]")) delete button.dataset.dshSpeechNav;
1740
+ };
1741
+ }, "dsh-speech: settings navigation icon");
1742
+ ctx.effect(() => () => {
1743
+ controller.dispose();
1744
+ }, "dsh-speech: browser controller");
1745
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
1746
+ name: "settings.section",
1747
+ id: "speech",
1748
+ order: 30,
1749
+ label: () => ctx.locale.bind("speech")("nav"),
1750
+ locale: "speech",
1751
+ inject: () => ({
1752
+ controller,
1753
+ scope,
1754
+ getLocale
1755
+ })
1756
+ }, SpeechSettings));
1757
+ ctx.inject(["conversation"], (scoped) => {
1758
+ const blocks = scoped.conversation.blocks;
1759
+ controller.attachBlocks(blocks);
1760
+ const mic = scoped.slots.register({
1761
+ name: "conversation.input.right",
1762
+ id: "speech-microphone",
1763
+ order: 100,
1764
+ locale: "speech",
1765
+ inject: () => ({
1766
+ controller,
1767
+ getLocale
1768
+ })
1769
+ }, SpeechMic);
1770
+ const dock = scoped.slots.register({
1771
+ name: "conversation.composer.dock",
1772
+ id: "speech-amplitude",
1773
+ order: 100,
1774
+ locale: "speech",
1775
+ inject: () => ({
1776
+ controller,
1777
+ getLocale
1778
+ })
1779
+ }, SpeechDock);
1780
+ const inputDock = scoped.slots.register({
1781
+ name: "conversation.input.dock",
1782
+ id: "speech-microphone-selector",
1783
+ order: 100,
1784
+ locale: "speech",
1785
+ inject: () => ({
1786
+ controller,
1787
+ getLocale
1788
+ })
1789
+ }, SpeechInputDock);
1790
+ return () => {
1791
+ controller.detachBlocks(blocks);
1792
+ mic();
1793
+ dock();
1794
+ inputDock();
1795
+ };
1796
+ });
1797
+ }
1798
+
1799
+ //#endregion
1800
+ exports.apply = apply;
1801
+ exports.inject = inject;
1802
+ return module.exports; } });
1803
+ //# sourceMappingURL=client.js.map