@opengeni/react 0.15.0 → 0.20.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 +110 -10
- package/dist/{chunk-TOJR776I.js → chunk-6BXBGJ3B.js} +45 -369
- package/dist/chunk-6BXBGJ3B.js.map +1 -0
- package/dist/chunk-6UT5OC3P.js +2133 -0
- package/dist/chunk-6UT5OC3P.js.map +1 -0
- package/dist/chunk-EBTT3MYN.js +92 -0
- package/dist/chunk-EBTT3MYN.js.map +1 -0
- package/dist/chunk-HHFA4AFX.js +425 -0
- package/dist/chunk-HHFA4AFX.js.map +1 -0
- package/dist/chunk-LAZ2A743.js +2393 -0
- package/dist/chunk-LAZ2A743.js.map +1 -0
- package/dist/chunk-MRSECXRP.js +563 -0
- package/dist/chunk-MRSECXRP.js.map +1 -0
- package/dist/chunk-TK7G6XLT.js +18 -0
- package/dist/chunk-TK7G6XLT.js.map +1 -0
- package/dist/composer-DvUaa5ki.d.ts +585 -0
- package/dist/composer.d.ts +6 -0
- package/dist/composer.js +56 -0
- package/dist/composer.js.map +1 -0
- package/dist/index.d.ts +1288 -1785
- package/dist/index.js +7015 -8806
- package/dist/index.js.map +1 -1
- package/dist/machines.d.ts +412 -3
- package/dist/machines.js +3 -1
- package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
- package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
- package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
- package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
- package/dist/queue-surface-implementation-NQMV2ML3.js +890 -0
- package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
- package/dist/session-Gd4iVPYw.d.ts +425 -0
- package/dist/session-context-DIFFlXKc.d.ts +52 -0
- package/dist/session.d.ts +4 -0
- package/dist/session.js +50 -0
- package/dist/session.js.map +1 -0
- package/dist/use-composer-BCfm4mzX.d.ts +112 -0
- package/package.json +20 -4
- package/src/approvals.ts +5 -3
- package/src/client.ts +40 -0
- package/src/components/approval-surface.tsx +198 -0
- package/src/components/chat-composer.tsx +94 -993
- package/src/components/code-editor.tsx +104 -26
- package/src/components/command-palette.tsx +8 -2
- package/src/components/composer-transcription-control.tsx +211 -0
- package/src/components/composer.tsx +1536 -0
- package/src/components/desktop-viewer.tsx +3 -3
- package/src/components/enrollment-consent.tsx +2 -2
- package/src/components/file-browser.tsx +367 -29
- package/src/components/human-input-form.tsx +417 -0
- package/src/components/machine-card.tsx +1 -1
- package/src/components/machines-dashboard.tsx +3 -3
- package/src/components/message-timeline.tsx +15 -7
- package/src/components/model-picker.tsx +12 -3
- package/src/components/pierre-diff.tsx +32 -6
- package/src/components/queue-surface-implementation.tsx +1041 -0
- package/src/components/queue-surface-state.tsx +94 -0
- package/src/components/queue-surface.tsx +41 -559
- package/src/components/sandbox-files.tsx +138 -14
- package/src/components/sandbox-terminal.tsx +8 -2
- package/src/components/sandbox-workspace.tsx +339 -119
- package/src/components/workbench-changes.tsx +84 -33
- package/src/components/workspace-dock.tsx +188 -18
- package/src/composer.ts +60 -0
- package/src/hooks/internal.ts +115 -34
- package/src/hooks/use-composer.ts +335 -104
- package/src/hooks/use-human-input.ts +131 -0
- package/src/hooks/use-machine-chip.ts +2 -2
- package/src/hooks/use-machines.ts +27 -11
- package/src/hooks/use-sandbox-files.ts +310 -62
- package/src/hooks/use-sandbox-git.ts +154 -40
- package/src/hooks/use-sandbox-terminal.ts +28 -6
- package/src/hooks/use-session-capabilities.ts +38 -9
- package/src/hooks/use-session-control.ts +37 -8
- package/src/hooks/use-session-events.ts +522 -48
- package/src/hooks/use-slash-commands.ts +38 -38
- package/src/hooks/use-transcription.ts +757 -0
- package/src/hooks/use-turn-queue.ts +134 -66
- package/src/hooks/use-windowed-sections.ts +2 -2
- package/src/hooks/use-workspace-capture.ts +146 -40
- package/src/hooks/use-workspace-edit.ts +50 -14
- package/src/hooks/use-workspace-sessions.ts +3 -0
- package/src/human-input.ts +72 -0
- package/src/index.ts +76 -6
- package/src/lib/noto-sans-arabic-loader.ts +21 -0
- package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
- package/src/lib/use-portal-token-style.ts +51 -0
- package/src/lib/use-unicode-fonts.ts +57 -0
- package/src/provider.tsx +4 -69
- package/src/session-context.ts +135 -0
- package/src/session.ts +79 -0
- package/src/timeline/parsers.ts +35 -0
- package/src/timeline/projection.ts +12 -4
- package/src/timeline/tool-renderers.tsx +41 -0
- package/src/timeline/turn-summary.tsx +2 -2
- package/src/timeline/types.ts +16 -8
- package/styles/index.css +56 -0
- package/styles/index.d.ts +2 -0
- package/styles/tokens.css +11 -6
- package/styles/tokens.d.ts +2 -0
- package/dist/chunk-TOJR776I.js.map +0 -1
- package/dist/machines-BpdwuQcD.d.ts +0 -449
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import {
|
|
2
|
+
authorizeTranscriptionAdapter,
|
|
3
|
+
createTranscriptionSessionRequest,
|
|
4
|
+
type TranscriptionAdapter,
|
|
5
|
+
type TranscriptionDiagnostic,
|
|
6
|
+
type TranscriptionErrorCode,
|
|
7
|
+
type TranscriptionEvent,
|
|
8
|
+
type TranscriptionLifecycleStatus,
|
|
9
|
+
type TranscriptionPolicyBlockReason,
|
|
10
|
+
type TranscriptionSession,
|
|
11
|
+
type TranscriptionTargetSelection,
|
|
12
|
+
type WorkspaceTranscriptionPolicy,
|
|
13
|
+
} from "@opengeni/sdk";
|
|
14
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
15
|
+
|
|
16
|
+
export type TranscriptionControlState = {
|
|
17
|
+
status: TranscriptionLifecycleStatus;
|
|
18
|
+
generation: number;
|
|
19
|
+
localSessionId: string | null;
|
|
20
|
+
providerSessionId: string | null;
|
|
21
|
+
lastSequence: number;
|
|
22
|
+
partial: string;
|
|
23
|
+
error: { code: TranscriptionErrorCode; recoverable: boolean } | null;
|
|
24
|
+
acceptedFinalIds: string[];
|
|
25
|
+
audioMilliseconds: number;
|
|
26
|
+
costUsd: number | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const INITIAL_TRANSCRIPTION_CONTROL_STATE: TranscriptionControlState = {
|
|
30
|
+
status: "idle",
|
|
31
|
+
generation: 0,
|
|
32
|
+
localSessionId: null,
|
|
33
|
+
providerSessionId: null,
|
|
34
|
+
lastSequence: 0,
|
|
35
|
+
partial: "",
|
|
36
|
+
error: null,
|
|
37
|
+
acceptedFinalIds: [],
|
|
38
|
+
audioMilliseconds: 0,
|
|
39
|
+
costUsd: null,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type TranscriptionControlAction =
|
|
43
|
+
| { type: "start"; generation: number; localSessionId: string }
|
|
44
|
+
| { type: "event"; generation: number; event: TranscriptionEvent }
|
|
45
|
+
| {
|
|
46
|
+
type: "start.failed";
|
|
47
|
+
generation: number;
|
|
48
|
+
code: TranscriptionErrorCode;
|
|
49
|
+
}
|
|
50
|
+
| { type: "cancel"; generation: number }
|
|
51
|
+
| { type: "cancel.settled"; generation: number }
|
|
52
|
+
| { type: "reset" };
|
|
53
|
+
|
|
54
|
+
export type TranscriptionControlTransition = {
|
|
55
|
+
state: TranscriptionControlState;
|
|
56
|
+
/** A newly accepted final to append to the current editable draft once. */
|
|
57
|
+
commit: string | null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function transitionTranscriptionControl(
|
|
61
|
+
state: TranscriptionControlState,
|
|
62
|
+
action: TranscriptionControlAction,
|
|
63
|
+
): TranscriptionControlTransition {
|
|
64
|
+
if (action.type === "reset") {
|
|
65
|
+
return {
|
|
66
|
+
state: { ...INITIAL_TRANSCRIPTION_CONTROL_STATE, generation: state.generation },
|
|
67
|
+
commit: null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (action.type === "start") {
|
|
71
|
+
if (action.generation <= state.generation) return { state, commit: null };
|
|
72
|
+
return {
|
|
73
|
+
state: {
|
|
74
|
+
...INITIAL_TRANSCRIPTION_CONTROL_STATE,
|
|
75
|
+
status: "requesting-permission",
|
|
76
|
+
generation: action.generation,
|
|
77
|
+
localSessionId: action.localSessionId,
|
|
78
|
+
},
|
|
79
|
+
commit: null,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (action.generation !== state.generation) return { state, commit: null };
|
|
83
|
+
if (action.type === "start.failed") {
|
|
84
|
+
if (state.status === "closed" || state.status === "error") return { state, commit: null };
|
|
85
|
+
return {
|
|
86
|
+
state: {
|
|
87
|
+
...state,
|
|
88
|
+
status: "error",
|
|
89
|
+
partial: "",
|
|
90
|
+
error: { code: normalizeTranscriptionErrorCode(action.code), recoverable: false },
|
|
91
|
+
},
|
|
92
|
+
commit: null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (action.type === "cancel") {
|
|
96
|
+
if (!isActiveTranscriptionStatus(state.status)) return { state, commit: null };
|
|
97
|
+
return {
|
|
98
|
+
state: { ...state, status: "cancelling", partial: "", error: null },
|
|
99
|
+
commit: null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (action.type === "cancel.settled") {
|
|
103
|
+
if (state.status !== "cancelling") return { state, commit: null };
|
|
104
|
+
return { state: { ...state, status: "closed", partial: "" }, commit: null };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const event = action.event;
|
|
108
|
+
if (event.localSessionId !== state.localSessionId) return { state, commit: null };
|
|
109
|
+
if (!Number.isSafeInteger(event.sequence) || event.sequence <= state.lastSequence) {
|
|
110
|
+
return { state, commit: null };
|
|
111
|
+
}
|
|
112
|
+
// Closed and failed generations are terminal local privacy fences. Adapters
|
|
113
|
+
// may still deliver callbacks while remote cleanup settles; none can revive
|
|
114
|
+
// the session or mutate the draft.
|
|
115
|
+
if (state.status === "closed" || state.status === "error") return { state, commit: null };
|
|
116
|
+
// Once cancellation begins, late partial/final/usage callbacks are fenced.
|
|
117
|
+
if (state.status === "cancelling" && event.type !== "session.closed") {
|
|
118
|
+
return { state: { ...state, lastSequence: event.sequence }, commit: null };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const sequenced = { ...state, lastSequence: event.sequence };
|
|
122
|
+
switch (event.type) {
|
|
123
|
+
case "permission.requested":
|
|
124
|
+
return {
|
|
125
|
+
state: { ...sequenced, status: "requesting-permission", partial: "", error: null },
|
|
126
|
+
commit: null,
|
|
127
|
+
};
|
|
128
|
+
case "session.opened":
|
|
129
|
+
return {
|
|
130
|
+
state: {
|
|
131
|
+
...sequenced,
|
|
132
|
+
status: "listening",
|
|
133
|
+
providerSessionId: event.providerSessionId,
|
|
134
|
+
error: null,
|
|
135
|
+
},
|
|
136
|
+
commit: null,
|
|
137
|
+
};
|
|
138
|
+
case "transcript.partial":
|
|
139
|
+
return {
|
|
140
|
+
state: { ...sequenced, status: "listening", partial: event.text, error: null },
|
|
141
|
+
commit: null,
|
|
142
|
+
};
|
|
143
|
+
case "transcript.final": {
|
|
144
|
+
const text = event.text.trim();
|
|
145
|
+
const duplicate = state.acceptedFinalIds.includes(event.providerAcceptanceId);
|
|
146
|
+
const acceptedFinalIds =
|
|
147
|
+
duplicate || text.length === 0
|
|
148
|
+
? state.acceptedFinalIds
|
|
149
|
+
: [...state.acceptedFinalIds, event.providerAcceptanceId];
|
|
150
|
+
return {
|
|
151
|
+
state: {
|
|
152
|
+
...sequenced,
|
|
153
|
+
status: "listening",
|
|
154
|
+
partial: "",
|
|
155
|
+
error: null,
|
|
156
|
+
acceptedFinalIds,
|
|
157
|
+
},
|
|
158
|
+
commit: duplicate || text.length === 0 ? null : text,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
case "usage":
|
|
162
|
+
return {
|
|
163
|
+
state: {
|
|
164
|
+
...sequenced,
|
|
165
|
+
audioMilliseconds: Math.max(state.audioMilliseconds, event.audioMilliseconds),
|
|
166
|
+
costUsd: event.costUsd,
|
|
167
|
+
},
|
|
168
|
+
commit: null,
|
|
169
|
+
};
|
|
170
|
+
case "session.reconnecting":
|
|
171
|
+
return {
|
|
172
|
+
state: { ...sequenced, status: "reconnecting", partial: "", error: null },
|
|
173
|
+
commit: null,
|
|
174
|
+
};
|
|
175
|
+
case "session.error":
|
|
176
|
+
return {
|
|
177
|
+
state: {
|
|
178
|
+
...sequenced,
|
|
179
|
+
status: event.recoverable ? "reconnecting" : "error",
|
|
180
|
+
partial: "",
|
|
181
|
+
error: {
|
|
182
|
+
code: normalizeTranscriptionErrorCode(event.code),
|
|
183
|
+
recoverable: event.recoverable,
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
commit: null,
|
|
187
|
+
};
|
|
188
|
+
case "session.closed":
|
|
189
|
+
return {
|
|
190
|
+
state: {
|
|
191
|
+
...sequenced,
|
|
192
|
+
status: event.reason === "error" ? "error" : "closed",
|
|
193
|
+
partial: "",
|
|
194
|
+
},
|
|
195
|
+
commit: null,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function appendFinalTranscript(draft: string, transcript: string): string {
|
|
201
|
+
const final = transcript.trim();
|
|
202
|
+
if (!final) return draft;
|
|
203
|
+
if (!draft) return final;
|
|
204
|
+
return /\s$/u.test(draft) ? `${draft}${final}` : `${draft} ${final}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export type UseTranscriptionOptions = {
|
|
208
|
+
adapter: TranscriptionAdapter | null;
|
|
209
|
+
policy: WorkspaceTranscriptionPolicy;
|
|
210
|
+
selection?: TranscriptionTargetSelection | undefined;
|
|
211
|
+
value: string;
|
|
212
|
+
setValue: (value: string) => void;
|
|
213
|
+
focusInput: () => void;
|
|
214
|
+
disabled?: boolean | undefined;
|
|
215
|
+
createLocalSessionId?: (() => string) | undefined;
|
|
216
|
+
lifecycleTimeouts?: Partial<TranscriptionLifecycleTimeouts> | undefined;
|
|
217
|
+
/** Receives bounded, redacted diagnostics; this detail is never rendered by the hook. */
|
|
218
|
+
onDiagnostic?: ((diagnostic: TranscriptionDiagnostic) => void) | undefined;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export type TranscriptionLifecycleTimeouts = {
|
|
222
|
+
startMs: number;
|
|
223
|
+
cleanupMs: number;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const DEFAULT_TRANSCRIPTION_LIFECYCLE_TIMEOUTS: TranscriptionLifecycleTimeouts = {
|
|
227
|
+
startMs: 15_000,
|
|
228
|
+
cleanupMs: 2_000,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
export type UseTranscriptionResult = {
|
|
232
|
+
state: TranscriptionControlState;
|
|
233
|
+
available: boolean;
|
|
234
|
+
unavailableReason:
|
|
235
|
+
| TranscriptionPolicyBlockReason
|
|
236
|
+
| "adapter_missing"
|
|
237
|
+
| "composer_disabled"
|
|
238
|
+
| null;
|
|
239
|
+
start: () => Promise<boolean>;
|
|
240
|
+
cancel: () => Promise<boolean>;
|
|
241
|
+
reset: () => void;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
export function useTranscription({
|
|
245
|
+
adapter,
|
|
246
|
+
policy,
|
|
247
|
+
selection = { kind: "primary" },
|
|
248
|
+
value,
|
|
249
|
+
setValue,
|
|
250
|
+
focusInput,
|
|
251
|
+
disabled = false,
|
|
252
|
+
createLocalSessionId = defaultLocalSessionId,
|
|
253
|
+
lifecycleTimeouts,
|
|
254
|
+
onDiagnostic,
|
|
255
|
+
}: UseTranscriptionOptions): UseTranscriptionResult {
|
|
256
|
+
const [state, setState] = useState(INITIAL_TRANSCRIPTION_CONTROL_STATE);
|
|
257
|
+
const stateRef = useRef(state);
|
|
258
|
+
const valueRef = useRef(value);
|
|
259
|
+
const sessionRef = useRef<{ generation: number; session: TranscriptionSession } | null>(null);
|
|
260
|
+
const pendingStartRef = useRef<{
|
|
261
|
+
generation: number;
|
|
262
|
+
controller: AbortController;
|
|
263
|
+
} | null>(null);
|
|
264
|
+
const activePolicyRef = useRef<{ generation: number; revision: string } | null>(null);
|
|
265
|
+
valueRef.current = value;
|
|
266
|
+
|
|
267
|
+
const startTimeoutMs = normalizeTimeout(
|
|
268
|
+
lifecycleTimeouts?.startMs,
|
|
269
|
+
DEFAULT_TRANSCRIPTION_LIFECYCLE_TIMEOUTS.startMs,
|
|
270
|
+
);
|
|
271
|
+
const cleanupTimeoutMs = normalizeTimeout(
|
|
272
|
+
lifecycleTimeouts?.cleanupMs,
|
|
273
|
+
DEFAULT_TRANSCRIPTION_LIFECYCLE_TIMEOUTS.cleanupMs,
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const reportDiagnostic = useCallback(
|
|
277
|
+
(diagnostic: unknown) => {
|
|
278
|
+
if (!onDiagnostic) return;
|
|
279
|
+
try {
|
|
280
|
+
onDiagnostic(sanitizeTranscriptionDiagnostic(diagnostic));
|
|
281
|
+
} catch {
|
|
282
|
+
// Observability must never break the local privacy or UI lifecycle.
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
[onDiagnostic],
|
|
286
|
+
);
|
|
287
|
+
const cleanupRuntimeRef = useRef({ cleanupTimeoutMs, reportDiagnostic });
|
|
288
|
+
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
cleanupRuntimeRef.current = { cleanupTimeoutMs, reportDiagnostic };
|
|
291
|
+
}, [cleanupTimeoutMs, reportDiagnostic]);
|
|
292
|
+
|
|
293
|
+
const policyRevision = useMemo(
|
|
294
|
+
() => transcriptionPolicyRevision(policy, selection),
|
|
295
|
+
[policy, selection],
|
|
296
|
+
);
|
|
297
|
+
const authorization = useMemo(
|
|
298
|
+
() => (adapter ? authorizeTranscriptionAdapter(policy, adapter.descriptor, selection) : null),
|
|
299
|
+
[adapter, policy, selection],
|
|
300
|
+
);
|
|
301
|
+
const unavailableReason = disabled
|
|
302
|
+
? ("composer_disabled" as const)
|
|
303
|
+
: !policy.enabled
|
|
304
|
+
? ("disabled" as const)
|
|
305
|
+
: !policy.acceptanceId
|
|
306
|
+
? ("unaccepted" as const)
|
|
307
|
+
: !policy.primary
|
|
308
|
+
? ("target_missing" as const)
|
|
309
|
+
: !adapter
|
|
310
|
+
? ("adapter_missing" as const)
|
|
311
|
+
: authorization?.authorized
|
|
312
|
+
? null
|
|
313
|
+
: (authorization?.reason ?? "unaccepted");
|
|
314
|
+
|
|
315
|
+
const apply = useCallback(
|
|
316
|
+
(action: TranscriptionControlAction) => {
|
|
317
|
+
const transition = transitionTranscriptionControl(stateRef.current, action);
|
|
318
|
+
if (transition.state !== stateRef.current) {
|
|
319
|
+
stateRef.current = transition.state;
|
|
320
|
+
setState(transition.state);
|
|
321
|
+
}
|
|
322
|
+
if (transition.commit !== null) {
|
|
323
|
+
const next = appendFinalTranscript(valueRef.current, transition.commit);
|
|
324
|
+
valueRef.current = next;
|
|
325
|
+
setValue(next);
|
|
326
|
+
focusInput();
|
|
327
|
+
}
|
|
328
|
+
return transition.state;
|
|
329
|
+
},
|
|
330
|
+
[focusInput, setValue],
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
const releaseTerminalSession = useCallback(
|
|
334
|
+
(generation: number) => {
|
|
335
|
+
const active = sessionRef.current;
|
|
336
|
+
if (!active || active.generation !== generation) return;
|
|
337
|
+
sessionRef.current = null;
|
|
338
|
+
if (activePolicyRef.current?.generation === generation) activePolicyRef.current = null;
|
|
339
|
+
detachSessionCleanup(active.session, {
|
|
340
|
+
cleanupTimeoutMs,
|
|
341
|
+
reportDiagnostic,
|
|
342
|
+
});
|
|
343
|
+
},
|
|
344
|
+
[cleanupTimeoutMs, reportDiagnostic],
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
const start = useCallback(async (): Promise<boolean> => {
|
|
348
|
+
if (
|
|
349
|
+
!adapter ||
|
|
350
|
+
unavailableReason !== null ||
|
|
351
|
+
isActiveTranscriptionStatus(stateRef.current.status)
|
|
352
|
+
) {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
const generation = stateRef.current.generation + 1;
|
|
356
|
+
const localSessionId = createLocalSessionId();
|
|
357
|
+
apply({ type: "start", generation, localSessionId });
|
|
358
|
+
const request = createTranscriptionSessionRequest({
|
|
359
|
+
policy,
|
|
360
|
+
adapter,
|
|
361
|
+
localSessionId,
|
|
362
|
+
selection,
|
|
363
|
+
sequenceFloor: 0,
|
|
364
|
+
});
|
|
365
|
+
if (!request) {
|
|
366
|
+
apply({
|
|
367
|
+
type: "start.failed",
|
|
368
|
+
generation,
|
|
369
|
+
code: "policy_blocked",
|
|
370
|
+
});
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
activePolicyRef.current = { generation, revision: policyRevision };
|
|
374
|
+
const controller = new AbortController();
|
|
375
|
+
pendingStartRef.current = { generation, controller };
|
|
376
|
+
let boundarySettled = false;
|
|
377
|
+
let accepted = false;
|
|
378
|
+
const observedStart = Promise.resolve()
|
|
379
|
+
.then(() =>
|
|
380
|
+
adapter.start(
|
|
381
|
+
request,
|
|
382
|
+
(event) => {
|
|
383
|
+
apply({ type: "event", generation, event });
|
|
384
|
+
if (event.type === "session.error" && !event.recoverable) {
|
|
385
|
+
releaseTerminalSession(generation);
|
|
386
|
+
} else if (event.type === "session.closed") {
|
|
387
|
+
releaseTerminalSession(generation);
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
signal: controller.signal,
|
|
392
|
+
reportDiagnostic,
|
|
393
|
+
},
|
|
394
|
+
),
|
|
395
|
+
)
|
|
396
|
+
.then(
|
|
397
|
+
(session) => {
|
|
398
|
+
if (boundarySettled && !accepted) {
|
|
399
|
+
detachSessionCleanup(session, {
|
|
400
|
+
cancelReason: "stale-generation",
|
|
401
|
+
cleanupTimeoutMs,
|
|
402
|
+
reportDiagnostic,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
return { kind: "session" as const, session };
|
|
406
|
+
},
|
|
407
|
+
(error: unknown) => {
|
|
408
|
+
if (boundarySettled) {
|
|
409
|
+
reportDiagnostic({
|
|
410
|
+
operation: "start",
|
|
411
|
+
code: classifyStartError(error),
|
|
412
|
+
detail: diagnosticDetail(error),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return { kind: "error" as const, error };
|
|
416
|
+
},
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
let abortListener: (() => void) | null = null;
|
|
420
|
+
const aborted = new Promise<{ kind: "aborted" }>((resolve) => {
|
|
421
|
+
if (controller.signal.aborted) {
|
|
422
|
+
resolve({ kind: "aborted" });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
abortListener = () => resolve({ kind: "aborted" });
|
|
426
|
+
controller.signal.addEventListener("abort", abortListener, { once: true });
|
|
427
|
+
});
|
|
428
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
429
|
+
const timedOut = new Promise<{ kind: "timeout" }>((resolve) => {
|
|
430
|
+
timeoutId = setTimeout(() => resolve({ kind: "timeout" }), startTimeoutMs);
|
|
431
|
+
});
|
|
432
|
+
const outcome = await Promise.race([observedStart, aborted, timedOut]);
|
|
433
|
+
boundarySettled = true;
|
|
434
|
+
if (timeoutId !== null) clearTimeout(timeoutId);
|
|
435
|
+
if (abortListener) controller.signal.removeEventListener("abort", abortListener);
|
|
436
|
+
if (pendingStartRef.current?.generation === generation) pendingStartRef.current = null;
|
|
437
|
+
|
|
438
|
+
if (outcome.kind === "aborted") return false;
|
|
439
|
+
|
|
440
|
+
if (outcome.kind === "timeout") {
|
|
441
|
+
controller.abort("transcription-start-timeout");
|
|
442
|
+
if (activePolicyRef.current?.generation === generation) activePolicyRef.current = null;
|
|
443
|
+
reportDiagnostic({
|
|
444
|
+
operation: "start",
|
|
445
|
+
code: "timeout",
|
|
446
|
+
detail: `Transcription adapter start exceeded ${startTimeoutMs}ms.`,
|
|
447
|
+
});
|
|
448
|
+
apply({ type: "start.failed", generation, code: "timeout" });
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (outcome.kind === "error") {
|
|
453
|
+
if (activePolicyRef.current?.generation === generation) activePolicyRef.current = null;
|
|
454
|
+
const code = classifyStartError(outcome.error);
|
|
455
|
+
reportDiagnostic({
|
|
456
|
+
operation: "start",
|
|
457
|
+
code,
|
|
458
|
+
detail: diagnosticDetail(outcome.error),
|
|
459
|
+
});
|
|
460
|
+
if (!controller.signal.aborted) {
|
|
461
|
+
apply({ type: "start.failed", generation, code });
|
|
462
|
+
}
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const current = stateRef.current;
|
|
467
|
+
if (
|
|
468
|
+
current.generation !== generation ||
|
|
469
|
+
current.localSessionId !== localSessionId ||
|
|
470
|
+
current.status === "cancelling" ||
|
|
471
|
+
!isActiveTranscriptionStatus(current.status)
|
|
472
|
+
) {
|
|
473
|
+
if (activePolicyRef.current?.generation === generation) activePolicyRef.current = null;
|
|
474
|
+
detachSessionCleanup(outcome.session, {
|
|
475
|
+
...(current.status === "error" ? {} : { cancelReason: "stale-generation" }),
|
|
476
|
+
cleanupTimeoutMs,
|
|
477
|
+
reportDiagnostic,
|
|
478
|
+
});
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
accepted = true;
|
|
482
|
+
sessionRef.current = { generation, session: outcome.session };
|
|
483
|
+
return true;
|
|
484
|
+
}, [
|
|
485
|
+
adapter,
|
|
486
|
+
apply,
|
|
487
|
+
createLocalSessionId,
|
|
488
|
+
cleanupTimeoutMs,
|
|
489
|
+
policy,
|
|
490
|
+
policyRevision,
|
|
491
|
+
releaseTerminalSession,
|
|
492
|
+
reportDiagnostic,
|
|
493
|
+
selection,
|
|
494
|
+
startTimeoutMs,
|
|
495
|
+
unavailableReason,
|
|
496
|
+
]);
|
|
497
|
+
|
|
498
|
+
const cancel = useCallback(async (): Promise<boolean> => {
|
|
499
|
+
const current = stateRef.current;
|
|
500
|
+
if (!isActiveTranscriptionStatus(current.status)) return false;
|
|
501
|
+
const generation = current.generation;
|
|
502
|
+
apply({ type: "cancel", generation });
|
|
503
|
+
const pending = pendingStartRef.current;
|
|
504
|
+
if (pending?.generation === generation) {
|
|
505
|
+
pendingStartRef.current = null;
|
|
506
|
+
pending.controller.abort("transcription-locally-cancelled");
|
|
507
|
+
}
|
|
508
|
+
const active = sessionRef.current;
|
|
509
|
+
sessionRef.current = null;
|
|
510
|
+
if (activePolicyRef.current?.generation === generation) activePolicyRef.current = null;
|
|
511
|
+
apply({ type: "cancel.settled", generation });
|
|
512
|
+
focusInput();
|
|
513
|
+
if (active?.generation === generation) {
|
|
514
|
+
detachSessionCleanup(active.session, {
|
|
515
|
+
cancelReason: "user-cancelled",
|
|
516
|
+
cleanupTimeoutMs,
|
|
517
|
+
reportDiagnostic,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
return true;
|
|
521
|
+
}, [apply, cleanupTimeoutMs, focusInput, reportDiagnostic]);
|
|
522
|
+
|
|
523
|
+
const reset = useCallback(() => apply({ type: "reset" }), [apply]);
|
|
524
|
+
|
|
525
|
+
useEffect(() => {
|
|
526
|
+
if (!isActiveTranscriptionStatus(state.status)) return;
|
|
527
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
528
|
+
if (event.key !== "Escape") return;
|
|
529
|
+
event.preventDefault();
|
|
530
|
+
void cancel();
|
|
531
|
+
};
|
|
532
|
+
document.addEventListener("keydown", onKeyDown);
|
|
533
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
534
|
+
}, [cancel, state.status]);
|
|
535
|
+
|
|
536
|
+
useEffect(() => {
|
|
537
|
+
if (!isActiveTranscriptionStatus(state.status)) return;
|
|
538
|
+
const activePolicy = activePolicyRef.current;
|
|
539
|
+
if (
|
|
540
|
+
unavailableReason === null &&
|
|
541
|
+
activePolicy?.generation === state.generation &&
|
|
542
|
+
activePolicy.revision === policyRevision
|
|
543
|
+
) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
void cancel();
|
|
547
|
+
}, [cancel, policyRevision, state.generation, state.status, unavailableReason]);
|
|
548
|
+
|
|
549
|
+
useEffect(
|
|
550
|
+
() => () => {
|
|
551
|
+
const pending = pendingStartRef.current;
|
|
552
|
+
pendingStartRef.current = null;
|
|
553
|
+
pending?.controller.abort("transcription-component-unmounted");
|
|
554
|
+
const active = sessionRef.current;
|
|
555
|
+
sessionRef.current = null;
|
|
556
|
+
activePolicyRef.current = null;
|
|
557
|
+
stateRef.current = {
|
|
558
|
+
...stateRef.current,
|
|
559
|
+
generation: stateRef.current.generation + 1,
|
|
560
|
+
status: "closed",
|
|
561
|
+
partial: "",
|
|
562
|
+
};
|
|
563
|
+
if (active) {
|
|
564
|
+
const runtime = cleanupRuntimeRef.current;
|
|
565
|
+
detachSessionCleanup(active.session, {
|
|
566
|
+
cancelReason: "component-unmounted",
|
|
567
|
+
cleanupTimeoutMs: runtime.cleanupTimeoutMs,
|
|
568
|
+
reportDiagnostic: runtime.reportDiagnostic,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
},
|
|
572
|
+
[],
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
return {
|
|
576
|
+
state,
|
|
577
|
+
available: unavailableReason === null,
|
|
578
|
+
unavailableReason,
|
|
579
|
+
start,
|
|
580
|
+
cancel,
|
|
581
|
+
reset,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function isActiveTranscriptionStatus(status: TranscriptionLifecycleStatus): boolean {
|
|
586
|
+
return (
|
|
587
|
+
status === "requesting-permission" ||
|
|
588
|
+
status === "listening" ||
|
|
589
|
+
status === "reconnecting" ||
|
|
590
|
+
status === "cancelling"
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function defaultLocalSessionId(): string {
|
|
595
|
+
return globalThis.crypto.randomUUID();
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function classifyStartError(error: unknown): TranscriptionErrorCode {
|
|
599
|
+
if (isRecord(error)) {
|
|
600
|
+
const normalized = normalizeTranscriptionErrorCode(error.code);
|
|
601
|
+
if (normalized !== "unknown") return normalized;
|
|
602
|
+
}
|
|
603
|
+
if (typeof DOMException !== "undefined" && error instanceof DOMException) {
|
|
604
|
+
if (error.name === "NotAllowedError") return "permission_denied";
|
|
605
|
+
if (error.name === "NotSupportedError") return "not_supported";
|
|
606
|
+
if (error.name === "TimeoutError") return "timeout";
|
|
607
|
+
if (error.name === "AbortError") return "cancelled";
|
|
608
|
+
}
|
|
609
|
+
return "unknown";
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export function normalizeTranscriptionErrorCode(code: unknown): TranscriptionErrorCode {
|
|
613
|
+
switch (code) {
|
|
614
|
+
case "permission_denied":
|
|
615
|
+
case "not_supported":
|
|
616
|
+
case "network":
|
|
617
|
+
case "provider":
|
|
618
|
+
case "policy_blocked":
|
|
619
|
+
case "timeout":
|
|
620
|
+
case "cancelled":
|
|
621
|
+
case "unknown":
|
|
622
|
+
return code;
|
|
623
|
+
default:
|
|
624
|
+
return "unknown";
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export function sanitizeTranscriptionDiagnostic(diagnostic: unknown): TranscriptionDiagnostic {
|
|
629
|
+
const source = isRecord(diagnostic) ? diagnostic : {};
|
|
630
|
+
const operation =
|
|
631
|
+
source.operation === "start" ||
|
|
632
|
+
source.operation === "session" ||
|
|
633
|
+
source.operation === "cancel" ||
|
|
634
|
+
source.operation === "close"
|
|
635
|
+
? source.operation
|
|
636
|
+
: "session";
|
|
637
|
+
const rawDetail = typeof source.detail === "string" ? source.detail : "No diagnostic detail.";
|
|
638
|
+
const detail = rawDetail
|
|
639
|
+
.replace(/[\u0000-\u001f\u007f]+/gu, " ")
|
|
640
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/giu, "Bearer [REDACTED]")
|
|
641
|
+
.replace(/\bsk-[A-Za-z0-9_-]{4,}\b/gu, "[REDACTED]")
|
|
642
|
+
.replace(
|
|
643
|
+
/\b(api[-_ ]?key|authorization|access[-_ ]?token|refresh[-_ ]?token|token|secret)\b\s*[:=]\s*[^\s,;]+/giu,
|
|
644
|
+
"$1=[REDACTED]",
|
|
645
|
+
)
|
|
646
|
+
.trim()
|
|
647
|
+
.slice(0, 512);
|
|
648
|
+
return {
|
|
649
|
+
operation,
|
|
650
|
+
code: normalizeTranscriptionErrorCode(source.code),
|
|
651
|
+
detail: detail || "No diagnostic detail.",
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function detachSessionCleanup(
|
|
656
|
+
session: TranscriptionSession,
|
|
657
|
+
options: {
|
|
658
|
+
cancelReason?: string | undefined;
|
|
659
|
+
cleanupTimeoutMs: number;
|
|
660
|
+
reportDiagnostic: (diagnostic: unknown) => void;
|
|
661
|
+
},
|
|
662
|
+
): void {
|
|
663
|
+
if (options.cancelReason) {
|
|
664
|
+
void runBoundedCleanup(
|
|
665
|
+
"cancel",
|
|
666
|
+
() => session.cancel(options.cancelReason),
|
|
667
|
+
options.cleanupTimeoutMs,
|
|
668
|
+
options.reportDiagnostic,
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
void runBoundedCleanup(
|
|
672
|
+
"close",
|
|
673
|
+
() => session.close(),
|
|
674
|
+
options.cleanupTimeoutMs,
|
|
675
|
+
options.reportDiagnostic,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function runBoundedCleanup(
|
|
680
|
+
operation: "cancel" | "close",
|
|
681
|
+
invoke: () => Promise<void>,
|
|
682
|
+
timeoutMs: number,
|
|
683
|
+
reportDiagnostic: (diagnostic: unknown) => void,
|
|
684
|
+
): Promise<void> {
|
|
685
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
686
|
+
const result = Promise.resolve()
|
|
687
|
+
.then(invoke)
|
|
688
|
+
.then(
|
|
689
|
+
() => ({ kind: "settled" as const }),
|
|
690
|
+
(error: unknown) => ({ kind: "rejected" as const, error }),
|
|
691
|
+
);
|
|
692
|
+
const timeout = new Promise<{ kind: "timeout" }>((resolve) => {
|
|
693
|
+
timeoutId = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
|
|
694
|
+
});
|
|
695
|
+
const outcome = await Promise.race([result, timeout]);
|
|
696
|
+
if (timeoutId !== null) clearTimeout(timeoutId);
|
|
697
|
+
if (outcome.kind === "settled") return;
|
|
698
|
+
reportDiagnostic({
|
|
699
|
+
operation,
|
|
700
|
+
code: outcome.kind === "timeout" ? "timeout" : "provider",
|
|
701
|
+
detail:
|
|
702
|
+
outcome.kind === "timeout"
|
|
703
|
+
? `Transcription ${operation} exceeded ${timeoutMs}ms.`
|
|
704
|
+
: diagnosticDetail(outcome.error),
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function diagnosticDetail(error: unknown): string {
|
|
709
|
+
if (error instanceof Error) return error.message;
|
|
710
|
+
return typeof error === "string" ? error : "Unknown transcription adapter failure.";
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function normalizeTimeout(value: number | undefined, fallback: number): number {
|
|
714
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 1
|
|
715
|
+
? Math.min(Math.floor(value), 60_000)
|
|
716
|
+
: fallback;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
720
|
+
return typeof value === "object" && value !== null;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function transcriptionPolicyRevision(
|
|
724
|
+
policy: WorkspaceTranscriptionPolicy,
|
|
725
|
+
selection: TranscriptionTargetSelection,
|
|
726
|
+
): string {
|
|
727
|
+
const targetKey = (target: WorkspaceTranscriptionPolicy["primary"]): string =>
|
|
728
|
+
target
|
|
729
|
+
? [
|
|
730
|
+
target.provider,
|
|
731
|
+
target.model ?? "",
|
|
732
|
+
target.credentialMode,
|
|
733
|
+
target.credentialConnectionId ?? "",
|
|
734
|
+
target.region ?? "",
|
|
735
|
+
].join("\u0000")
|
|
736
|
+
: "";
|
|
737
|
+
return [
|
|
738
|
+
policy.enabled ? "1" : "0",
|
|
739
|
+
policy.acceptanceId ?? "",
|
|
740
|
+
targetKey(policy.primary),
|
|
741
|
+
policy.language ?? "",
|
|
742
|
+
policy.autoDetectLanguage ? "1" : "0",
|
|
743
|
+
policy.diarization.enabled ? "1" : "0",
|
|
744
|
+
policy.diarization.maxSpeakers?.toString() ?? "",
|
|
745
|
+
policy.retention.mode,
|
|
746
|
+
policy.retention.maxDays?.toString() ?? "",
|
|
747
|
+
policy.privacy.allowProviderLogging ? "1" : "0",
|
|
748
|
+
policy.privacy.allowProviderTraining ? "1" : "0",
|
|
749
|
+
policy.fallback.mode,
|
|
750
|
+
policy.fallback.targets.map(targetKey).join("\u0001"),
|
|
751
|
+
policy.cost.currency,
|
|
752
|
+
policy.cost.maxPerHour?.toString() ?? "",
|
|
753
|
+
policy.cost.maxPerMonth?.toString() ?? "",
|
|
754
|
+
selection.kind,
|
|
755
|
+
selection.kind === "fallback" ? selection.index.toString() : "",
|
|
756
|
+
].join("\u0002");
|
|
757
|
+
}
|