@opengeni/react 0.45.0 → 0.48.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/README.md +68 -12
- package/dist/{chunk-U3CSCMWA.js → chunk-FK6VG4LL.js} +9 -13
- package/dist/chunk-FK6VG4LL.js.map +1 -0
- package/dist/{chunk-HFO4ERGQ.js → chunk-FWXGSNXT.js} +2 -2
- package/dist/{chunk-SRFUT2ZU.js → chunk-I4MPFDRP.js} +119 -63
- package/dist/chunk-I4MPFDRP.js.map +1 -0
- package/dist/{chunk-EB67J347.js → chunk-KZ73RL76.js} +111 -31
- package/dist/chunk-KZ73RL76.js.map +1 -0
- package/dist/{chunk-LWR4MXSS.js → chunk-LHLYWG5R.js} +10 -1
- package/dist/chunk-LHLYWG5R.js.map +1 -0
- package/dist/{chunk-L3EWO3UK.js → chunk-NJ7M2B6Z.js} +445 -84
- package/dist/chunk-NJ7M2B6Z.js.map +1 -0
- package/dist/{chunk-VCBFUSUZ.js → chunk-O4VXOBGF.js} +3 -3
- package/dist/{chunk-VCBFUSUZ.js.map → chunk-O4VXOBGF.js.map} +1 -1
- package/dist/{chunk-6EDV6YFE.js → chunk-OL6QVXGF.js} +121 -29
- package/dist/chunk-OL6QVXGF.js.map +1 -0
- package/dist/chunk-VZTQ73XT.js +86 -0
- package/dist/chunk-VZTQ73XT.js.map +1 -0
- package/dist/{chunk-EHSYZ4KP.js → chunk-WYA65Y3F.js} +16 -16
- package/dist/chunk-WYA65Y3F.js.map +1 -0
- package/dist/{chunk-FD72GJMK.js → chunk-XCNTXWBB.js} +274 -125
- package/dist/chunk-XCNTXWBB.js.map +1 -0
- package/dist/client.d.ts +1 -1
- package/dist/components/chat-composer.d.ts +7 -2
- package/dist/components/composer-transcription-control.d.ts +2 -0
- package/dist/components/composer.d.ts +14 -0
- package/dist/components/machines/machine-detail.d.ts +3 -1
- package/dist/components/machines-dashboard.d.ts +3 -3
- package/dist/components/message-timeline.d.ts +12 -2
- package/dist/components/sandbox-files.d.ts +2 -2
- package/dist/components/sandbox-terminal.d.ts +29 -0
- package/dist/components/sandbox-workspace.d.ts +5 -1
- package/dist/components/workspace-dock.d.ts +3 -1
- package/dist/composer.d.ts +2 -2
- package/dist/composer.js +6 -6
- package/dist/hooks/internal.d.ts +6 -0
- package/dist/hooks/use-codex-accounts.d.ts +1 -1
- package/dist/hooks/use-environments.d.ts +10 -4
- package/dist/hooks/use-machines.d.ts +7 -0
- package/dist/hooks/use-sandbox-files.d.ts +19 -0
- package/dist/hooks/use-sandbox-git.d.ts +7 -0
- package/dist/hooks/use-transcription.d.ts +2 -2
- package/dist/hooks/use-voice-input.d.ts +13 -4
- package/dist/index.d.ts +6 -5
- package/dist/index.js +904 -538
- package/dist/index.js.map +1 -1
- package/dist/lib/composer-responsive-context.d.ts +9 -0
- package/dist/lib/use-portal-token-style.d.ts +14 -3
- package/dist/machines.js +2 -2
- package/dist/{queue-surface-implementation-J4XJFKRW.js → queue-surface-implementation-U5JFG7HT.js} +19 -18
- package/dist/queue-surface-implementation-U5JFG7HT.js.map +1 -0
- package/dist/realtime/realtime-control.d.ts +9 -0
- package/dist/realtime.js +29 -32
- package/dist/realtime.js.map +1 -1
- package/dist/session-ui.js +5 -5
- package/dist/session.js +4 -4
- package/dist/timeline/activity-rail.d.ts +3 -2
- package/dist/timeline/index.d.ts +2 -2
- package/dist/timeline/parsers.d.ts +3 -3
- package/dist/timeline/registry.d.ts +3 -0
- package/dist/timeline/screenshot-lightbox.d.ts +1 -1
- package/dist/timeline/types.d.ts +4 -0
- package/dist/voice-recording-store.d.ts +8 -2
- package/package.json +20 -4
- package/src/client.ts +3 -0
- package/src/components/chat-composer.tsx +12 -2
- package/src/components/code-editor.tsx +1 -1
- package/src/components/command-palette.tsx +1 -1
- package/src/components/composer-transcription-control.tsx +41 -16
- package/src/components/composer.tsx +52 -26
- package/src/components/copy-button.tsx +2 -2
- package/src/components/desktop-viewer.tsx +2 -2
- package/src/components/file-browser.tsx +39 -17
- package/src/components/human-input-form.tsx +2 -2
- package/src/components/machine-card.tsx +1 -1
- package/src/components/machines/machine-detail.tsx +39 -15
- package/src/components/machines-dashboard.tsx +7 -6
- package/src/components/message-timeline.tsx +44 -11
- package/src/components/model-picker.tsx +2 -2
- package/src/components/model-policy-picker.tsx +16 -15
- package/src/components/queue-surface-implementation.tsx +20 -16
- package/src/components/queue-surface-state.tsx +2 -2
- package/src/components/sandbox-files.tsx +140 -60
- package/src/components/sandbox-terminal.tsx +24 -5
- package/src/components/sandbox-workspace.tsx +180 -142
- package/src/components/session-chrome.tsx +14 -14
- package/src/components/tooltip.tsx +13 -16
- package/src/components/user-message-body.tsx +1 -1
- package/src/components/workbench-changes.tsx +4 -4
- package/src/components/workspace-dock.tsx +13 -5
- package/src/composer.ts +3 -0
- package/src/hooks/internal.ts +132 -38
- package/src/hooks/use-codex-accounts.ts +4 -4
- package/src/hooks/use-environments.ts +24 -5
- package/src/hooks/use-file-attachments.ts +70 -32
- package/src/hooks/use-goal.ts +17 -5
- package/src/hooks/use-machines.ts +47 -1
- package/src/hooks/use-sandbox-files.ts +310 -59
- package/src/hooks/use-sandbox-git.ts +148 -41
- package/src/hooks/use-session-capabilities.ts +35 -24
- package/src/hooks/use-session-events.ts +18 -4
- package/src/hooks/use-transcription.ts +4 -14
- package/src/hooks/use-turn-queue.ts +17 -5
- package/src/hooks/use-voice-input.ts +481 -36
- package/src/hooks/use-windowed-sections.ts +9 -7
- package/src/index.ts +11 -6
- package/src/lib/composer-responsive-context.ts +15 -0
- package/src/lib/use-portal-token-style.ts +60 -15
- package/src/provider.tsx +52 -7
- package/src/realtime/dropdown-menu.tsx +15 -19
- package/src/realtime/realtime-control.tsx +18 -7
- package/src/timeline/activity-rail.tsx +15 -6
- package/src/timeline/index.ts +1 -1
- package/src/timeline/parsers.ts +33 -20
- package/src/timeline/projection.ts +23 -0
- package/src/timeline/registry.ts +7 -0
- package/src/timeline/screenshot-lightbox.tsx +15 -4
- package/src/timeline/shared.tsx +8 -8
- package/src/timeline/tool-renderers.tsx +166 -22
- package/src/timeline/turn-summary.tsx +6 -6
- package/src/timeline/types.ts +4 -0
- package/src/voice-recording-store.ts +15 -0
- package/styles/compiled.css +5065 -0
- package/styles/compiled.d.ts +2 -0
- package/styles/effective-tokens.css +27 -0
- package/styles/index.css +3 -0
- package/styles/responsive.css +196 -0
- package/styles/responsive.d.ts +2 -0
- package/styles/tokens.css +50 -17
- package/dist/chunk-6EDV6YFE.js.map +0 -1
- package/dist/chunk-EB67J347.js.map +0 -1
- package/dist/chunk-EHSYZ4KP.js.map +0 -1
- package/dist/chunk-FD72GJMK.js.map +0 -1
- package/dist/chunk-JB5725BI.js +0 -48
- package/dist/chunk-JB5725BI.js.map +0 -1
- package/dist/chunk-L3EWO3UK.js.map +0 -1
- package/dist/chunk-LWR4MXSS.js.map +0 -1
- package/dist/chunk-SRFUT2ZU.js.map +0 -1
- package/dist/chunk-U3CSCMWA.js.map +0 -1
- package/dist/queue-surface-implementation-J4XJFKRW.js.map +0 -1
- /package/dist/{chunk-HFO4ERGQ.js.map → chunk-FWXGSNXT.js.map} +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { OpenGeniApiError, type ClientVoiceInputConfig, type OpenGeniClient } from "@opengeni/sdk";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
3
|
import {
|
|
4
4
|
IndexedDbVoiceRecordingStore,
|
|
5
5
|
VoiceRecordingOwnedError,
|
|
6
6
|
VoiceRecordingStorageUnavailableError,
|
|
7
7
|
createVoiceRecordingManifest,
|
|
8
|
+
type VoiceRecordingChunk,
|
|
8
9
|
type VoiceRecordingManifest,
|
|
9
10
|
type VoiceRecordingStore,
|
|
10
11
|
} from "../voice-recording-store";
|
|
@@ -20,12 +21,13 @@ export type VoiceInputStatus =
|
|
|
20
21
|
| "recording"
|
|
21
22
|
| "saving"
|
|
22
23
|
| "transcribing"
|
|
24
|
+
| "retrying"
|
|
23
25
|
| "recovered"
|
|
24
26
|
| "transcript-ready"
|
|
25
27
|
| "error";
|
|
26
28
|
|
|
27
29
|
export type UseVoiceInputOptions = {
|
|
28
|
-
client:
|
|
30
|
+
client: VoiceInputClient | null;
|
|
29
31
|
workspaceId: string;
|
|
30
32
|
capability: ClientVoiceInputConfig | null;
|
|
31
33
|
enabled: boolean;
|
|
@@ -38,8 +40,23 @@ export type UseVoiceInputOptions = {
|
|
|
38
40
|
createRecordingId?: (() => string) | undefined;
|
|
39
41
|
createOwnerId?: (() => string) | undefined;
|
|
40
42
|
now?: (() => Date) | undefined;
|
|
43
|
+
/** Test/embed seam. Production uses bounded exponential recovery backoff. */
|
|
44
|
+
automaticRetryDelayMilliseconds?: number | undefined;
|
|
41
45
|
};
|
|
42
46
|
|
|
47
|
+
type ResumableVoiceInputClient = Pick<
|
|
48
|
+
OpenGeniClient,
|
|
49
|
+
| "createTranscriptionRecording"
|
|
50
|
+
| "getTranscriptionRecording"
|
|
51
|
+
| "uploadTranscriptionRecordingChunk"
|
|
52
|
+
| "finalizeTranscriptionRecording"
|
|
53
|
+
| "processNextTranscriptionRecordingSegment"
|
|
54
|
+
| "discardTranscriptionRecording"
|
|
55
|
+
>;
|
|
56
|
+
|
|
57
|
+
type VoiceInputClient = Pick<OpenGeniClient, "transcribeAudio"> &
|
|
58
|
+
Partial<ResumableVoiceInputClient>;
|
|
59
|
+
|
|
43
60
|
export type UseVoiceInputResult = {
|
|
44
61
|
status: VoiceInputStatus;
|
|
45
62
|
error: string | null;
|
|
@@ -68,11 +85,42 @@ export const VOICE_RECORDING_TIMESLICE_MILLISECONDS = 5_000;
|
|
|
68
85
|
export const VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS = 5_000;
|
|
69
86
|
export const VOICE_RECORDING_OWNER_STALE_MILLISECONDS = 30_000;
|
|
70
87
|
export const VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS = 600;
|
|
71
|
-
|
|
88
|
+
export const VOICE_RECORDING_RESUMABLE_CLIENT_MAX_DURATION_SECONDS = 8 * 60 * 60;
|
|
89
|
+
export const VOICE_RECORDING_RECOVERY_STATUS_POLL_MILLISECONDS = 2_000;
|
|
90
|
+
export const VOICE_RECORDING_RECOVERY_MAX_MUTATION_DELAY_MILLISECONDS = 30_000;
|
|
91
|
+
const TRANSCRIPTION_RECORDING_RECOVERY_RETRY_AFTER_MILLISECONDS = 5_000;
|
|
72
92
|
const MIME_PREFERENCES = ["audio/webm;codecs=opus", "audio/mp4", "audio/ogg;codecs=opus"];
|
|
73
93
|
const createDefaultVoiceRecordingId = () => crypto.randomUUID();
|
|
74
94
|
const currentDate = () => new Date();
|
|
75
95
|
|
|
96
|
+
export function transcriptionRecoveryMutationDelayMilliseconds(
|
|
97
|
+
retryAfterMilliseconds: number | undefined,
|
|
98
|
+
attempt: number,
|
|
99
|
+
random: () => number = Math.random,
|
|
100
|
+
): number {
|
|
101
|
+
const hint =
|
|
102
|
+
typeof retryAfterMilliseconds === "number" &&
|
|
103
|
+
Number.isInteger(retryAfterMilliseconds) &&
|
|
104
|
+
retryAfterMilliseconds > 0
|
|
105
|
+
? retryAfterMilliseconds
|
|
106
|
+
: TRANSCRIPTION_RECORDING_RECOVERY_RETRY_AFTER_MILLISECONDS;
|
|
107
|
+
const boundedHint = Math.max(
|
|
108
|
+
500,
|
|
109
|
+
Math.min(hint, VOICE_RECORDING_RECOVERY_MAX_MUTATION_DELAY_MILLISECONDS),
|
|
110
|
+
);
|
|
111
|
+
const exponent = Math.max(0, Math.min(Math.floor(attempt), 6));
|
|
112
|
+
const exponential = Math.min(
|
|
113
|
+
VOICE_RECORDING_RECOVERY_MAX_MUTATION_DELAY_MILLISECONDS,
|
|
114
|
+
boundedHint * 2 ** exponent,
|
|
115
|
+
);
|
|
116
|
+
const sampledJitter = random();
|
|
117
|
+
const jitterRatio = Number.isFinite(sampledJitter) ? Math.max(0, Math.min(1, sampledJitter)) : 0;
|
|
118
|
+
return Math.min(
|
|
119
|
+
VOICE_RECORDING_RECOVERY_MAX_MUTATION_DELAY_MILLISECONDS,
|
|
120
|
+
Math.ceil(exponential * (1 + jitterRatio * 0.2)),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
76
124
|
export function useVoiceInput({
|
|
77
125
|
client,
|
|
78
126
|
workspaceId,
|
|
@@ -86,6 +134,7 @@ export function useVoiceInput({
|
|
|
86
134
|
createRecordingId = createDefaultVoiceRecordingId,
|
|
87
135
|
createOwnerId,
|
|
88
136
|
now = currentDate,
|
|
137
|
+
automaticRetryDelayMilliseconds,
|
|
89
138
|
}: UseVoiceInputOptions): UseVoiceInputResult {
|
|
90
139
|
const [status, setStatus] = useState<VoiceInputStatus>("idle");
|
|
91
140
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -110,6 +159,8 @@ export function useVoiceInput({
|
|
|
110
159
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
|
111
160
|
const streamRef = useRef<MediaStream | null>(null);
|
|
112
161
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
162
|
+
const automaticRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
163
|
+
const automaticRetryAttemptRef = useRef(0);
|
|
113
164
|
const ownerHeartbeatTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
114
165
|
const controllerRef = useRef<AbortController | null>(null);
|
|
115
166
|
const valueRef = useRef(value);
|
|
@@ -165,6 +216,7 @@ export function useVoiceInput({
|
|
|
165
216
|
setRecordingId(null);
|
|
166
217
|
setDurationSeconds(0);
|
|
167
218
|
setLocallySaved(false);
|
|
219
|
+
automaticRetryAttemptRef.current = 0;
|
|
168
220
|
}, [stopOwnerHeartbeat]);
|
|
169
221
|
|
|
170
222
|
const ensureStore = useCallback(async (): Promise<VoiceRecordingStore> => {
|
|
@@ -302,7 +354,9 @@ export function useVoiceInput({
|
|
|
302
354
|
setStatus(
|
|
303
355
|
claimed.finalizationState === "transcript-ready" && claimed.transcriptText !== null
|
|
304
356
|
? "transcript-ready"
|
|
305
|
-
: "
|
|
357
|
+
: claimed.recoveryMode === "automatic"
|
|
358
|
+
? "retrying"
|
|
359
|
+
: "recovered",
|
|
306
360
|
);
|
|
307
361
|
setError(null);
|
|
308
362
|
return;
|
|
@@ -322,8 +376,17 @@ export function useVoiceInput({
|
|
|
322
376
|
const finalizePersistedRecording = useCallback(
|
|
323
377
|
async (generation: number): Promise<void> => {
|
|
324
378
|
const manifest = manifestRef.current;
|
|
325
|
-
const
|
|
326
|
-
|
|
379
|
+
const resumable =
|
|
380
|
+
capability?.resumable && isResumableVoiceInputClient(client) ? capability.resumable : null;
|
|
381
|
+
const maxSizeBytes = resumable?.maxSizeBytes ?? capability?.maxSizeBytes;
|
|
382
|
+
if (
|
|
383
|
+
!manifest ||
|
|
384
|
+
!client ||
|
|
385
|
+
!capability ||
|
|
386
|
+
!maxSizeBytes ||
|
|
387
|
+
manifest.workspaceId !== workspaceId
|
|
388
|
+
)
|
|
389
|
+
return;
|
|
327
390
|
const controller = new AbortController();
|
|
328
391
|
controllerRef.current?.abort();
|
|
329
392
|
controllerRef.current = controller;
|
|
@@ -360,7 +423,12 @@ export function useVoiceInput({
|
|
|
360
423
|
if (chunks.length === 0) {
|
|
361
424
|
const retained = await store.updateManifest(
|
|
362
425
|
transcribing.recordingId,
|
|
363
|
-
{
|
|
426
|
+
{
|
|
427
|
+
uploadState: "retrying",
|
|
428
|
+
transcriptionState: "retrying",
|
|
429
|
+
recoveryMode: "manual",
|
|
430
|
+
handoffMode: "explicit",
|
|
431
|
+
},
|
|
364
432
|
readNow().toISOString(),
|
|
365
433
|
ownerIdRef.current ?? undefined,
|
|
366
434
|
);
|
|
@@ -368,18 +436,12 @@ export function useVoiceInput({
|
|
|
368
436
|
await preserveForRetry(retained, "invalid_audio", generation);
|
|
369
437
|
return;
|
|
370
438
|
}
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if (!active()) return;
|
|
378
|
-
if (audio.size > maxSizeBytes) throw { code: "too_large" };
|
|
379
|
-
const response = await client.transcribeAudio(workspaceId, {
|
|
380
|
-
audio,
|
|
381
|
-
mimeType: audio.type,
|
|
382
|
-
durationSeconds: transcribing.totalDurationMilliseconds / 1_000,
|
|
439
|
+
const response = await transcribePersistedRecording({
|
|
440
|
+
client,
|
|
441
|
+
workspaceId,
|
|
442
|
+
capability,
|
|
443
|
+
manifest: transcribing,
|
|
444
|
+
chunks,
|
|
383
445
|
signal: controller.signal,
|
|
384
446
|
});
|
|
385
447
|
if (!active()) return;
|
|
@@ -398,6 +460,14 @@ export function useVoiceInput({
|
|
|
398
460
|
if (!active()) return;
|
|
399
461
|
rememberManifest(ready);
|
|
400
462
|
|
|
463
|
+
automaticRetryAttemptRef.current = 0;
|
|
464
|
+
if (ready.handoffMode === "explicit") {
|
|
465
|
+
setStatus("transcript-ready");
|
|
466
|
+
setError("handoff_uncertain");
|
|
467
|
+
focusInput();
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
401
471
|
const next = appendFinalTranscript(valueRef.current, response.text);
|
|
402
472
|
if (next !== valueRef.current) {
|
|
403
473
|
valueRef.current = next;
|
|
@@ -422,6 +492,14 @@ export function useVoiceInput({
|
|
|
422
492
|
}
|
|
423
493
|
if (!active()) return;
|
|
424
494
|
rememberManifest(handedOff);
|
|
495
|
+
if (resumable && isResumableVoiceInputClient(client)) {
|
|
496
|
+
await client
|
|
497
|
+
.discardTranscriptionRecording(workspaceId, handedOff.recordingId, {
|
|
498
|
+
signal: controller.signal,
|
|
499
|
+
})
|
|
500
|
+
.catch(() => undefined);
|
|
501
|
+
}
|
|
502
|
+
if (!active()) return;
|
|
425
503
|
await store
|
|
426
504
|
.discard(handedOff.recordingId, ownerIdRef.current ?? undefined)
|
|
427
505
|
.catch(() => undefined);
|
|
@@ -433,22 +511,28 @@ export function useVoiceInput({
|
|
|
433
511
|
await loadNextRecoverable(generation);
|
|
434
512
|
} catch (reason) {
|
|
435
513
|
if (!active()) return;
|
|
514
|
+
const code = controller.signal.aborted ? null : errorCode(reason);
|
|
515
|
+
const retryable = !controller.signal.aborted && isRetryableVoiceInputError(reason);
|
|
436
516
|
const retained = await updateManifestBestEffort(transcribing, {
|
|
437
517
|
uploadState: "retrying",
|
|
438
518
|
transcriptionState: "retrying",
|
|
519
|
+
recoveryMode: retryable ? "automatic" : "manual",
|
|
520
|
+
handoffMode: "explicit",
|
|
439
521
|
});
|
|
440
522
|
if (!active()) return;
|
|
441
|
-
|
|
442
|
-
retained
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
523
|
+
if (retryable) {
|
|
524
|
+
rememberManifest(retained);
|
|
525
|
+
setStatus("retrying");
|
|
526
|
+
setError(code);
|
|
527
|
+
} else {
|
|
528
|
+
await preserveForRetry(retained, code, generation);
|
|
529
|
+
}
|
|
446
530
|
} finally {
|
|
447
531
|
if (controllerRef.current === controller) controllerRef.current = null;
|
|
448
532
|
}
|
|
449
533
|
},
|
|
450
534
|
[
|
|
451
|
-
capability
|
|
535
|
+
capability,
|
|
452
536
|
clearVisibleRecording,
|
|
453
537
|
client,
|
|
454
538
|
ensureStore,
|
|
@@ -462,6 +546,61 @@ export function useVoiceInput({
|
|
|
462
546
|
workspaceId,
|
|
463
547
|
],
|
|
464
548
|
);
|
|
549
|
+
// Live session renders must not postpone a durable retry deadline. Execute the
|
|
550
|
+
// latest finalizer without making its callback identity part of the timer key.
|
|
551
|
+
const finalizePersistedRecordingRef = useRef(finalizePersistedRecording);
|
|
552
|
+
finalizePersistedRecordingRef.current = finalizePersistedRecording;
|
|
553
|
+
const clientAvailable = client !== null && client !== undefined;
|
|
554
|
+
|
|
555
|
+
useEffect(() => {
|
|
556
|
+
if (
|
|
557
|
+
status !== "retrying" ||
|
|
558
|
+
!manifestRef.current ||
|
|
559
|
+
manifestRef.current.recoveryMode !== "automatic" ||
|
|
560
|
+
manifestRef.current.workspaceId !== workspaceId ||
|
|
561
|
+
!clientAvailable ||
|
|
562
|
+
disabled ||
|
|
563
|
+
!enabled
|
|
564
|
+
) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const generation = generationRef.current;
|
|
568
|
+
let started = false;
|
|
569
|
+
const run = () => {
|
|
570
|
+
if (
|
|
571
|
+
started ||
|
|
572
|
+
generation !== generationRef.current ||
|
|
573
|
+
statusRef.current !== "retrying" ||
|
|
574
|
+
workspaceIdRef.current !== workspaceId
|
|
575
|
+
) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
started = true;
|
|
579
|
+
if (automaticRetryTimerRef.current) clearTimeout(automaticRetryTimerRef.current);
|
|
580
|
+
automaticRetryTimerRef.current = null;
|
|
581
|
+
automaticRetryAttemptRef.current += 1;
|
|
582
|
+
void finalizePersistedRecordingRef.current(generation);
|
|
583
|
+
};
|
|
584
|
+
const onOnline = () => run();
|
|
585
|
+
globalThis.addEventListener?.("online", onOnline);
|
|
586
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
|
587
|
+
return () => globalThis.removeEventListener?.("online", onOnline);
|
|
588
|
+
}
|
|
589
|
+
const delay = Math.max(
|
|
590
|
+
0,
|
|
591
|
+
automaticRetryDelayMilliseconds ??
|
|
592
|
+
transcriptionRecoveryMutationDelayMilliseconds(
|
|
593
|
+
TRANSCRIPTION_RECORDING_RECOVERY_RETRY_AFTER_MILLISECONDS,
|
|
594
|
+
automaticRetryAttemptRef.current,
|
|
595
|
+
),
|
|
596
|
+
);
|
|
597
|
+
automaticRetryTimerRef.current = setTimeout(run, delay);
|
|
598
|
+
return () => {
|
|
599
|
+
globalThis.removeEventListener?.("online", onOnline);
|
|
600
|
+
if (automaticRetryTimerRef.current) clearTimeout(automaticRetryTimerRef.current);
|
|
601
|
+
automaticRetryTimerRef.current = null;
|
|
602
|
+
};
|
|
603
|
+
}, [automaticRetryDelayMilliseconds, clientAvailable, disabled, enabled, status, workspaceId]);
|
|
465
604
|
|
|
466
605
|
const stop = useCallback(() => {
|
|
467
606
|
const recorder = recorderRef.current;
|
|
@@ -483,6 +622,7 @@ export function useVoiceInput({
|
|
|
483
622
|
status === "recording" ||
|
|
484
623
|
status === "saving" ||
|
|
485
624
|
status === "transcribing" ||
|
|
625
|
+
status === "retrying" ||
|
|
486
626
|
!navigator.mediaDevices?.getUserMedia ||
|
|
487
627
|
typeof MediaRecorder === "undefined"
|
|
488
628
|
) {
|
|
@@ -602,7 +742,11 @@ export function useVoiceInput({
|
|
|
602
742
|
});
|
|
603
743
|
if (!startAttemptIsCurrent()) return;
|
|
604
744
|
rememberManifest(result.manifest);
|
|
605
|
-
|
|
745
|
+
const maxSizeBytes =
|
|
746
|
+
capability.resumable && isResumableVoiceInputClient(client)
|
|
747
|
+
? capability.resumable.maxSizeBytes
|
|
748
|
+
: capability.maxSizeBytes;
|
|
749
|
+
if (result.manifest.totalBytes > maxSizeBytes) {
|
|
606
750
|
attemptCaptureLimitError = "too_large";
|
|
607
751
|
if (attemptOwnsSharedCapture()) captureLimitErrorRef.current = "too_large";
|
|
608
752
|
if (recorder.state !== "inactive") recorder.stop();
|
|
@@ -655,11 +799,19 @@ export function useVoiceInput({
|
|
|
655
799
|
}
|
|
656
800
|
rememberManifest(stopped);
|
|
657
801
|
if (attemptPersistenceError) {
|
|
658
|
-
await
|
|
802
|
+
const retained = await updateManifestBestEffort(stopped, {
|
|
803
|
+
recoveryMode: "manual",
|
|
804
|
+
handoffMode: "explicit",
|
|
805
|
+
});
|
|
806
|
+
await preserveForRetry(retained, "storage_unavailable", generation);
|
|
659
807
|
return;
|
|
660
808
|
}
|
|
661
809
|
if (attemptCaptureLimitError) {
|
|
662
|
-
await
|
|
810
|
+
const retained = await updateManifestBestEffort(stopped, {
|
|
811
|
+
recoveryMode: "manual",
|
|
812
|
+
handoffMode: "explicit",
|
|
813
|
+
});
|
|
814
|
+
await preserveForRetry(retained, attemptCaptureLimitError, generation);
|
|
663
815
|
return;
|
|
664
816
|
}
|
|
665
817
|
await finalizePersistedRecording(generation);
|
|
@@ -675,8 +827,14 @@ export function useVoiceInput({
|
|
|
675
827
|
setStatus("recording");
|
|
676
828
|
timerRef.current = setTimeout(
|
|
677
829
|
stop,
|
|
678
|
-
Math.min(
|
|
679
|
-
|
|
830
|
+
Math.min(
|
|
831
|
+
capability.resumable && isResumableVoiceInputClient(client)
|
|
832
|
+
? capability.resumable.maxDurationSeconds
|
|
833
|
+
: capability.maxDurationSeconds,
|
|
834
|
+
capability.resumable && isResumableVoiceInputClient(client)
|
|
835
|
+
? VOICE_RECORDING_RESUMABLE_CLIENT_MAX_DURATION_SECONDS
|
|
836
|
+
: VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS,
|
|
837
|
+
) * 1_000,
|
|
680
838
|
);
|
|
681
839
|
return true;
|
|
682
840
|
} catch (reason) {
|
|
@@ -759,9 +917,29 @@ export function useVoiceInput({
|
|
|
759
917
|
}
|
|
760
918
|
const generation = ++generationRef.current;
|
|
761
919
|
controllerRef.current?.abort();
|
|
920
|
+
if (automaticRetryTimerRef.current) clearTimeout(automaticRetryTimerRef.current);
|
|
921
|
+
automaticRetryTimerRef.current = null;
|
|
922
|
+
automaticRetryAttemptRef.current = 0;
|
|
762
923
|
setError(null);
|
|
763
|
-
|
|
764
|
-
|
|
924
|
+
const manifest = manifestRef.current;
|
|
925
|
+
void updateManifestBestEffort(manifest, {
|
|
926
|
+
recoveryMode: "automatic",
|
|
927
|
+
handoffMode: "append",
|
|
928
|
+
}).then((retained) => {
|
|
929
|
+
if (generation !== generationRef.current) return;
|
|
930
|
+
rememberManifest(retained);
|
|
931
|
+
void finalizePersistedRecording(generation);
|
|
932
|
+
});
|
|
933
|
+
}, [
|
|
934
|
+
client,
|
|
935
|
+
disabled,
|
|
936
|
+
enabled,
|
|
937
|
+
finalizePersistedRecording,
|
|
938
|
+
rememberManifest,
|
|
939
|
+
status,
|
|
940
|
+
updateManifestBestEffort,
|
|
941
|
+
workspaceId,
|
|
942
|
+
]);
|
|
765
943
|
|
|
766
944
|
const insertSavedTranscript = useCallback(async (): Promise<void> => {
|
|
767
945
|
const manifest = manifestRef.current;
|
|
@@ -800,6 +978,12 @@ export function useVoiceInput({
|
|
|
800
978
|
return;
|
|
801
979
|
}
|
|
802
980
|
if (generation !== generationRef.current) return;
|
|
981
|
+
if (capability?.resumable && isResumableVoiceInputClient(client)) {
|
|
982
|
+
await client
|
|
983
|
+
.discardTranscriptionRecording(workspaceId, handedOff.recordingId)
|
|
984
|
+
.catch(() => undefined);
|
|
985
|
+
}
|
|
986
|
+
if (generation !== generationRef.current) return;
|
|
803
987
|
await store
|
|
804
988
|
.discard(handedOff.recordingId, ownerIdRef.current ?? undefined)
|
|
805
989
|
.catch(() => undefined);
|
|
@@ -811,6 +995,8 @@ export function useVoiceInput({
|
|
|
811
995
|
await loadNextRecoverable(generation);
|
|
812
996
|
}, [
|
|
813
997
|
clearVisibleRecording,
|
|
998
|
+
capability?.resumable,
|
|
999
|
+
client,
|
|
814
1000
|
ensureStore,
|
|
815
1001
|
focusInput,
|
|
816
1002
|
loadNextRecoverable,
|
|
@@ -835,6 +1021,12 @@ export function useVoiceInput({
|
|
|
835
1021
|
await captureSettled.catch(() => undefined);
|
|
836
1022
|
if (generation !== generationRef.current) return;
|
|
837
1023
|
if (manifest) {
|
|
1024
|
+
if (capability?.resumable && isResumableVoiceInputClient(client)) {
|
|
1025
|
+
await client
|
|
1026
|
+
.discardTranscriptionRecording(workspaceId, manifest.recordingId)
|
|
1027
|
+
.catch(() => undefined);
|
|
1028
|
+
}
|
|
1029
|
+
if (generation !== generationRef.current) return;
|
|
838
1030
|
try {
|
|
839
1031
|
await (await ensureStore()).discard(manifest.recordingId, ownerIdRef.current ?? undefined);
|
|
840
1032
|
} catch {
|
|
@@ -856,10 +1048,13 @@ export function useVoiceInput({
|
|
|
856
1048
|
}, [
|
|
857
1049
|
clearCaptureRuntime,
|
|
858
1050
|
clearVisibleRecording,
|
|
1051
|
+
capability?.resumable,
|
|
1052
|
+
client,
|
|
859
1053
|
ensureStore,
|
|
860
1054
|
focusInput,
|
|
861
1055
|
loadNextRecoverable,
|
|
862
1056
|
rememberManifest,
|
|
1057
|
+
workspaceId,
|
|
863
1058
|
]);
|
|
864
1059
|
|
|
865
1060
|
const cancel = useCallback(() => {
|
|
@@ -867,10 +1062,12 @@ export function useVoiceInput({
|
|
|
867
1062
|
void discard();
|
|
868
1063
|
return;
|
|
869
1064
|
}
|
|
870
|
-
if (status === "saving" || status === "transcribing") {
|
|
1065
|
+
if (status === "saving" || status === "transcribing" || status === "retrying") {
|
|
871
1066
|
const generation = ++generationRef.current;
|
|
872
1067
|
controllerRef.current?.abort();
|
|
873
1068
|
controllerRef.current = null;
|
|
1069
|
+
if (automaticRetryTimerRef.current) clearTimeout(automaticRetryTimerRef.current);
|
|
1070
|
+
automaticRetryTimerRef.current = null;
|
|
874
1071
|
const captureSettled = status === "saving" ? captureSettledRef.current : Promise.resolve();
|
|
875
1072
|
void captureSettled.then(async () => {
|
|
876
1073
|
const manifest = manifestRef.current;
|
|
@@ -879,6 +1076,8 @@ export function useVoiceInput({
|
|
|
879
1076
|
captureState: "stopped",
|
|
880
1077
|
uploadState: "retrying",
|
|
881
1078
|
transcriptionState: "retrying",
|
|
1079
|
+
recoveryMode: "manual",
|
|
1080
|
+
handoffMode: "explicit",
|
|
882
1081
|
});
|
|
883
1082
|
await preserveForRetry(retained, null, generation);
|
|
884
1083
|
});
|
|
@@ -912,7 +1111,12 @@ export function useVoiceInput({
|
|
|
912
1111
|
{
|
|
913
1112
|
...(current.captureState === "capturing" ? { captureState: "stopped" as const } : {}),
|
|
914
1113
|
...(wasProcessing
|
|
915
|
-
? {
|
|
1114
|
+
? {
|
|
1115
|
+
uploadState: "retrying" as const,
|
|
1116
|
+
transcriptionState: "retrying" as const,
|
|
1117
|
+
recoveryMode: "automatic" as const,
|
|
1118
|
+
handoffMode: "explicit" as const,
|
|
1119
|
+
}
|
|
916
1120
|
: {}),
|
|
917
1121
|
ownerId: null,
|
|
918
1122
|
ownerHeartbeatAt: null,
|
|
@@ -944,7 +1148,8 @@ export function useVoiceInput({
|
|
|
944
1148
|
(status === "requesting-permission" ||
|
|
945
1149
|
status === "recording" ||
|
|
946
1150
|
status === "saving" ||
|
|
947
|
-
status === "transcribing"
|
|
1151
|
+
status === "transcribing" ||
|
|
1152
|
+
status === "retrying")
|
|
948
1153
|
) {
|
|
949
1154
|
event.preventDefault();
|
|
950
1155
|
cancel();
|
|
@@ -959,6 +1164,8 @@ export function useVoiceInput({
|
|
|
959
1164
|
generationRef.current += 1;
|
|
960
1165
|
controllerRef.current?.abort();
|
|
961
1166
|
controllerRef.current = null;
|
|
1167
|
+
if (automaticRetryTimerRef.current) clearTimeout(automaticRetryTimerRef.current);
|
|
1168
|
+
automaticRetryTimerRef.current = null;
|
|
962
1169
|
const manifest = manifestRef.current;
|
|
963
1170
|
const ownerReady = ownerIdPromiseRef.current;
|
|
964
1171
|
stopOwnerHeartbeat();
|
|
@@ -977,7 +1184,9 @@ export function useVoiceInput({
|
|
|
977
1184
|
const store = storeRef.current;
|
|
978
1185
|
if (store && manifest && ownerId) {
|
|
979
1186
|
const wasProcessing =
|
|
980
|
-
statusRef.current === "saving" ||
|
|
1187
|
+
statusRef.current === "saving" ||
|
|
1188
|
+
statusRef.current === "transcribing" ||
|
|
1189
|
+
statusRef.current === "retrying";
|
|
981
1190
|
await store
|
|
982
1191
|
.updateManifest(
|
|
983
1192
|
manifest.recordingId,
|
|
@@ -989,6 +1198,8 @@ export function useVoiceInput({
|
|
|
989
1198
|
? {
|
|
990
1199
|
uploadState: "retrying" as const,
|
|
991
1200
|
transcriptionState: "retrying" as const,
|
|
1201
|
+
recoveryMode: "automatic" as const,
|
|
1202
|
+
handoffMode: "explicit" as const,
|
|
992
1203
|
}
|
|
993
1204
|
: {}),
|
|
994
1205
|
ownerId: null,
|
|
@@ -1032,6 +1243,226 @@ export function useVoiceInput({
|
|
|
1032
1243
|
};
|
|
1033
1244
|
}
|
|
1034
1245
|
|
|
1246
|
+
async function transcribePersistedRecording(input: {
|
|
1247
|
+
client: VoiceInputClient;
|
|
1248
|
+
workspaceId: string;
|
|
1249
|
+
capability: ClientVoiceInputConfig;
|
|
1250
|
+
manifest: VoiceRecordingManifest;
|
|
1251
|
+
chunks: VoiceRecordingChunk[];
|
|
1252
|
+
signal: AbortSignal;
|
|
1253
|
+
}): Promise<{ text: string; languages: string[] }> {
|
|
1254
|
+
const client = input.client;
|
|
1255
|
+
const resumable = input.capability.resumable;
|
|
1256
|
+
const ordered = [...input.chunks].sort((left, right) => left.chunkNumber - right.chunkNumber);
|
|
1257
|
+
if (!resumable || !isResumableVoiceInputClient(client)) {
|
|
1258
|
+
const audio = new Blob(
|
|
1259
|
+
ordered.map((chunk) => chunk.audio),
|
|
1260
|
+
{ type: input.manifest.mimeType },
|
|
1261
|
+
);
|
|
1262
|
+
if (audio.size > input.capability.maxSizeBytes) throw { code: "too_large" };
|
|
1263
|
+
return await client.transcribeAudio(input.workspaceId, {
|
|
1264
|
+
audio,
|
|
1265
|
+
mimeType: audio.type,
|
|
1266
|
+
durationSeconds: input.manifest.totalDurationMilliseconds / 1_000,
|
|
1267
|
+
signal: input.signal,
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
if (
|
|
1272
|
+
input.manifest.totalBytes > resumable.maxSizeBytes ||
|
|
1273
|
+
input.manifest.totalDurationMilliseconds > resumable.maxDurationSeconds * 1_000
|
|
1274
|
+
) {
|
|
1275
|
+
throw { code: "too_large" };
|
|
1276
|
+
}
|
|
1277
|
+
if (
|
|
1278
|
+
ordered.some((chunk, index) => chunk.chunkNumber !== index) ||
|
|
1279
|
+
ordered.length !== input.manifest.chunkCount
|
|
1280
|
+
) {
|
|
1281
|
+
throw { code: "invalid_audio" };
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const finalizeInput = {
|
|
1285
|
+
chunkCount: input.manifest.chunkCount,
|
|
1286
|
+
totalBytes: input.manifest.totalBytes,
|
|
1287
|
+
totalDurationMilliseconds: input.manifest.totalDurationMilliseconds,
|
|
1288
|
+
signal: input.signal,
|
|
1289
|
+
};
|
|
1290
|
+
|
|
1291
|
+
let remote = await client.createTranscriptionRecording(input.workspaceId, {
|
|
1292
|
+
recordingId: input.manifest.recordingId,
|
|
1293
|
+
mimeType: input.manifest.mimeType,
|
|
1294
|
+
signal: input.signal,
|
|
1295
|
+
});
|
|
1296
|
+
for (const chunk of ordered) {
|
|
1297
|
+
if (chunk.chunkNumber < remote.recording.nextChunkNumber) continue;
|
|
1298
|
+
if (chunk.chunkNumber !== remote.recording.nextChunkNumber) {
|
|
1299
|
+
throw { code: "invalid_audio" };
|
|
1300
|
+
}
|
|
1301
|
+
if (chunk.byteLength > resumable.maxChunkSizeBytes) throw { code: "too_large" };
|
|
1302
|
+
const uploaded = await client.uploadTranscriptionRecordingChunk(
|
|
1303
|
+
input.workspaceId,
|
|
1304
|
+
input.manifest.recordingId,
|
|
1305
|
+
chunk.chunkNumber,
|
|
1306
|
+
{
|
|
1307
|
+
audio: chunk.audio,
|
|
1308
|
+
mimeType: input.manifest.mimeType,
|
|
1309
|
+
sha256: chunk.sha256,
|
|
1310
|
+
startMilliseconds: chunk.startMilliseconds,
|
|
1311
|
+
durationMilliseconds: chunk.durationMilliseconds,
|
|
1312
|
+
signal: input.signal,
|
|
1313
|
+
},
|
|
1314
|
+
);
|
|
1315
|
+
remote = { recording: uploaded.recording, segments: remote.segments };
|
|
1316
|
+
}
|
|
1317
|
+
if (remote.recording.nextChunkNumber !== ordered.length) {
|
|
1318
|
+
throw { code: "invalid_audio" };
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
remote = await client.finalizeTranscriptionRecording(
|
|
1322
|
+
input.workspaceId,
|
|
1323
|
+
input.manifest.recordingId,
|
|
1324
|
+
finalizeInput,
|
|
1325
|
+
);
|
|
1326
|
+
|
|
1327
|
+
let recoveryMutationAttempt = 0;
|
|
1328
|
+
let recoveryMutationDueAt = 0;
|
|
1329
|
+
const scheduleRecoveryMutation = (response: typeof remote): void => {
|
|
1330
|
+
if (response.recording.state !== "segmenting" && response.recording.state !== "transcribing") {
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
recoveryMutationDueAt =
|
|
1334
|
+
Date.now() +
|
|
1335
|
+
transcriptionRecoveryMutationDelayMilliseconds(
|
|
1336
|
+
response.retryAfterMilliseconds,
|
|
1337
|
+
recoveryMutationAttempt,
|
|
1338
|
+
);
|
|
1339
|
+
recoveryMutationAttempt += 1;
|
|
1340
|
+
};
|
|
1341
|
+
scheduleRecoveryMutation(remote);
|
|
1342
|
+
|
|
1343
|
+
for (;;) {
|
|
1344
|
+
if (input.signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
1345
|
+
switch (remote.recording.state) {
|
|
1346
|
+
case "complete":
|
|
1347
|
+
if (remote.recording.transcriptText === null) throw { code: "invalid_audio" };
|
|
1348
|
+
return {
|
|
1349
|
+
text: remote.recording.transcriptText,
|
|
1350
|
+
languages: remote.recording.languages,
|
|
1351
|
+
};
|
|
1352
|
+
case "ready":
|
|
1353
|
+
remote = await client.processNextTranscriptionRecordingSegment(
|
|
1354
|
+
input.workspaceId,
|
|
1355
|
+
input.manifest.recordingId,
|
|
1356
|
+
{ signal: input.signal },
|
|
1357
|
+
);
|
|
1358
|
+
scheduleRecoveryMutation(remote);
|
|
1359
|
+
break;
|
|
1360
|
+
case "failed": {
|
|
1361
|
+
if (!remote.recording.retryable) {
|
|
1362
|
+
throw { code: remote.recording.errorCode ?? "unknown", retryable: false };
|
|
1363
|
+
}
|
|
1364
|
+
const retried =
|
|
1365
|
+
remote.recording.segmentCount === 0
|
|
1366
|
+
? await client.finalizeTranscriptionRecording(
|
|
1367
|
+
input.workspaceId,
|
|
1368
|
+
input.manifest.recordingId,
|
|
1369
|
+
finalizeInput,
|
|
1370
|
+
)
|
|
1371
|
+
: await client.processNextTranscriptionRecordingSegment(
|
|
1372
|
+
input.workspaceId,
|
|
1373
|
+
input.manifest.recordingId,
|
|
1374
|
+
{ signal: input.signal },
|
|
1375
|
+
);
|
|
1376
|
+
if (retried.recording.state === "failed") {
|
|
1377
|
+
throw {
|
|
1378
|
+
code: retried.recording.errorCode ?? "unknown",
|
|
1379
|
+
retryable: retried.recording.retryable,
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
remote = retried;
|
|
1383
|
+
scheduleRecoveryMutation(remote);
|
|
1384
|
+
break;
|
|
1385
|
+
}
|
|
1386
|
+
case "discarded":
|
|
1387
|
+
throw { code: "invalid_audio" };
|
|
1388
|
+
case "uploading":
|
|
1389
|
+
remote = await client.finalizeTranscriptionRecording(
|
|
1390
|
+
input.workspaceId,
|
|
1391
|
+
input.manifest.recordingId,
|
|
1392
|
+
finalizeInput,
|
|
1393
|
+
);
|
|
1394
|
+
scheduleRecoveryMutation(remote);
|
|
1395
|
+
break;
|
|
1396
|
+
case "segmenting":
|
|
1397
|
+
case "transcribing":
|
|
1398
|
+
if (Date.now() < recoveryMutationDueAt) {
|
|
1399
|
+
await abortableDelay(
|
|
1400
|
+
Math.min(
|
|
1401
|
+
recoveryMutationDueAt - Date.now(),
|
|
1402
|
+
VOICE_RECORDING_RECOVERY_STATUS_POLL_MILLISECONDS,
|
|
1403
|
+
),
|
|
1404
|
+
input.signal,
|
|
1405
|
+
);
|
|
1406
|
+
remote = await client.getTranscriptionRecording(
|
|
1407
|
+
input.workspaceId,
|
|
1408
|
+
input.manifest.recordingId,
|
|
1409
|
+
{ signal: input.signal },
|
|
1410
|
+
);
|
|
1411
|
+
break;
|
|
1412
|
+
}
|
|
1413
|
+
if (remote.recording.state === "segmenting") {
|
|
1414
|
+
// Re-enter the server-authoritative assembly claim only when its
|
|
1415
|
+
// durable recovery schedule is due.
|
|
1416
|
+
remote = await client.finalizeTranscriptionRecording(
|
|
1417
|
+
input.workspaceId,
|
|
1418
|
+
input.manifest.recordingId,
|
|
1419
|
+
finalizeInput,
|
|
1420
|
+
);
|
|
1421
|
+
} else {
|
|
1422
|
+
// Re-enter the server-authoritative segment claim only when its
|
|
1423
|
+
// durable recovery schedule is due.
|
|
1424
|
+
remote = await client.processNextTranscriptionRecordingSegment(
|
|
1425
|
+
input.workspaceId,
|
|
1426
|
+
input.manifest.recordingId,
|
|
1427
|
+
{ signal: input.signal },
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
scheduleRecoveryMutation(remote);
|
|
1431
|
+
break;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function isResumableVoiceInputClient(
|
|
1437
|
+
client: VoiceInputClient | null,
|
|
1438
|
+
): client is VoiceInputClient & ResumableVoiceInputClient {
|
|
1439
|
+
return Boolean(
|
|
1440
|
+
client &&
|
|
1441
|
+
client.createTranscriptionRecording &&
|
|
1442
|
+
client.getTranscriptionRecording &&
|
|
1443
|
+
client.uploadTranscriptionRecordingChunk &&
|
|
1444
|
+
client.finalizeTranscriptionRecording &&
|
|
1445
|
+
client.processNextTranscriptionRecordingSegment &&
|
|
1446
|
+
client.discardTranscriptionRecording,
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
async function abortableDelay(milliseconds: number, signal: AbortSignal): Promise<void> {
|
|
1451
|
+
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
1452
|
+
await new Promise<void>((resolve, reject) => {
|
|
1453
|
+
const timer = setTimeout(() => {
|
|
1454
|
+
signal.removeEventListener("abort", onAbort);
|
|
1455
|
+
resolve();
|
|
1456
|
+
}, milliseconds);
|
|
1457
|
+
const onAbort = () => {
|
|
1458
|
+
clearTimeout(timer);
|
|
1459
|
+
signal.removeEventListener("abort", onAbort);
|
|
1460
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
1461
|
+
};
|
|
1462
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1035
1466
|
function chooseMimeType(accepted: string[]): string | undefined {
|
|
1036
1467
|
const normalized = accepted.map((value) => value.trim().toLowerCase());
|
|
1037
1468
|
return MIME_PREFERENCES.find((mimeType) => {
|
|
@@ -1049,6 +1480,9 @@ function chooseMimeType(accepted: string[]): string | undefined {
|
|
|
1049
1480
|
}
|
|
1050
1481
|
|
|
1051
1482
|
function errorCode(error: unknown): string {
|
|
1483
|
+
if (error instanceof OpenGeniApiError && typeof error.details?.transcriptionCode === "string") {
|
|
1484
|
+
return error.details.transcriptionCode;
|
|
1485
|
+
}
|
|
1052
1486
|
if (error && typeof error === "object" && "code" in error && typeof error.code === "string") {
|
|
1053
1487
|
return error.code;
|
|
1054
1488
|
}
|
|
@@ -1056,3 +1490,14 @@ function errorCode(error: unknown): string {
|
|
|
1056
1490
|
if (error instanceof DOMException && error.name === "NotSupportedError") return "not_supported";
|
|
1057
1491
|
return "unknown";
|
|
1058
1492
|
}
|
|
1493
|
+
|
|
1494
|
+
function isRetryableVoiceInputError(error: unknown): boolean {
|
|
1495
|
+
if (error instanceof OpenGeniApiError) {
|
|
1496
|
+
return error.retryable && error.status !== 409;
|
|
1497
|
+
}
|
|
1498
|
+
if (error instanceof TypeError) return true;
|
|
1499
|
+
if (error && typeof error === "object" && "retryable" in error) {
|
|
1500
|
+
return error.retryable === true;
|
|
1501
|
+
}
|
|
1502
|
+
return false;
|
|
1503
|
+
}
|