@opengeni/react 1.3.0 → 1.4.2
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 +29 -1
- package/dist/accounts.js +2 -0
- package/dist/accounts.js.map +1 -1
- package/dist/artifacts-document.js +5 -4
- package/dist/artifacts-presentation.js +5 -4
- package/dist/artifacts-spreadsheet.js +4 -3
- package/dist/artifacts.js +14 -13
- package/dist/artifacts.js.map +1 -1
- package/dist/browser-viewer-WGJ43YGI.js +17 -0
- package/dist/chat-interactive-block-ZFHOKTMS.js +12 -0
- package/dist/{chunk-WEHMPOXK.js → chunk-2DDJ6YH6.js} +3 -1
- package/dist/{chunk-WEHMPOXK.js.map → chunk-2DDJ6YH6.js.map} +1 -1
- package/dist/{chunk-C5X5AJXK.js → chunk-2HVLQ26H.js} +5 -4
- package/dist/{chunk-C5X5AJXK.js.map → chunk-2HVLQ26H.js.map} +1 -1
- package/dist/{chunk-A4UJB2MM.js → chunk-32CGLXW2.js} +4 -3
- package/dist/{chunk-A4UJB2MM.js.map → chunk-32CGLXW2.js.map} +1 -1
- package/dist/{chunk-5CVXZA3Y.js → chunk-45NUOSXQ.js} +3 -2
- package/dist/{chunk-5CVXZA3Y.js.map → chunk-45NUOSXQ.js.map} +1 -1
- package/dist/{chunk-WUISZ3WU.js → chunk-53KJBGPZ.js} +5 -4
- package/dist/{chunk-WUISZ3WU.js.map → chunk-53KJBGPZ.js.map} +1 -1
- package/dist/{chunk-TMYGL6X5.js → chunk-55DXD22O.js} +73 -7
- package/dist/chunk-55DXD22O.js.map +1 -0
- package/dist/{chunk-JT4JW7BC.js → chunk-5I4VRWBB.js} +3 -1
- package/dist/{chunk-JT4JW7BC.js.map → chunk-5I4VRWBB.js.map} +1 -1
- package/dist/{chunk-UK4TINKT.js → chunk-B27E3V37.js} +3 -1
- package/dist/{chunk-UK4TINKT.js.map → chunk-B27E3V37.js.map} +1 -1
- package/dist/{chunk-J6DBS4T3.js → chunk-BNKV5ZLF.js} +5 -4
- package/dist/{chunk-J6DBS4T3.js.map → chunk-BNKV5ZLF.js.map} +1 -1
- package/dist/{chunk-YM2DE7BQ.js → chunk-C5BF4L26.js} +13 -12
- package/dist/{chunk-YM2DE7BQ.js.map → chunk-C5BF4L26.js.map} +1 -1
- package/dist/{chunk-YBXQDQFX.js → chunk-C7CMD56T.js} +6 -5
- package/dist/{chunk-YBXQDQFX.js.map → chunk-C7CMD56T.js.map} +1 -1
- package/dist/{chunk-UGLEYEMP.js → chunk-CM7LVUKC.js} +3 -1
- package/dist/{chunk-UGLEYEMP.js.map → chunk-CM7LVUKC.js.map} +1 -1
- package/dist/{chunk-PGSX7UCP.js → chunk-CREMJMVN.js} +1502 -833
- package/dist/chunk-CREMJMVN.js.map +1 -0
- package/dist/{chunk-JVYQRRC4.js → chunk-CVYCLFMH.js} +3 -1
- package/dist/{chunk-JVYQRRC4.js.map → chunk-CVYCLFMH.js.map} +1 -1
- package/dist/{chunk-Y2PRMYLC.js → chunk-G3U2A6GS.js} +3 -2
- package/dist/{chunk-Y2PRMYLC.js.map → chunk-G3U2A6GS.js.map} +1 -1
- package/dist/{chunk-WWEUO2PL.js → chunk-G6YRCIYJ.js} +202 -14
- package/dist/chunk-G6YRCIYJ.js.map +1 -0
- package/dist/{chunk-B3V6TG77.js → chunk-GC3PH6N7.js} +3 -1
- package/dist/{chunk-B3V6TG77.js.map → chunk-GC3PH6N7.js.map} +1 -1
- package/dist/{chunk-FZCP527Y.js → chunk-HXYOSSQO.js} +3 -2
- package/dist/{chunk-FZCP527Y.js.map → chunk-HXYOSSQO.js.map} +1 -1
- package/dist/{chunk-BRM6FVZA.js → chunk-IEE7WCLN.js} +4 -3
- package/dist/{chunk-BRM6FVZA.js.map → chunk-IEE7WCLN.js.map} +1 -1
- package/dist/{chunk-DYWBUX5Q.js → chunk-IMSUVYQB.js} +6 -5
- package/dist/{chunk-DYWBUX5Q.js.map → chunk-IMSUVYQB.js.map} +1 -1
- package/dist/{chunk-CY7WK5FD.js → chunk-IUHHPMAX.js} +203 -46
- package/dist/chunk-IUHHPMAX.js.map +1 -0
- package/dist/{chunk-SGKD2OCN.js → chunk-IYQCWGU5.js} +6 -5
- package/dist/{chunk-SGKD2OCN.js.map → chunk-IYQCWGU5.js.map} +1 -1
- package/dist/{chunk-J2QWVSQY.js → chunk-JH7V627O.js} +8 -7
- package/dist/{chunk-J2QWVSQY.js.map → chunk-JH7V627O.js.map} +1 -1
- package/dist/{chunk-EESD3PY3.js → chunk-JHTIMBOL.js} +6 -5
- package/dist/{chunk-EESD3PY3.js.map → chunk-JHTIMBOL.js.map} +1 -1
- package/dist/{chunk-LNIRAOA3.js → chunk-L2J3PU7A.js} +7 -6
- package/dist/{chunk-LNIRAOA3.js.map → chunk-L2J3PU7A.js.map} +1 -1
- package/dist/{chunk-ZYRGEKWS.js → chunk-LJUMYUBO.js} +3 -1
- package/dist/{chunk-ZYRGEKWS.js.map → chunk-LJUMYUBO.js.map} +1 -1
- package/dist/{chunk-4RVYOY4O.js → chunk-LSW2OJVV.js} +4 -3
- package/dist/{chunk-4RVYOY4O.js.map → chunk-LSW2OJVV.js.map} +1 -1
- package/dist/{chunk-UTRTG6LH.js → chunk-LVS3MH5T.js} +3 -1
- package/dist/{chunk-UTRTG6LH.js.map → chunk-LVS3MH5T.js.map} +1 -1
- package/dist/chunk-M357MHH3.js +2 -0
- package/dist/{chunk-GYVRFZDY.js → chunk-MDIXFKA3.js} +9 -8
- package/dist/{chunk-GYVRFZDY.js.map → chunk-MDIXFKA3.js.map} +1 -1
- package/dist/{chunk-WRHIZYZF.js → chunk-MEQM4LI3.js} +6 -5
- package/dist/{chunk-WRHIZYZF.js.map → chunk-MEQM4LI3.js.map} +1 -1
- package/dist/chunk-NX4ADH73.js +14 -0
- package/dist/chunk-NX4ADH73.js.map +1 -0
- package/dist/{chunk-XCHWDJ22.js → chunk-OGTTUYNT.js} +3 -1
- package/dist/{chunk-XCHWDJ22.js.map → chunk-OGTTUYNT.js.map} +1 -1
- package/dist/{chunk-GIMRELBW.js → chunk-OYQQJL3J.js} +3 -1
- package/dist/{chunk-GIMRELBW.js.map → chunk-OYQQJL3J.js.map} +1 -1
- package/dist/{chunk-RSI7XYXL.js → chunk-PBCZL4JP.js} +6 -5
- package/dist/{chunk-RSI7XYXL.js.map → chunk-PBCZL4JP.js.map} +1 -1
- package/dist/{chunk-I7IWRQLN.js → chunk-PHBFJCEU.js} +17 -11
- package/dist/chunk-PHBFJCEU.js.map +1 -0
- package/dist/{chunk-3CTRFMAK.js → chunk-PKCAYHDC.js} +5 -4
- package/dist/{chunk-3CTRFMAK.js.map → chunk-PKCAYHDC.js.map} +1 -1
- package/dist/{chunk-K3HQAKQX.js → chunk-PMV53IMM.js} +3 -1
- package/dist/{chunk-K3HQAKQX.js.map → chunk-PMV53IMM.js.map} +1 -1
- package/dist/{chunk-YQEAA6LT.js → chunk-QHUE7E4N.js} +3 -2
- package/dist/{chunk-YQEAA6LT.js.map → chunk-QHUE7E4N.js.map} +1 -1
- package/dist/{chunk-RC6BZ44Q.js → chunk-QMMCJGFI.js} +4 -3
- package/dist/{chunk-RC6BZ44Q.js.map → chunk-QMMCJGFI.js.map} +1 -1
- package/dist/{chunk-YV4L3ILF.js → chunk-QV3DJVRZ.js} +3 -1
- package/dist/{chunk-YV4L3ILF.js.map → chunk-QV3DJVRZ.js.map} +1 -1
- package/dist/{chunk-447JLCBU.js → chunk-S2QH6EZ5.js} +3 -1
- package/dist/{chunk-447JLCBU.js.map → chunk-S2QH6EZ5.js.map} +1 -1
- package/dist/{chunk-PNY3JKBJ.js → chunk-SFH43JT2.js} +5 -4
- package/dist/{chunk-PNY3JKBJ.js.map → chunk-SFH43JT2.js.map} +1 -1
- package/dist/{chunk-ERGYJIBB.js → chunk-T46QZH3E.js} +4 -2
- package/dist/{chunk-ERGYJIBB.js.map → chunk-T46QZH3E.js.map} +1 -1
- package/dist/{chunk-5J4FOW6Q.js → chunk-TNEZNMSN.js} +3 -1
- package/dist/{chunk-5J4FOW6Q.js.map → chunk-TNEZNMSN.js.map} +1 -1
- package/dist/{chunk-22KP6LR5.js → chunk-TXS65BB2.js} +3 -1
- package/dist/{chunk-22KP6LR5.js.map → chunk-TXS65BB2.js.map} +1 -1
- package/dist/{chunk-742CZ3L3.js → chunk-UDPH5SLV.js} +19 -202
- package/dist/chunk-UDPH5SLV.js.map +1 -0
- package/dist/{chunk-HDJWDK7W.js → chunk-VCSERYXH.js} +10 -9
- package/dist/{chunk-HDJWDK7W.js.map → chunk-VCSERYXH.js.map} +1 -1
- package/dist/{chunk-VZTQ73XT.js → chunk-XEA5BCMA.js} +3 -1
- package/dist/{chunk-VZTQ73XT.js.map → chunk-XEA5BCMA.js.map} +1 -1
- package/dist/{chunk-5F4WR7X7.js → chunk-XS67YG5R.js} +3 -1
- package/dist/{chunk-5F4WR7X7.js.map → chunk-XS67YG5R.js.map} +1 -1
- package/dist/{chunk-TKOTKRWY.js → chunk-XTD7TZHW.js} +3 -1
- package/dist/{chunk-TKOTKRWY.js.map → chunk-XTD7TZHW.js.map} +1 -1
- package/dist/{chunk-A2PI6UMY.js → chunk-YSRBFJMQ.js} +3 -1
- package/dist/{chunk-A2PI6UMY.js.map → chunk-YSRBFJMQ.js.map} +1 -1
- package/dist/chunk-YVFRBXST.js +1420 -0
- package/dist/chunk-YVFRBXST.js.map +1 -0
- package/dist/{chunk-SCOTPE2I.js → chunk-YYLBXXTE.js} +3 -1
- package/dist/{chunk-SCOTPE2I.js.map → chunk-YYLBXXTE.js.map} +1 -1
- package/dist/{chunk-RHREZ7BO.js → chunk-ZUWF4NF7.js} +3 -1
- package/dist/{chunk-RHREZ7BO.js.map → chunk-ZUWF4NF7.js.map} +1 -1
- package/dist/clipboard.js +3 -2
- package/dist/components/composer-transcription-control.d.ts +7 -0
- package/dist/components/open-geni-chat.d.ts +6 -2
- package/dist/components/session-conversation.d.ts +31 -19
- package/dist/components/session-list.d.ts +4 -1
- package/dist/components/tool-action-review.d.ts +29 -2
- package/dist/components/tool-review-history.d.ts +8 -4
- package/dist/composer-voice-events.d.ts +8 -0
- package/dist/composer.js +15 -13
- package/dist/computer-viewer-46UIN7F3.js +20 -0
- package/dist/connect.js +15 -12
- package/dist/connect.js.map +1 -1
- package/dist/desktop.js +6 -5
- package/dist/desktop.js.map +1 -1
- package/dist/diffs.js +2 -1
- package/dist/diffs.js.map +1 -1
- package/dist/editor.js +4 -3
- package/dist/editor.js.map +1 -1
- package/dist/embedded-voice-NIL4RDX3.js +69 -0
- package/dist/embedded-voice-NIL4RDX3.js.map +1 -0
- package/dist/{fleet-decision-row-YZ3ZKWMM.js → fleet-decision-row-SI63O5V4.js} +3 -1
- package/dist/{fleet-decision-row-YZ3ZKWMM.js.map → fleet-decision-row-SI63O5V4.js.map} +1 -1
- package/dist/hooks/use-client-config-flags.d.ts +43 -0
- package/dist/hooks/use-realtime-voice-models.d.ts +9 -0
- package/dist/hooks/use-voice-input.d.ts +8 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +60 -48
- package/dist/index.js.map +1 -1
- package/dist/interaction.js +14 -13
- package/dist/interaction.js.map +1 -1
- package/dist/{latest-question-YTOBHCWR.js → latest-question-2POFPYNB.js} +5 -4
- package/dist/{latest-question-YTOBHCWR.js.map → latest-question-2POFPYNB.js.map} +1 -1
- package/dist/machines.js +8 -7
- package/dist/{markdown-table-layout-QXSLK4BY.js → markdown-table-layout-DQ4DRNVT.js} +3 -1
- package/dist/{markdown-table-layout-QXSLK4BY.js.map → markdown-table-layout-DQ4DRNVT.js.map} +1 -1
- package/dist/model-policy-picker-menu-WRNSRFQC.js +15 -0
- package/dist/model-policy.js +2 -1
- package/dist/{noto-sans-arabic-loader-IA2T4X2A.js → noto-sans-arabic-loader-XSH2KYWJ.js} +3 -1
- package/dist/{noto-sans-arabic-loader-IA2T4X2A.js.map → noto-sans-arabic-loader-XSH2KYWJ.js.map} +1 -1
- package/dist/{noto-sans-jp-loader.generated-HFGLYBR3.js → noto-sans-jp-loader.generated-MR5L6V7K.js} +3 -1
- package/dist/{noto-sans-jp-loader.generated-HFGLYBR3.js.map → noto-sans-jp-loader.generated-MR5L6V7K.js.map} +1 -1
- package/dist/{platform-activity-row-627BFZ46.js → platform-activity-row-PJJVATTZ.js} +3 -1
- package/dist/{platform-activity-row-627BFZ46.js.map → platform-activity-row-PJJVATTZ.js.map} +1 -1
- package/dist/{queue-surface-implementation-ZHFI5JBP.js → queue-surface-implementation-67FS7FUI.js} +10 -9
- package/dist/{queue-surface-implementation-ZHFI5JBP.js.map → queue-surface-implementation-67FS7FUI.js.map} +1 -1
- package/dist/realtime/embedded-voice.d.ts +20 -0
- package/dist/realtime/realtime-control.d.ts +2 -0
- package/dist/realtime.js +22 -1332
- package/dist/realtime.js.map +1 -1
- package/dist/sandbox-files-34CJJNOY.js +21 -0
- package/dist/session-capability-policy.d.ts +20 -1
- package/dist/session-ui.js +32 -30
- package/dist/session-ui.js.map +1 -1
- package/dist/session.js +12 -11
- package/dist/sites.js +2 -1
- package/dist/terminal.js +6 -5
- package/dist/terminal.js.map +1 -1
- package/dist/timeline/index.d.ts +2 -0
- package/dist/timeline/retained-loaders.d.ts +27 -0
- package/dist/{timeline-annotation-markers-GNDHTGBD.js → timeline-annotation-markers-U3G66KUD.js} +5 -4
- package/dist/{timeline-annotation-markers-GNDHTGBD.js.map → timeline-annotation-markers-U3G66KUD.js.map} +1 -1
- package/dist/{timeline-annotation-selection-DMLMM2TX.js → timeline-annotation-selection-BI7QTPTZ.js} +4 -3
- package/dist/{timeline-annotation-selection-DMLMM2TX.js.map → timeline-annotation-selection-BI7QTPTZ.js.map} +1 -1
- package/dist/{timeline-annotations-dialog-ELRDBHTS.js → timeline-annotations-dialog-FOMHSDTE.js} +7 -6
- package/dist/{timeline-annotations-dialog-ELRDBHTS.js.map → timeline-annotations-dialog-FOMHSDTE.js.map} +1 -1
- package/dist/usage.js +6 -5
- package/dist/usage.js.map +1 -1
- package/dist/voice-recording-owner.d.ts +7 -0
- package/package.json +3 -3
- package/src/components/approval-surface.tsx +190 -79
- package/src/components/artifacts/session-artifact-viewer.tsx +4 -2
- package/src/components/composer-transcription-control.tsx +121 -25
- package/src/components/composer.tsx +1 -0
- package/src/components/markdown.tsx +7 -2
- package/src/components/message-timeline.tsx +10 -1
- package/src/components/open-geni-chat.tsx +53 -13
- package/src/components/session-conversation.tsx +273 -63
- package/src/components/session-list.tsx +9 -2
- package/src/components/session-proxy-scope.tsx +26 -5
- package/src/components/tool-action-review.tsx +502 -239
- package/src/components/tool-review-history.tsx +18 -43
- package/src/composer-voice-events.ts +12 -0
- package/src/connection-discovery.tsx +4 -1
- package/src/hooks/use-client-config-flags.ts +110 -0
- package/src/hooks/use-goal.ts +12 -1
- package/src/hooks/use-realtime-voice-models.ts +67 -0
- package/src/hooks/use-session-events.ts +19 -16
- package/src/hooks/use-transcription.ts +6 -0
- package/src/hooks/use-voice-input.ts +115 -18
- package/src/index.ts +9 -0
- package/src/realtime/embedded-voice.tsx +81 -0
- package/src/realtime/realtime-control.tsx +109 -17
- package/src/session-capability-policy.ts +119 -6
- package/src/timeline/index.ts +10 -0
- package/src/timeline/retained-loaders.ts +86 -0
- package/src/timeline/tool-display-name.ts +5 -1
- package/src/timeline/tool-renderers.tsx +143 -10
- package/src/voice-recording-owner.ts +22 -0
- package/styles/compiled.css +135 -37
- package/styles/index.css +49 -0
- package/styles/responsive.css +61 -4
- package/styles/session-capability.css +1 -1
- package/styles/tokens.css +1 -1
- package/dist/browser-viewer-SHYKQRNX.js +0 -16
- package/dist/chat-interactive-block-UGBTNB3L.js +0 -11
- package/dist/chunk-742CZ3L3.js.map +0 -1
- package/dist/chunk-CY7WK5FD.js.map +0 -1
- package/dist/chunk-I7IWRQLN.js.map +0 -1
- package/dist/chunk-IWIDV7SB.js +0 -1
- package/dist/chunk-PGSX7UCP.js.map +0 -1
- package/dist/chunk-TMYGL6X5.js.map +0 -1
- package/dist/chunk-WWEUO2PL.js.map +0 -1
- package/dist/computer-viewer-6SRGWRUX.js +0 -19
- package/dist/model-policy-picker-menu-DXHUL73H.js +0 -14
- package/dist/sandbox-files-GORZOUUJ.js +0 -20
- /package/dist/{browser-viewer-SHYKQRNX.js.map → browser-viewer-WGJ43YGI.js.map} +0 -0
- /package/dist/{chat-interactive-block-UGBTNB3L.js.map → chat-interactive-block-ZFHOKTMS.js.map} +0 -0
- /package/dist/{chunk-IWIDV7SB.js.map → chunk-M357MHH3.js.map} +0 -0
- /package/dist/{computer-viewer-6SRGWRUX.js.map → computer-viewer-46UIN7F3.js.map} +0 -0
- /package/dist/{model-policy-picker-menu-DXHUL73H.js.map → model-policy-picker-menu-WRNSRFQC.js.map} +0 -0
- /package/dist/{sandbox-files-GORZOUUJ.js.map → sandbox-files-34CJJNOY.js.map} +0 -0
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "../voice-recording-store";
|
|
12
12
|
import {
|
|
13
13
|
acquireDefaultVoiceRecordingOwnerLease,
|
|
14
|
+
liveVoiceRecordingOwnerIds,
|
|
14
15
|
type VoiceRecordingOwnerLease,
|
|
15
16
|
} from "../voice-recording-owner";
|
|
16
17
|
import { appendFinalTranscript } from "./use-transcription";
|
|
@@ -65,6 +66,10 @@ export type UseVoiceInputResult = {
|
|
|
65
66
|
stream: MediaStream | null;
|
|
66
67
|
recordingId: string | null;
|
|
67
68
|
durationSeconds: number;
|
|
69
|
+
/** Wall-clock start of the current capture (epoch ms), or null when not recording. */
|
|
70
|
+
recordingStartedAt: number | null;
|
|
71
|
+
/** Capture stops and transcribes automatically after this many seconds. */
|
|
72
|
+
maxRecordingSeconds: number | null;
|
|
68
73
|
locallySaved: boolean;
|
|
69
74
|
hasRecoverableRecording: boolean;
|
|
70
75
|
savedTranscript: string | null;
|
|
@@ -75,7 +80,10 @@ export type UseVoiceInputResult = {
|
|
|
75
80
|
retry: () => void;
|
|
76
81
|
/** Explicitly insert a durably saved transcript whose prior handoff may be uncertain. */
|
|
77
82
|
insertSavedTranscript: () => Promise<void>;
|
|
78
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Cancel active work without losing speech: a recording longer than a few
|
|
85
|
+
* seconds and any stopped/transcribing recording stay locally recoverable.
|
|
86
|
+
*/
|
|
79
87
|
cancel: () => void;
|
|
80
88
|
/** Intentionally delete the current durable recording. */
|
|
81
89
|
discard: () => Promise<void>;
|
|
@@ -85,6 +93,8 @@ export const VOICE_RECORDING_TIMESLICE_MILLISECONDS = 5_000;
|
|
|
85
93
|
export const VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS = 5_000;
|
|
86
94
|
export const VOICE_RECORDING_OWNER_STALE_MILLISECONDS = 30_000;
|
|
87
95
|
export const VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS = 600;
|
|
96
|
+
// Cancel deletes only an accidental start; anything longer is kept for the user.
|
|
97
|
+
const VOICE_RECORDING_DISCARD_ON_CANCEL_MAX_MILLISECONDS = 3_000;
|
|
88
98
|
export const VOICE_RECORDING_RESUMABLE_CLIENT_MAX_DURATION_SECONDS = 8 * 60 * 60;
|
|
89
99
|
export const VOICE_RECORDING_RECOVERY_STATUS_POLL_MILLISECONDS = 2_000;
|
|
90
100
|
export const VOICE_RECORDING_RECOVERY_MAX_MUTATION_DELAY_MILLISECONDS = 30_000;
|
|
@@ -159,6 +169,9 @@ export function useVoiceInput({
|
|
|
159
169
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
|
160
170
|
const streamRef = useRef<MediaStream | null>(null);
|
|
161
171
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
172
|
+
// Cancel/Escape/voice takeover keep a real dictation instead of deleting it.
|
|
173
|
+
const holdOnStopRef = useRef(false);
|
|
174
|
+
const recordingStartedAtRef = useRef(0);
|
|
162
175
|
const automaticRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
163
176
|
const automaticRetryAttemptRef = useRef(0);
|
|
164
177
|
const ownerHeartbeatTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
@@ -321,15 +334,31 @@ export function useVoiceInput({
|
|
|
321
334
|
generation === generationRef.current && workspaceIdRef.current === workspaceId;
|
|
322
335
|
const [store, ownerId] = await Promise.all([ensureStore(), ensureOwnerId()]);
|
|
323
336
|
if (!active()) return;
|
|
324
|
-
const
|
|
337
|
+
const heartbeatStaleBefore = new Date(
|
|
325
338
|
readNow().getTime() - VOICE_RECORDING_OWNER_STALE_MILLISECONDS,
|
|
326
339
|
).toISOString();
|
|
327
|
-
await store
|
|
340
|
+
await store
|
|
341
|
+
.cleanupHandedOffManifests({ ownerId, staleBefore: heartbeatStaleBefore })
|
|
342
|
+
.catch(() => undefined);
|
|
328
343
|
if (!active()) return;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
344
|
+
// A closed or crashed tab releases its owner lock at once: recover its
|
|
345
|
+
// recording now rather than after the heartbeat window (the user who
|
|
346
|
+
// reopens the app right away must still see their dictation).
|
|
347
|
+
const liveOwners =
|
|
348
|
+
createOwnerIdRef.current === undefined ? await liveVoiceRecordingOwnerIds() : null;
|
|
349
|
+
if (!active()) return;
|
|
350
|
+
const staleBefore = liveOwners ? readNow().toISOString() : heartbeatStaleBefore;
|
|
351
|
+
const manifests = (
|
|
352
|
+
await store.listRecoverableManifests(workspaceId, { ownerId, staleBefore })
|
|
353
|
+
).filter(
|
|
354
|
+
(manifest) =>
|
|
355
|
+
!liveOwners ||
|
|
356
|
+
!manifest.ownerId ||
|
|
357
|
+
manifest.ownerId === ownerId ||
|
|
358
|
+
!liveOwners.has(manifest.ownerId) ||
|
|
359
|
+
!manifest.ownerHeartbeatAt ||
|
|
360
|
+
manifest.ownerHeartbeatAt <= heartbeatStaleBefore,
|
|
361
|
+
);
|
|
333
362
|
if (!active()) return;
|
|
334
363
|
for (const candidate of manifests) {
|
|
335
364
|
try {
|
|
@@ -353,14 +382,20 @@ export function useVoiceInput({
|
|
|
353
382
|
}
|
|
354
383
|
rememberManifest(claimed);
|
|
355
384
|
beginOwnerHeartbeat(claimed);
|
|
385
|
+
const transcriptReady =
|
|
386
|
+
claimed.finalizationState === "transcript-ready" && claimed.transcriptText !== null;
|
|
356
387
|
setStatus(
|
|
357
|
-
|
|
388
|
+
transcriptReady
|
|
358
389
|
? "transcript-ready"
|
|
359
390
|
: claimed.recoveryMode === "automatic"
|
|
360
391
|
? "retrying"
|
|
361
392
|
: "recovered",
|
|
362
393
|
);
|
|
363
|
-
|
|
394
|
+
// An append-mode transcript may already be in the draft (the page
|
|
395
|
+
// closed between appending and recording the handoff).
|
|
396
|
+
setError(
|
|
397
|
+
transcriptReady && candidate.handoffMode === "append" ? "handoff_uncertain" : null,
|
|
398
|
+
);
|
|
364
399
|
return;
|
|
365
400
|
} catch (reason) {
|
|
366
401
|
if (reason instanceof VoiceRecordingOwnedError) continue;
|
|
@@ -464,8 +499,9 @@ export function useVoiceInput({
|
|
|
464
499
|
|
|
465
500
|
automaticRetryAttemptRef.current = 0;
|
|
466
501
|
if (ready.handoffMode === "explicit") {
|
|
502
|
+
// Never appended: offer an explicit insert, not an "already inserted?" warning.
|
|
467
503
|
setStatus("transcript-ready");
|
|
468
|
-
setError(
|
|
504
|
+
setError(null);
|
|
469
505
|
focusInput();
|
|
470
506
|
return;
|
|
471
507
|
}
|
|
@@ -824,6 +860,15 @@ export function useVoiceInput({
|
|
|
824
860
|
await preserveForRetry(retained, attemptCaptureLimitError, generation);
|
|
825
861
|
return;
|
|
826
862
|
}
|
|
863
|
+
if (holdOnStopRef.current) {
|
|
864
|
+
holdOnStopRef.current = false;
|
|
865
|
+
const retained = await updateManifestBestEffort(stopped, {
|
|
866
|
+
recoveryMode: "manual",
|
|
867
|
+
handoffMode: "explicit",
|
|
868
|
+
});
|
|
869
|
+
await preserveForRetry(retained, null, generation);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
827
872
|
await finalizePersistedRecording(generation);
|
|
828
873
|
});
|
|
829
874
|
};
|
|
@@ -832,6 +877,8 @@ export function useVoiceInput({
|
|
|
832
877
|
resolveCaptureSettledRef.current = resolve;
|
|
833
878
|
});
|
|
834
879
|
attemptRecordingStartedAt = readNow().getTime();
|
|
880
|
+
recordingStartedAtRef.current = attemptRecordingStartedAt;
|
|
881
|
+
holdOnStopRef.current = false;
|
|
835
882
|
recorder.start(VOICE_RECORDING_TIMESLICE_MILLISECONDS);
|
|
836
883
|
recorderStarted = true;
|
|
837
884
|
setStatus("recording");
|
|
@@ -1069,7 +1116,27 @@ export function useVoiceInput({
|
|
|
1069
1116
|
]);
|
|
1070
1117
|
|
|
1071
1118
|
const cancel = useCallback(() => {
|
|
1072
|
-
if (status === "recording"
|
|
1119
|
+
if (status === "recording") {
|
|
1120
|
+
const recorder = recorderRef.current;
|
|
1121
|
+
const elapsed = readNow().getTime() - recordingStartedAtRef.current;
|
|
1122
|
+
if (
|
|
1123
|
+
recorder &&
|
|
1124
|
+
recorder.state !== "inactive" &&
|
|
1125
|
+
elapsed >= VOICE_RECORDING_DISCARD_ON_CANCEL_MAX_MILLISECONDS
|
|
1126
|
+
) {
|
|
1127
|
+
// Never delete real speech on Cancel/Escape: stop, keep it locally, and
|
|
1128
|
+
// let the user transcribe or explicitly discard it.
|
|
1129
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
1130
|
+
timerRef.current = null;
|
|
1131
|
+
holdOnStopRef.current = true;
|
|
1132
|
+
setStatus("saving");
|
|
1133
|
+
recorder.stop();
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
void discard();
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
if (status === "requesting-permission") {
|
|
1073
1140
|
void discard();
|
|
1074
1141
|
return;
|
|
1075
1142
|
}
|
|
@@ -1093,7 +1160,7 @@ export function useVoiceInput({
|
|
|
1093
1160
|
await preserveForRetry(retained, null, generation);
|
|
1094
1161
|
});
|
|
1095
1162
|
}
|
|
1096
|
-
}, [discard, preserveForRetry, status, updateManifestBestEffort]);
|
|
1163
|
+
}, [discard, preserveForRetry, readNow, status, updateManifestBestEffort]);
|
|
1097
1164
|
|
|
1098
1165
|
useEffect(() => {
|
|
1099
1166
|
const current = manifestRef.current;
|
|
@@ -1156,6 +1223,8 @@ export function useVoiceInput({
|
|
|
1156
1223
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
1157
1224
|
if (
|
|
1158
1225
|
event.key === "Escape" &&
|
|
1226
|
+
// A dialog, menu, or popover that handled Escape owns it.
|
|
1227
|
+
!event.defaultPrevented &&
|
|
1159
1228
|
(status === "requesting-permission" ||
|
|
1160
1229
|
status === "recording" ||
|
|
1161
1230
|
status === "saving" ||
|
|
@@ -1179,6 +1248,19 @@ export function useVoiceInput({
|
|
|
1179
1248
|
automaticRetryTimerRef.current = null;
|
|
1180
1249
|
const manifest = manifestRef.current;
|
|
1181
1250
|
const ownerReady = ownerIdPromiseRef.current;
|
|
1251
|
+
const knownOwnerId = ownerIdRef.current;
|
|
1252
|
+
const ownerLease = ownerLeaseRef.current;
|
|
1253
|
+
const closingStore = storeRef.current;
|
|
1254
|
+
const ownsClosingStore = ownsStoreRef.current;
|
|
1255
|
+
// Detach synchronously: a remount of this instance (React StrictMode,
|
|
1256
|
+
// keyed re-render) must open a fresh store and owner instead of reusing
|
|
1257
|
+
// the connection this cleanup is about to close.
|
|
1258
|
+
storeRef.current = null;
|
|
1259
|
+
storePromiseRef.current = null;
|
|
1260
|
+
ownerIdRef.current = null;
|
|
1261
|
+
ownerIdPromiseRef.current = null;
|
|
1262
|
+
ownerLeaseRef.current = null;
|
|
1263
|
+
manifestRef.current = null;
|
|
1182
1264
|
stopOwnerHeartbeat();
|
|
1183
1265
|
const recorder = recorderRef.current;
|
|
1184
1266
|
let captureSettled = persistenceQueueRef.current;
|
|
@@ -1190,9 +1272,8 @@ export function useVoiceInput({
|
|
|
1190
1272
|
void captureSettled
|
|
1191
1273
|
.catch(() => undefined)
|
|
1192
1274
|
.then(async () => {
|
|
1193
|
-
const ownerId =
|
|
1194
|
-
|
|
1195
|
-
const store = storeRef.current;
|
|
1275
|
+
const ownerId = knownOwnerId ?? (ownerReady ? await ownerReady.catch(() => null) : null);
|
|
1276
|
+
const store = closingStore;
|
|
1196
1277
|
if (store && manifest && ownerId) {
|
|
1197
1278
|
const wasProcessing =
|
|
1198
1279
|
statusRef.current === "saving" ||
|
|
@@ -1221,12 +1302,11 @@ export function useVoiceInput({
|
|
|
1221
1302
|
)
|
|
1222
1303
|
.catch(() => undefined);
|
|
1223
1304
|
}
|
|
1224
|
-
if (
|
|
1305
|
+
if (ownsClosingStore) await store?.close();
|
|
1225
1306
|
})
|
|
1226
1307
|
.catch(() => undefined)
|
|
1227
1308
|
.finally(() => {
|
|
1228
|
-
|
|
1229
|
-
ownerLeaseRef.current = null;
|
|
1309
|
+
ownerLease?.release();
|
|
1230
1310
|
});
|
|
1231
1311
|
},
|
|
1232
1312
|
[clearCaptureRuntime, readNow, stopOwnerHeartbeat],
|
|
@@ -1239,6 +1319,17 @@ export function useVoiceInput({
|
|
|
1239
1319
|
stream,
|
|
1240
1320
|
recordingId,
|
|
1241
1321
|
durationSeconds,
|
|
1322
|
+
recordingStartedAt: status === "recording" ? recordingStartedAtRef.current || null : null,
|
|
1323
|
+
maxRecordingSeconds: capability
|
|
1324
|
+
? Math.min(
|
|
1325
|
+
capability.resumable && isResumableVoiceInputClient(client)
|
|
1326
|
+
? capability.resumable.maxDurationSeconds
|
|
1327
|
+
: capability.maxDurationSeconds,
|
|
1328
|
+
capability.resumable && isResumableVoiceInputClient(client)
|
|
1329
|
+
? VOICE_RECORDING_RESUMABLE_CLIENT_MAX_DURATION_SECONDS
|
|
1330
|
+
: VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS,
|
|
1331
|
+
)
|
|
1332
|
+
: null,
|
|
1242
1333
|
locallySaved,
|
|
1243
1334
|
hasRecoverableRecording: manifestRef.current !== null && status !== "recording",
|
|
1244
1335
|
savedTranscript:
|
|
@@ -1503,6 +1594,12 @@ function errorCode(error: unknown): string {
|
|
|
1503
1594
|
}
|
|
1504
1595
|
|
|
1505
1596
|
function isRetryableVoiceInputError(error: unknown): boolean {
|
|
1597
|
+
if (
|
|
1598
|
+
["insufficient_credits", "allowance_exhausted", "monthly_model_cost_limit"].includes(
|
|
1599
|
+
errorCode(error),
|
|
1600
|
+
)
|
|
1601
|
+
)
|
|
1602
|
+
return false;
|
|
1506
1603
|
if (error instanceof OpenGeniApiError) {
|
|
1507
1604
|
return error.retryable && error.status !== 409;
|
|
1508
1605
|
}
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,9 @@ export {
|
|
|
16
16
|
export {
|
|
17
17
|
attachSessionCapability,
|
|
18
18
|
completeSessionCapabilityOAuth,
|
|
19
|
+
prepareSessionCapabilityAccess,
|
|
20
|
+
applySessionCapabilityAccess,
|
|
21
|
+
type SessionCapabilityAccessPlan,
|
|
19
22
|
} from "./session-capability-policy";
|
|
20
23
|
export { retainedImageId } from "./components/markdown";
|
|
21
24
|
// oxlint-disable-next-line typescript/triple-slash-reference -- package consumers must load the ambient type for this optional untyped peer without emitting a runtime import.
|
|
@@ -388,14 +391,20 @@ export type {
|
|
|
388
391
|
// Tool-renderer registry + the per-tool renderers (the timeline's extension API)
|
|
389
392
|
export {
|
|
390
393
|
createDefaultToolRegistry,
|
|
394
|
+
createSessionRetainedScreenshotLoader,
|
|
391
395
|
createToolRegistry,
|
|
396
|
+
createWorkspaceRetainedArtifactLoader,
|
|
397
|
+
createWorkspaceRetainedVideoLoader,
|
|
392
398
|
defaultToolRegistry,
|
|
393
399
|
rawTypeOf,
|
|
394
400
|
} from "./timeline";
|
|
395
401
|
export type {
|
|
396
402
|
CreateToolRegistryOptions,
|
|
397
403
|
RetainedArtifactLoader,
|
|
404
|
+
RetainedArtifactLoaderClient,
|
|
398
405
|
RetainedScreenshotLoader,
|
|
406
|
+
RetainedScreenshotLoaderClient,
|
|
407
|
+
RetainedVideoLoaderClient,
|
|
399
408
|
VideoArtifactPlaybackLoader,
|
|
400
409
|
ToolRegistry,
|
|
401
410
|
ToolRegistryEntry,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { EffectiveSessionControl, SessionEvent, SessionStatus } from "@opengeni/sdk";
|
|
2
|
+
import { projectSessionRealtimeLifecycle } from "@opengeni/sdk/realtime";
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
RealtimeVoiceControl,
|
|
7
|
+
useSessionRealtime,
|
|
8
|
+
type RealtimeModelOption,
|
|
9
|
+
} from "./realtime-control";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The stock conversation's live voice button (lazy chunk). The conversation
|
|
13
|
+
* mounts it only when the workspace catalog offers an available voice model;
|
|
14
|
+
* this owns choosing among those models and the call itself.
|
|
15
|
+
*/
|
|
16
|
+
export default function EmbeddedRealtimeVoice(props: {
|
|
17
|
+
/** The conversation's client, which serves the realtime routes (or a proxy of them). */
|
|
18
|
+
client: unknown;
|
|
19
|
+
workspaceId: string;
|
|
20
|
+
/** Available voice models, already filtered by the conversation. */
|
|
21
|
+
models: readonly RealtimeModelOption[];
|
|
22
|
+
sessionId: string;
|
|
23
|
+
sessionStatus: SessionStatus;
|
|
24
|
+
effectiveControl: EffectiveSessionControl;
|
|
25
|
+
events: SessionEvent[];
|
|
26
|
+
eventsReady: boolean;
|
|
27
|
+
onVoiceActiveChange?: ((active: boolean) => void) | undefined;
|
|
28
|
+
}) {
|
|
29
|
+
const lifecycle = useMemo(() => projectSessionRealtimeLifecycle(props.events), [props.events]);
|
|
30
|
+
// A call already running (this browser after reload, or another one) fixes the model.
|
|
31
|
+
const activeModel =
|
|
32
|
+
lifecycle?.state === "active" && !(Date.parse(lifecycle.leaseExpiresAt) <= Date.now())
|
|
33
|
+
? lifecycle.model
|
|
34
|
+
: null;
|
|
35
|
+
const [chosen, setChosen] = useState<string | null>(null);
|
|
36
|
+
const fallback = props.models.find((model) => model.recommended) ?? props.models[0]!;
|
|
37
|
+
const selected =
|
|
38
|
+
props.models.find((model) => model.id === (activeModel ?? chosen)) ??
|
|
39
|
+
(activeModel ? { ...fallback, id: activeModel } : fallback);
|
|
40
|
+
const realtime = useSessionRealtime({
|
|
41
|
+
client: props.client as Parameters<typeof useSessionRealtime>[0]["client"],
|
|
42
|
+
workspaceId: props.workspaceId,
|
|
43
|
+
sessionId: props.sessionId,
|
|
44
|
+
sessionStatus: props.sessionStatus,
|
|
45
|
+
effectiveControl: props.effectiveControl,
|
|
46
|
+
events: props.events,
|
|
47
|
+
eventsReady: props.eventsReady,
|
|
48
|
+
codexConnected: false,
|
|
49
|
+
model: selected.id,
|
|
50
|
+
modelAvailable: selected.available,
|
|
51
|
+
modelUnavailableReason: selected.unavailableReason,
|
|
52
|
+
});
|
|
53
|
+
const voiceActive = realtime.snapshot.mode?.state === "active";
|
|
54
|
+
const onVoiceActiveChange = props.onVoiceActiveChange;
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
onVoiceActiveChange?.(voiceActive);
|
|
57
|
+
}, [onVoiceActiveChange, voiceActive]);
|
|
58
|
+
useEffect(() => () => onVoiceActiveChange?.(false), [onVoiceActiveChange]);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<RealtimeVoiceControl
|
|
62
|
+
snapshot={realtime.snapshot}
|
|
63
|
+
canStart={realtime.canStart}
|
|
64
|
+
admissionBlocker={realtime.admissionBlocker}
|
|
65
|
+
modelAvailable={selected.available}
|
|
66
|
+
menuSide="top"
|
|
67
|
+
modelMenu="split"
|
|
68
|
+
showDiagnostics={false}
|
|
69
|
+
audioRef={realtime.audioRef}
|
|
70
|
+
selectedModel={selected}
|
|
71
|
+
models={props.models}
|
|
72
|
+
onSelectModel={realtime.lifecycleActive ? undefined : setChosen}
|
|
73
|
+
onStart={realtime.start}
|
|
74
|
+
onStop={realtime.stop}
|
|
75
|
+
onRetry={realtime.retry}
|
|
76
|
+
onRetryAudibleOutput={realtime.retryAudibleOutput}
|
|
77
|
+
onSetInputMuted={realtime.setInputMuted}
|
|
78
|
+
onSetOutputMuted={realtime.setOutputMuted}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COMPOSER_VOICE_INPUT_START_EVENT,
|
|
3
|
+
composerVoiceEventScope,
|
|
4
|
+
} from "../composer-voice-events";
|
|
1
5
|
import { type EffectiveSessionControl, type SessionEvent, type SessionStatus } from "@opengeni/sdk";
|
|
2
6
|
import {
|
|
3
7
|
hasStoredSessionRealtimeOwnerProof,
|
|
@@ -22,6 +26,7 @@ import {
|
|
|
22
26
|
SquareIcon,
|
|
23
27
|
Volume2Icon,
|
|
24
28
|
VolumeXIcon,
|
|
29
|
+
XIcon,
|
|
25
30
|
} from "lucide-react";
|
|
26
31
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
27
32
|
import {
|
|
@@ -63,9 +68,26 @@ export type RealtimeModelOption = {
|
|
|
63
68
|
description: string;
|
|
64
69
|
available: boolean;
|
|
65
70
|
unavailableReason: string | null;
|
|
71
|
+
/** Machine-readable reason, e.g. `insufficient_credits` (same codes as voice input). */
|
|
72
|
+
unavailableCode?: string | null | undefined;
|
|
66
73
|
recommended: boolean;
|
|
67
74
|
};
|
|
68
75
|
|
|
76
|
+
/** Short status label for a credit/availability refusal (shared with the model menu). */
|
|
77
|
+
function realtimeRefusalLabel(code: string | null | undefined): string | null {
|
|
78
|
+
switch (code) {
|
|
79
|
+
case "insufficient_credits":
|
|
80
|
+
return "Credits needed";
|
|
81
|
+
case "allowance_exhausted":
|
|
82
|
+
case "monthly_model_cost_limit":
|
|
83
|
+
return "Usage limit reached";
|
|
84
|
+
case "realtime_voice_unavailable":
|
|
85
|
+
return "Voice unavailable";
|
|
86
|
+
default:
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
69
91
|
const CODEX_LIVE_MODEL: RealtimeModelOption = {
|
|
70
92
|
id: "gpt-live-1-boulder-alpha",
|
|
71
93
|
label: "Codex Live",
|
|
@@ -86,19 +108,30 @@ type RealtimeModelProvider = (typeof REALTIME_MODEL_PROVIDERS)[number];
|
|
|
86
108
|
|
|
87
109
|
const REALTIME_PROVIDER_META: Record<
|
|
88
110
|
RealtimeModelProvider,
|
|
89
|
-
{ billingClass: BillingClass; hint: string }
|
|
111
|
+
{ billingClass: BillingClass; label: string; hint: string }
|
|
90
112
|
> = {
|
|
91
|
-
|
|
113
|
+
// The wire value keeps its historical spelling; the product name is Opengeni.
|
|
114
|
+
OpenGeni: { billingClass: "opengeni_credits", label: "Opengeni", hint: "Uses Opengeni credits" },
|
|
92
115
|
"Connected Codex": {
|
|
93
116
|
billingClass: "codex_subscription",
|
|
117
|
+
label: "Connected Codex",
|
|
94
118
|
hint: "ChatGPT / Codex plan",
|
|
95
119
|
},
|
|
96
120
|
"Connected SuperGrok": {
|
|
97
121
|
billingClass: "supergrok_subscription",
|
|
122
|
+
label: "Connected SuperGrok",
|
|
98
123
|
hint: "SuperGrok / xAI plan",
|
|
99
124
|
},
|
|
100
|
-
"Your Gateway": {
|
|
125
|
+
"Your Gateway": {
|
|
126
|
+
billingClass: "byok",
|
|
127
|
+
label: "Your Gateway",
|
|
128
|
+
hint: "Billed to your AI Gateway",
|
|
129
|
+
},
|
|
101
130
|
};
|
|
131
|
+
/** Display name; the catalog's `provider` value stays the wire identifier. */
|
|
132
|
+
function realtimeProviderLabel(provider: RealtimeModelProvider): string {
|
|
133
|
+
return provider === "OpenGeni" ? "Opengeni" : provider;
|
|
134
|
+
}
|
|
102
135
|
const REALTIME_MODEL_STORAGE_PREFIX = "opengeni:realtime-model";
|
|
103
136
|
const REALTIME_MODEL_CATALOG_CACHE_TTL_MS = 60_000;
|
|
104
137
|
const REALTIME_MODEL_CATALOG_CACHE_MAX_WORKSPACES = 64;
|
|
@@ -524,14 +557,13 @@ export function SessionRealtimeControl(props: {
|
|
|
524
557
|
}
|
|
525
558
|
if (!canStart) return;
|
|
526
559
|
autostartStartedRef.current = true;
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
});
|
|
560
|
+
// One automatic attempt. A failed start stays visible with its reason and
|
|
561
|
+
// the user retries explicitly; re-arming would loop on a definitive refusal.
|
|
562
|
+
const consume = () => {
|
|
563
|
+
autostartModelRef.current = null;
|
|
564
|
+
onRealtimeAutostartConsumed?.();
|
|
565
|
+
};
|
|
566
|
+
void start().then(consume, consume);
|
|
535
567
|
}, [
|
|
536
568
|
canStart,
|
|
537
569
|
lifecycleActive,
|
|
@@ -567,6 +599,18 @@ export function SessionRealtimeControl(props: {
|
|
|
567
599
|
);
|
|
568
600
|
}
|
|
569
601
|
|
|
602
|
+
/**
|
|
603
|
+
* First available model; otherwise the model whose blocker the user can act on
|
|
604
|
+
* (for example adding credits) rather than an unrelated "connect" prompt.
|
|
605
|
+
*/
|
|
606
|
+
function fallbackRealtimeModelId(models: readonly RealtimeModelOption[]): SessionRealtimeModel {
|
|
607
|
+
return (
|
|
608
|
+
models.find((model) => model.available)?.id ??
|
|
609
|
+
models.find((model) => model.unavailableCode)?.id ??
|
|
610
|
+
CODEX_LIVE_MODEL.id
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
570
614
|
export function useRealtimeModelSelection(options: {
|
|
571
615
|
client?: RealtimeControllerClient | undefined;
|
|
572
616
|
workspaceId?: string | undefined;
|
|
@@ -593,7 +637,7 @@ export function useRealtimeModelSelection(options: {
|
|
|
593
637
|
if (initialCachedCatalog.some((model) => model.id === preferred && model.available)) {
|
|
594
638
|
return preferred;
|
|
595
639
|
}
|
|
596
|
-
return initialCachedCatalog
|
|
640
|
+
return fallbackRealtimeModelId(initialCachedCatalog);
|
|
597
641
|
});
|
|
598
642
|
|
|
599
643
|
useEffect(() => {
|
|
@@ -604,7 +648,7 @@ export function useRealtimeModelSelection(options: {
|
|
|
604
648
|
setCatalog(models);
|
|
605
649
|
setSelectedModelId((current) => {
|
|
606
650
|
if (models.some((model) => model.id === current && model.available)) return current;
|
|
607
|
-
return models
|
|
651
|
+
return fallbackRealtimeModelId(models);
|
|
608
652
|
});
|
|
609
653
|
};
|
|
610
654
|
const cached = readCachedRealtimeModelCatalog(client, workspaceId);
|
|
@@ -822,6 +866,7 @@ export function RealtimeVoiceControl(props: {
|
|
|
822
866
|
props.canStart,
|
|
823
867
|
props.admissionBlocker ?? null,
|
|
824
868
|
selectedModel.label,
|
|
869
|
+
selectedModel.unavailableCode ?? null,
|
|
825
870
|
);
|
|
826
871
|
const modeOwned = props.snapshot.mode?.state === "active";
|
|
827
872
|
const retryConnection =
|
|
@@ -830,6 +875,11 @@ export function RealtimeVoiceControl(props: {
|
|
|
830
875
|
props.snapshot.status !== "active" &&
|
|
831
876
|
props.snapshot.diagnostic?.recoverable === true;
|
|
832
877
|
const audioBlocked = props.snapshot.audibleOutput === "blocked" && !props.snapshot.outputMuted;
|
|
878
|
+
// A call that ended in a failure keeps its reason on screen, not only in a
|
|
879
|
+
// tooltip or the model menu, until the user starts again or changes model.
|
|
880
|
+
const startFailed = props.snapshot.status === "error" && !modeOwned;
|
|
881
|
+
const failureText =
|
|
882
|
+
status.label === "Voice unavailable" ? status.detail : `${status.label}. ${status.detail}`;
|
|
833
883
|
const mainDisabled =
|
|
834
884
|
props.snapshot.status === "stopping" ||
|
|
835
885
|
props.snapshot.status === "lost_owner" ||
|
|
@@ -841,7 +891,11 @@ export function RealtimeVoiceControl(props: {
|
|
|
841
891
|
? "Retry voice connection"
|
|
842
892
|
: modeOwned
|
|
843
893
|
? "End voice conversation"
|
|
844
|
-
:
|
|
894
|
+
: !props.canStart && status.phase === "unavailable"
|
|
895
|
+
? `${status.label}: ${status.detail}`
|
|
896
|
+
: startFailed
|
|
897
|
+
? `Try voice again with ${selectedModel.label}`
|
|
898
|
+
: `Start voice with ${selectedModel.label}`;
|
|
845
899
|
const runMainAction = audioBlocked
|
|
846
900
|
? props.onRetryAudibleOutput
|
|
847
901
|
: retryConnection
|
|
@@ -858,6 +912,20 @@ export function RealtimeVoiceControl(props: {
|
|
|
858
912
|
selectedModel.provider,
|
|
859
913
|
);
|
|
860
914
|
const [pickerDirection, setPickerDirection] = useState<1 | -1>(1);
|
|
915
|
+
const [failureDismissed, setFailureDismissed] = useState(false);
|
|
916
|
+
useEffect(() => {
|
|
917
|
+
if (!startFailed) setFailureDismissed(false);
|
|
918
|
+
}, [startFailed]);
|
|
919
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
920
|
+
useEffect(() => {
|
|
921
|
+
if (!startFailed) return;
|
|
922
|
+
// Starting dictation in this composer supersedes a stale start failure.
|
|
923
|
+
const scope = composerVoiceEventScope(rootRef.current);
|
|
924
|
+
if (!scope) return;
|
|
925
|
+
const dismiss = () => setFailureDismissed(true);
|
|
926
|
+
scope.addEventListener(COMPOSER_VOICE_INPUT_START_EVENT, dismiss);
|
|
927
|
+
return () => scope.removeEventListener(COMPOSER_VOICE_INPUT_START_EVENT, dismiss);
|
|
928
|
+
}, [startFailed]);
|
|
861
929
|
|
|
862
930
|
useEffect(() => {
|
|
863
931
|
if (!pickerOpen) setPickerProvider(selectedModel.provider);
|
|
@@ -865,6 +933,7 @@ export function RealtimeVoiceControl(props: {
|
|
|
865
933
|
|
|
866
934
|
return (
|
|
867
935
|
<div
|
|
936
|
+
ref={rootRef}
|
|
868
937
|
role="group"
|
|
869
938
|
aria-label="Realtime voice"
|
|
870
939
|
data-picker-side={props.menuSide ?? "top"}
|
|
@@ -946,6 +1015,20 @@ export function RealtimeVoiceControl(props: {
|
|
|
946
1015
|
</motion.div>
|
|
947
1016
|
) : null}
|
|
948
1017
|
</AnimatePresence>
|
|
1018
|
+
{startFailed && !failureDismissed ? (
|
|
1019
|
+
<button
|
|
1020
|
+
type="button"
|
|
1021
|
+
data-realtime-attention=""
|
|
1022
|
+
data-testid="realtime-failure-reason"
|
|
1023
|
+
title="Dismiss"
|
|
1024
|
+
aria-label={`Dismiss: ${failureText}`}
|
|
1025
|
+
onClick={() => setFailureDismissed(true)}
|
|
1026
|
+
className="og-realtime-failure mr-1.5 inline-flex min-w-0 items-center gap-1 rounded-og-sm text-right text-og-xs leading-tight text-og-status-failed outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/45"
|
|
1027
|
+
>
|
|
1028
|
+
<span className="og-realtime-failure-label line-clamp-2 max-w-56">{failureText}</span>
|
|
1029
|
+
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden />
|
|
1030
|
+
</button>
|
|
1031
|
+
) : null}
|
|
949
1032
|
<div className="inline-flex shrink-0 items-center">
|
|
950
1033
|
<motion.button
|
|
951
1034
|
type="button"
|
|
@@ -1202,7 +1285,7 @@ export function RealtimeModelPickerMenu(props: {
|
|
|
1202
1285
|
const body = props.provider ? (
|
|
1203
1286
|
<div data-testid="realtime-model-picker-models">
|
|
1204
1287
|
<PickerBackHeader
|
|
1205
|
-
label={props.provider}
|
|
1288
|
+
label={realtimeProviderLabel(props.provider)}
|
|
1206
1289
|
icon={
|
|
1207
1290
|
<BillingClassMark
|
|
1208
1291
|
billingClass={REALTIME_PROVIDER_META[props.provider].billingClass}
|
|
@@ -1245,7 +1328,7 @@ export function RealtimeModelPickerMenu(props: {
|
|
|
1245
1328
|
return (
|
|
1246
1329
|
<PickerNavRow
|
|
1247
1330
|
key={provider}
|
|
1248
|
-
label={provider}
|
|
1331
|
+
label={realtimeProviderLabel(provider)}
|
|
1249
1332
|
hint={meta.hint}
|
|
1250
1333
|
icon={<BillingClassMark billingClass={meta.billingClass} aria-label="" />}
|
|
1251
1334
|
active={props.selectedModel.provider === provider}
|
|
@@ -1333,6 +1416,7 @@ function statusContent(
|
|
|
1333
1416
|
canStart: boolean,
|
|
1334
1417
|
admissionBlocker: string | null,
|
|
1335
1418
|
modelLabel: string,
|
|
1419
|
+
unavailableCode: string | null = null,
|
|
1336
1420
|
): { phase: RealtimeVisualPhase; label: string; detail: string } {
|
|
1337
1421
|
if (snapshot.audibleOutput === "blocked" && !snapshot.outputMuted) {
|
|
1338
1422
|
return {
|
|
@@ -1379,6 +1463,13 @@ function statusContent(
|
|
|
1379
1463
|
detail: "Return to the browser that started it, or wait for that connection to expire.",
|
|
1380
1464
|
};
|
|
1381
1465
|
case "error":
|
|
1466
|
+
if (snapshot.refusal) {
|
|
1467
|
+
return {
|
|
1468
|
+
phase: "unavailable",
|
|
1469
|
+
label: realtimeRefusalLabel(snapshot.refusal.code) ?? "Voice unavailable",
|
|
1470
|
+
detail: snapshot.refusal.message,
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1382
1473
|
return {
|
|
1383
1474
|
phase: "error",
|
|
1384
1475
|
label: "Voice unavailable",
|
|
@@ -1388,7 +1479,7 @@ function statusContent(
|
|
|
1388
1479
|
if (!modelAvailable) {
|
|
1389
1480
|
return {
|
|
1390
1481
|
phase: "unavailable",
|
|
1391
|
-
label: "Voice model unavailable",
|
|
1482
|
+
label: realtimeRefusalLabel(unavailableCode) ?? "Voice model unavailable",
|
|
1392
1483
|
detail: admissionBlocker ?? "Choose another voice model.",
|
|
1393
1484
|
};
|
|
1394
1485
|
}
|
|
@@ -1580,6 +1671,7 @@ function writeRealtimeModelPreference(workspaceId: string, model: SessionRealtim
|
|
|
1580
1671
|
|
|
1581
1672
|
function isRealtimeModel(value: string | null): value is SessionRealtimeModel {
|
|
1582
1673
|
return (
|
|
1674
|
+
value === "opengeni-azure/gpt-live-1" ||
|
|
1583
1675
|
value === "gpt-live-1-boulder-alpha" ||
|
|
1584
1676
|
value === "opengeni-gateway/openai/gpt-realtime-2.1" ||
|
|
1585
1677
|
value === "opengeni-gateway/openai/gpt-realtime-mini" ||
|