@agent-native/toolkit 0.6.0 → 0.8.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 +17 -1
- package/agent-native.eject.json +411 -0
- package/dist/chat-history/ChatHistoryList.d.ts +103 -0
- package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.js +109 -0
- package/dist/chat-history/ChatHistoryList.js.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.js +283 -0
- package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
- package/dist/chat-history/index.d.ts +2 -0
- package/dist/chat-history/index.d.ts.map +1 -0
- package/dist/chat-history/index.js +2 -0
- package/dist/chat-history/index.js.map +1 -0
- package/dist/chat-history.css +314 -0
- package/package.json +20 -1
- package/src/app-shell/header-actions.tsx +84 -0
- package/src/app-shell/index.ts +1 -0
- package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
- package/src/chat-history/ChatHistoryList.tsx +469 -0
- package/src/chat-history/index.ts +7 -0
- package/src/chat-history.css +314 -0
- package/src/collab-ui/AgentPresenceChip.tsx +69 -0
- package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
- package/src/collab-ui/PresenceBar.tsx +346 -0
- package/src/collab-ui/RecentEditHighlights.tsx +225 -0
- package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
- package/src/collab-ui/agent-identity.ts +16 -0
- package/src/collab-ui/index.ts +39 -0
- package/src/collab-ui/lead-client.ts +32 -0
- package/src/collab-ui/types.spec.ts +18 -0
- package/src/collab-ui/types.ts +96 -0
- package/src/composer/AgentComposerFrame.tsx +59 -0
- package/src/composer/ComposerPlusMenu.tsx +983 -0
- package/src/composer/MentionPopover.tsx +412 -0
- package/src/composer/PastedTextChip.tsx +150 -0
- package/src/composer/PromptComposer.spec.ts +73 -0
- package/src/composer/PromptComposer.tsx +718 -0
- package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
- package/src/composer/RealtimeVoiceMode.tsx +856 -0
- package/src/composer/TiptapComposer.spec.ts +390 -0
- package/src/composer/TiptapComposer.tsx +2936 -0
- package/src/composer/VoiceButton.spec.ts +33 -0
- package/src/composer/VoiceButton.tsx +255 -0
- package/src/composer/asset-picker-url.spec.ts +56 -0
- package/src/composer/asset-picker-url.ts +80 -0
- package/src/composer/attachment-accept.spec.ts +22 -0
- package/src/composer/attachment-accept.ts +71 -0
- package/src/composer/draft-key.spec.ts +19 -0
- package/src/composer/draft-key.ts +7 -0
- package/src/composer/extensions/FileReference.tsx +55 -0
- package/src/composer/extensions/MentionReference.tsx +93 -0
- package/src/composer/extensions/SkillReference.tsx +56 -0
- package/src/composer/index.ts +79 -0
- package/src/composer/pasted-text.spec.ts +162 -0
- package/src/composer/pasted-text.ts +160 -0
- package/src/composer/prompt-attachments.spec.ts +80 -0
- package/src/composer/prompt-attachments.ts +93 -0
- package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
- package/src/composer/realtime-voice-audio-level.ts +64 -0
- package/src/composer/realtime-voice-transcript.spec.ts +95 -0
- package/src/composer/realtime-voice-transcript.ts +134 -0
- package/src/composer/runtime-adapters.tsx +343 -0
- package/src/composer/types.ts +66 -0
- package/src/composer/use-file-search.ts +64 -0
- package/src/composer/use-mention-search.ts +90 -0
- package/src/composer/use-skills.ts +45 -0
- package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
- package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
- package/src/composer/useVoiceDictation.spec.ts +45 -0
- package/src/composer/useVoiceDictation.ts +1191 -0
- package/src/context-ui/ContextMeter.tsx +101 -0
- package/src/context-ui/ContextSegmentRow.tsx +184 -0
- package/src/context-ui/ContextTreemap.tsx +125 -0
- package/src/context-ui/ContextXRayPanel.tsx +377 -0
- package/src/context-ui/format.ts +42 -0
- package/src/context-ui/index.ts +15 -0
- package/src/context-ui/types.ts +43 -0
- package/src/design-tweaks/index.ts +1 -0
- package/src/design-tweaks/visual-style-controls.tsx +1084 -0
- package/src/editor/BubbleToolbar.tsx +253 -0
- package/src/editor/CodeBlockNode.tsx +254 -0
- package/src/editor/DragHandle.spec.ts +588 -0
- package/src/editor/DragHandle.ts +1437 -0
- package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
- package/src/editor/ImageExtension.ts +306 -0
- package/src/editor/RegistryBlockContext.tsx +107 -0
- package/src/editor/RegistryBlockNode.spec.ts +279 -0
- package/src/editor/RegistryBlockNode.tsx +652 -0
- package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
- package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
- package/src/editor/RichMarkdownEditor.tsx +81 -0
- package/src/editor/RunId.ts +61 -0
- package/src/editor/SharedRichEditor.tsx +321 -0
- package/src/editor/SlashCommandMenu.tsx +310 -0
- package/src/editor/disableHistory.spec.ts +85 -0
- package/src/editor/extensions.ts +402 -0
- package/src/editor/gfmDoc.spec.ts +230 -0
- package/src/editor/gfmDoc.ts +92 -0
- package/src/editor/index.ts +93 -0
- package/src/editor/registrySlashCommands.ts +131 -0
- package/src/editor/structuralReorderUndo.spec.ts +144 -0
- package/src/editor/surgical-apply.spec.ts +279 -0
- package/src/editor/surgical-apply.ts +192 -0
- package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
- package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
- package/src/editor/useCollabReconcile.ts +589 -0
- package/src/editor.css +510 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/use-mobile.tsx +21 -0
- package/src/hooks/use-toast.ts +188 -0
- package/src/index.ts +8 -0
- package/src/onboarding/index.ts +1 -0
- package/src/provider.tsx +47 -0
- package/src/sharing/VisibilityBadge.spec.ts +96 -0
- package/src/sharing/VisibilityBadge.tsx +37 -0
- package/src/sharing/index.ts +4 -0
- package/src/styles.css +21 -0
- package/src/ui/accordion.tsx +56 -0
- package/src/ui/alert-dialog.tsx +141 -0
- package/src/ui/alert.tsx +59 -0
- package/src/ui/aspect-ratio.tsx +5 -0
- package/src/ui/avatar.tsx +48 -0
- package/src/ui/badge.tsx +37 -0
- package/src/ui/breadcrumb.tsx +115 -0
- package/src/ui/button.tsx +78 -0
- package/src/ui/calendar.tsx +214 -0
- package/src/ui/card.tsx +86 -0
- package/src/ui/carousel.tsx +260 -0
- package/src/ui/chart.tsx +375 -0
- package/src/ui/checkbox.tsx +28 -0
- package/src/ui/collapsible.tsx +9 -0
- package/src/ui/command.spec.tsx +51 -0
- package/src/ui/command.tsx +174 -0
- package/src/ui/context-menu.tsx +202 -0
- package/src/ui/date-picker.tsx +64 -0
- package/src/ui/dialog.spec.tsx +43 -0
- package/src/ui/dialog.tsx +146 -0
- package/src/ui/drawer.tsx +126 -0
- package/src/ui/dropdown-menu.tsx +218 -0
- package/src/ui/form.tsx +177 -0
- package/src/ui/hover-card.tsx +27 -0
- package/src/ui/index.ts +52 -0
- package/src/ui/input-otp.tsx +69 -0
- package/src/ui/input.tsx +22 -0
- package/src/ui/label.tsx +24 -0
- package/src/ui/logo.tsx +36 -0
- package/src/ui/menubar.tsx +235 -0
- package/src/ui/navigation-menu.tsx +128 -0
- package/src/ui/pagination.tsx +121 -0
- package/src/ui/popover.tsx +44 -0
- package/src/ui/progress.tsx +26 -0
- package/src/ui/radio-group.tsx +42 -0
- package/src/ui/resizable.tsx +43 -0
- package/src/ui/scroll-area.tsx +46 -0
- package/src/ui/select.tsx +158 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/sheet.tsx +160 -0
- package/src/ui/sidebar.tsx +777 -0
- package/src/ui/skeleton.tsx +18 -0
- package/src/ui/slider.tsx +26 -0
- package/src/ui/sonner.tsx +58 -0
- package/src/ui/spinner.tsx +17 -0
- package/src/ui/switch.tsx +27 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/tabs.tsx +53 -0
- package/src/ui/textarea.tsx +23 -0
- package/src/ui/toast.tsx +127 -0
- package/src/ui/toaster.tsx +33 -0
- package/src/ui/toggle-group.tsx +59 -0
- package/src/ui/toggle.tsx +43 -0
- package/src/ui/tooltip.tsx +53 -0
- package/src/ui/use-toast.ts +3 -0
- package/src/utils.ts +8 -0
|
@@ -0,0 +1,2117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { createPortal } from "react-dom";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
createRealtimeVoiceAudioLevelStore,
|
|
15
|
+
normalizeRealtimeVoiceRms,
|
|
16
|
+
smoothRealtimeVoiceLevel,
|
|
17
|
+
type RealtimeVoiceAudioLevelStore,
|
|
18
|
+
} from "./realtime-voice-audio-level.js";
|
|
19
|
+
import { realtimeVoiceTranscriptRegistry } from "./realtime-voice-transcript.js";
|
|
20
|
+
import {
|
|
21
|
+
RealtimeVoiceModeDock,
|
|
22
|
+
type RealtimeVoiceModeCopy,
|
|
23
|
+
type RealtimeVoiceModeState,
|
|
24
|
+
} from "./RealtimeVoiceMode.js";
|
|
25
|
+
import { useComposerRuntimeAdapters } from "./runtime-adapters.js";
|
|
26
|
+
|
|
27
|
+
const REALTIME_VOICE_STATE_KEY = "realtime-voice-session";
|
|
28
|
+
const REALTIME_VOICE_PREFERENCES_KEY = "realtime-voice-prefs";
|
|
29
|
+
const REALTIME_VOICE_REQUEST_SOURCE = "realtime-voice";
|
|
30
|
+
const REALTIME_VOICE_SESSION_PATH = "/_agent-native/realtime-voice/session";
|
|
31
|
+
const REALTIME_VOICE_TOOL_PATH = "/_agent-native/realtime-voice/tool";
|
|
32
|
+
const REALTIME_VOICE_CAPABILITY_HEADER = "X-Agent-Native-Realtime-Capability";
|
|
33
|
+
const REALTIME_VOICE_CONNECTION_TIMEOUT_MS = 15_000;
|
|
34
|
+
const REALTIME_VOICE_MAX_TOOLS = 32;
|
|
35
|
+
const REALTIME_VOICE_MAX_TOOL_SCHEMA_BYTES = 32_000;
|
|
36
|
+
const REALTIME_VOICE_MAX_SESSION_BYTES = 64_000;
|
|
37
|
+
const REALTIME_VOICE_TOOL_UPDATE_TIMEOUT_MS = 5_000;
|
|
38
|
+
const REALTIME_VOICE_PRIORITY_TOOL_NAMES = [
|
|
39
|
+
"navigate",
|
|
40
|
+
"set-url-path",
|
|
41
|
+
"set-search-params",
|
|
42
|
+
"view-screen",
|
|
43
|
+
"tool-search",
|
|
44
|
+
] as const;
|
|
45
|
+
const REALTIME_VOICE_TOOL_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
46
|
+
const REALTIME_VOICE_MICROPHONE_STORAGE_KEY =
|
|
47
|
+
"agent-native:realtime-voice-microphone";
|
|
48
|
+
|
|
49
|
+
export const REALTIME_VOICE_LANGUAGES = [
|
|
50
|
+
"auto",
|
|
51
|
+
"en",
|
|
52
|
+
"es",
|
|
53
|
+
"fr",
|
|
54
|
+
"de",
|
|
55
|
+
"it",
|
|
56
|
+
"pt",
|
|
57
|
+
"ja",
|
|
58
|
+
"ko",
|
|
59
|
+
"zh",
|
|
60
|
+
] as const;
|
|
61
|
+
export const REALTIME_VOICE_INTELLIGENCE_LEVELS = [
|
|
62
|
+
"instant",
|
|
63
|
+
"balanced",
|
|
64
|
+
"deep",
|
|
65
|
+
] as const;
|
|
66
|
+
export const REALTIME_VOICES = [
|
|
67
|
+
"marin",
|
|
68
|
+
"cedar",
|
|
69
|
+
"coral",
|
|
70
|
+
"sage",
|
|
71
|
+
"verse",
|
|
72
|
+
"alloy",
|
|
73
|
+
"ash",
|
|
74
|
+
"ballad",
|
|
75
|
+
"echo",
|
|
76
|
+
"shimmer",
|
|
77
|
+
] as const;
|
|
78
|
+
|
|
79
|
+
export type RealtimeVoiceLanguage = (typeof REALTIME_VOICE_LANGUAGES)[number];
|
|
80
|
+
export type RealtimeVoiceIntelligence =
|
|
81
|
+
(typeof REALTIME_VOICE_INTELLIGENCE_LEVELS)[number];
|
|
82
|
+
export type RealtimeVoice = (typeof REALTIME_VOICES)[number];
|
|
83
|
+
|
|
84
|
+
export interface RealtimeVoicePreferences {
|
|
85
|
+
language: RealtimeVoiceLanguage;
|
|
86
|
+
intelligence: RealtimeVoiceIntelligence;
|
|
87
|
+
voice: RealtimeVoice;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const DEFAULT_REALTIME_VOICE_PREFERENCES: RealtimeVoicePreferences = {
|
|
91
|
+
language: "auto",
|
|
92
|
+
intelligence: "instant",
|
|
93
|
+
voice: "marin",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const REALTIME_VOICE_AUDIO_CONSTRAINTS: MediaTrackConstraints = {
|
|
97
|
+
// Prefer the browser/OS-selected input instead of whichever physical device
|
|
98
|
+
// happens to be enumerated first. `ideal` keeps browsers without the
|
|
99
|
+
// synthetic `default` device from failing with OverconstrainedError.
|
|
100
|
+
deviceId: { ideal: "default" },
|
|
101
|
+
echoCancellation: true,
|
|
102
|
+
noiseSuppression: true,
|
|
103
|
+
autoGainControl: true,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
type RealtimeServerEvent = Record<string, unknown> & { type?: string };
|
|
107
|
+
|
|
108
|
+
export interface RealtimeVoiceToolResult {
|
|
109
|
+
callId: string;
|
|
110
|
+
status: "completed" | "failed" | "approval_required";
|
|
111
|
+
output: string;
|
|
112
|
+
approvalKey?: string;
|
|
113
|
+
expandedTools?: RealtimeVoiceFunctionTool[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface RealtimeVoiceSessionAnswer {
|
|
117
|
+
sdp: string;
|
|
118
|
+
capability?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface RealtimeVoiceFunctionTool {
|
|
122
|
+
type: "function";
|
|
123
|
+
name: string;
|
|
124
|
+
description?: string;
|
|
125
|
+
parameters?: Record<string, unknown>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface RealtimeVoiceModeApi {
|
|
129
|
+
state: "idle" | RealtimeVoiceModeState;
|
|
130
|
+
active: boolean;
|
|
131
|
+
errorMessage: string | null;
|
|
132
|
+
chatVisible: boolean;
|
|
133
|
+
audioLevels: RealtimeVoiceAudioLevelStore;
|
|
134
|
+
preferences: RealtimeVoicePreferences;
|
|
135
|
+
microphones: readonly RealtimeVoiceMicrophone[];
|
|
136
|
+
microphoneDeviceId: string;
|
|
137
|
+
microphoneSwitching: boolean;
|
|
138
|
+
microphoneError: string | null;
|
|
139
|
+
voiceChangePending: boolean;
|
|
140
|
+
setLanguage: (language: RealtimeVoiceLanguage) => void;
|
|
141
|
+
setIntelligence: (intelligence: RealtimeVoiceIntelligence) => void;
|
|
142
|
+
setVoice: (voice: RealtimeVoice) => void;
|
|
143
|
+
setMicrophone: (deviceId: string) => Promise<void>;
|
|
144
|
+
start: () => Promise<void>;
|
|
145
|
+
end: () => void;
|
|
146
|
+
toggleChat: () => void;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface RealtimeVoiceMicrophone {
|
|
150
|
+
deviceId: string;
|
|
151
|
+
label: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function createRealtimeVoiceAudioConstraints(
|
|
155
|
+
deviceId = "default",
|
|
156
|
+
exact = false,
|
|
157
|
+
): MediaTrackConstraints {
|
|
158
|
+
return {
|
|
159
|
+
...REALTIME_VOICE_AUDIO_CONSTRAINTS,
|
|
160
|
+
deviceId:
|
|
161
|
+
deviceId === "default"
|
|
162
|
+
? { ideal: "default" }
|
|
163
|
+
: exact
|
|
164
|
+
? { exact: deviceId }
|
|
165
|
+
: { ideal: deviceId },
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function replaceRealtimeVoiceMicrophone(options: {
|
|
170
|
+
mediaDevices: Pick<MediaDevices, "getUserMedia">;
|
|
171
|
+
peer: Pick<RTCPeerConnection, "getSenders">;
|
|
172
|
+
currentStream: MediaStream | null;
|
|
173
|
+
deviceId: string;
|
|
174
|
+
}): Promise<MediaStream> {
|
|
175
|
+
const replacementStream = await options.mediaDevices.getUserMedia({
|
|
176
|
+
audio: createRealtimeVoiceAudioConstraints(options.deviceId, true),
|
|
177
|
+
});
|
|
178
|
+
try {
|
|
179
|
+
const replacementTrack = replacementStream.getAudioTracks()[0];
|
|
180
|
+
const sender = options.peer
|
|
181
|
+
.getSenders()
|
|
182
|
+
.find((candidate) => candidate.track?.kind === "audio");
|
|
183
|
+
if (!replacementTrack || !sender) {
|
|
184
|
+
throw new Error("No active microphone track is available.");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await sender.replaceTrack(replacementTrack);
|
|
188
|
+
for (const track of options.currentStream?.getTracks() ?? []) {
|
|
189
|
+
track.onended = null;
|
|
190
|
+
track.stop();
|
|
191
|
+
}
|
|
192
|
+
return replacementStream;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
for (const track of replacementStream.getTracks()) track.stop();
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function readRealtimeVoiceMicrophoneId(): string {
|
|
200
|
+
if (typeof window === "undefined") return "default";
|
|
201
|
+
try {
|
|
202
|
+
return (
|
|
203
|
+
window.localStorage.getItem(REALTIME_VOICE_MICROPHONE_STORAGE_KEY) ||
|
|
204
|
+
"default"
|
|
205
|
+
);
|
|
206
|
+
} catch {
|
|
207
|
+
return "default";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function writeRealtimeVoiceMicrophoneId(deviceId: string): void {
|
|
212
|
+
try {
|
|
213
|
+
window.localStorage.setItem(
|
|
214
|
+
REALTIME_VOICE_MICROPHONE_STORAGE_KEY,
|
|
215
|
+
deviceId,
|
|
216
|
+
);
|
|
217
|
+
} catch {
|
|
218
|
+
// A remembered local device is optional and must never block voice mode.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isOneOf<T extends readonly string[]>(
|
|
223
|
+
values: T,
|
|
224
|
+
value: unknown,
|
|
225
|
+
): value is T[number] {
|
|
226
|
+
return typeof value === "string" && values.includes(value);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function normalizeRealtimeVoicePreferences(
|
|
230
|
+
value: unknown,
|
|
231
|
+
): RealtimeVoicePreferences {
|
|
232
|
+
const record =
|
|
233
|
+
value && typeof value === "object" && !Array.isArray(value)
|
|
234
|
+
? (value as Record<string, unknown>)
|
|
235
|
+
: {};
|
|
236
|
+
return {
|
|
237
|
+
language: isOneOf(REALTIME_VOICE_LANGUAGES, record.language)
|
|
238
|
+
? record.language
|
|
239
|
+
: DEFAULT_REALTIME_VOICE_PREFERENCES.language,
|
|
240
|
+
intelligence: isOneOf(
|
|
241
|
+
REALTIME_VOICE_INTELLIGENCE_LEVELS,
|
|
242
|
+
record.intelligence,
|
|
243
|
+
)
|
|
244
|
+
? record.intelligence
|
|
245
|
+
: DEFAULT_REALTIME_VOICE_PREFERENCES.intelligence,
|
|
246
|
+
voice: isOneOf(REALTIME_VOICES, record.voice)
|
|
247
|
+
? record.voice
|
|
248
|
+
: DEFAULT_REALTIME_VOICE_PREFERENCES.voice,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function resolveRealtimeVoiceLanguage(
|
|
253
|
+
language: RealtimeVoiceLanguage,
|
|
254
|
+
browserLanguages: readonly string[] = [],
|
|
255
|
+
): Exclude<RealtimeVoiceLanguage, "auto"> {
|
|
256
|
+
if (language !== "auto") return language;
|
|
257
|
+
for (const locale of browserLanguages) {
|
|
258
|
+
const primary = locale.trim().split("-")[0]?.toLowerCase();
|
|
259
|
+
if (isOneOf(REALTIME_VOICE_LANGUAGES, primary) && primary !== "auto") {
|
|
260
|
+
return primary;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return "en";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function realtimeVoiceReasoningEffort(
|
|
267
|
+
intelligence: RealtimeVoiceIntelligence,
|
|
268
|
+
): "minimal" | "low" | "medium" {
|
|
269
|
+
if (intelligence === "balanced") return "low";
|
|
270
|
+
if (intelligence === "deep") return "medium";
|
|
271
|
+
return "minimal";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function createRealtimeVoicePreferenceUpdate(
|
|
275
|
+
preferences: RealtimeVoicePreferences,
|
|
276
|
+
options: {
|
|
277
|
+
browserLanguages?: readonly string[];
|
|
278
|
+
includeVoice?: boolean;
|
|
279
|
+
} = {},
|
|
280
|
+
): Record<string, unknown> {
|
|
281
|
+
return {
|
|
282
|
+
type: "session.update",
|
|
283
|
+
session: {
|
|
284
|
+
type: "realtime",
|
|
285
|
+
reasoning: {
|
|
286
|
+
effort: realtimeVoiceReasoningEffort(preferences.intelligence),
|
|
287
|
+
},
|
|
288
|
+
audio: {
|
|
289
|
+
input: {
|
|
290
|
+
transcription: {
|
|
291
|
+
model: "gpt-4o-mini-transcribe",
|
|
292
|
+
language: resolveRealtimeVoiceLanguage(
|
|
293
|
+
preferences.language,
|
|
294
|
+
options.browserLanguages,
|
|
295
|
+
),
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
...(options.includeVoice
|
|
299
|
+
? { output: { voice: preferences.voice } }
|
|
300
|
+
: {}),
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export interface CompletedRealtimeVoiceTranscript {
|
|
307
|
+
role: "user" | "assistant";
|
|
308
|
+
text: string;
|
|
309
|
+
providerId?: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface RealtimeVoiceTranscriptSequencer {
|
|
313
|
+
handle: (event: RealtimeServerEvent) => void;
|
|
314
|
+
reset: () => void;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
interface SequencedRealtimeVoiceTranscript {
|
|
318
|
+
status: "pending" | "completed" | "skipped";
|
|
319
|
+
role?: CompletedRealtimeVoiceTranscript["role"];
|
|
320
|
+
transcript?: CompletedRealtimeVoiceTranscript;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Input transcription is produced by a separate ASR model and can finish after
|
|
325
|
+
* the assistant response. Reserve each message's position when OpenAI adds it
|
|
326
|
+
* to the conversation, then publish only the contiguous completed prefix.
|
|
327
|
+
*/
|
|
328
|
+
export function createRealtimeVoiceTranscriptSequencer(
|
|
329
|
+
publish: (transcript: CompletedRealtimeVoiceTranscript) => void,
|
|
330
|
+
): RealtimeVoiceTranscriptSequencer {
|
|
331
|
+
const order: string[] = [];
|
|
332
|
+
const items = new Map<string, SequencedRealtimeVoiceTranscript>();
|
|
333
|
+
const settledItemIds = new Set<string>();
|
|
334
|
+
const receivedTranscriptIds = new Set<string>();
|
|
335
|
+
|
|
336
|
+
const reserve = (
|
|
337
|
+
id: string,
|
|
338
|
+
role?: CompletedRealtimeVoiceTranscript["role"],
|
|
339
|
+
) => {
|
|
340
|
+
if (settledItemIds.has(id)) return;
|
|
341
|
+
const existing = items.get(id);
|
|
342
|
+
if (existing) {
|
|
343
|
+
existing.role ??= role;
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
order.push(id);
|
|
347
|
+
items.set(id, { status: "pending", role });
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const drain = () => {
|
|
351
|
+
while (order.length > 0) {
|
|
352
|
+
const id = order[0]!;
|
|
353
|
+
const entry = items.get(id);
|
|
354
|
+
if (!entry || entry.status === "pending") return;
|
|
355
|
+
order.shift();
|
|
356
|
+
items.delete(id);
|
|
357
|
+
settledItemIds.add(id);
|
|
358
|
+
if (entry.status === "completed" && entry.transcript) {
|
|
359
|
+
publish(entry.transcript);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const skip = (id: unknown) => {
|
|
365
|
+
if (typeof id !== "string" || !id) return;
|
|
366
|
+
reserve(id);
|
|
367
|
+
const entry = items.get(id)!;
|
|
368
|
+
if (entry.status === "pending") entry.status = "skipped";
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
handle(event) {
|
|
373
|
+
if (
|
|
374
|
+
event.type === "conversation.item.added" ||
|
|
375
|
+
event.type === "conversation.item.created"
|
|
376
|
+
) {
|
|
377
|
+
const item = event.item;
|
|
378
|
+
if (item && typeof item === "object") {
|
|
379
|
+
const record = item as Record<string, unknown>;
|
|
380
|
+
if (
|
|
381
|
+
record.type === "message" &&
|
|
382
|
+
(record.role === "user" || record.role === "assistant") &&
|
|
383
|
+
typeof record.id === "string"
|
|
384
|
+
) {
|
|
385
|
+
reserve(record.id, record.role);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const transcript = extractCompletedRealtimeVoiceTranscript(event);
|
|
391
|
+
if (transcript) {
|
|
392
|
+
const transcriptId = transcript.providerId
|
|
393
|
+
? `${transcript.role}:${transcript.providerId}`
|
|
394
|
+
: undefined;
|
|
395
|
+
if (transcriptId && receivedTranscriptIds.has(transcriptId)) return;
|
|
396
|
+
if (transcriptId) receivedTranscriptIds.add(transcriptId);
|
|
397
|
+
const itemId =
|
|
398
|
+
typeof event.item_id === "string" ? event.item_id : undefined;
|
|
399
|
+
if (!itemId) {
|
|
400
|
+
// Older providers may omit item_id. Match the first reserved slot for
|
|
401
|
+
// that role so one incomplete legacy event cannot strand the queue.
|
|
402
|
+
const reservedId = order.find((id) => {
|
|
403
|
+
const entry = items.get(id);
|
|
404
|
+
return (
|
|
405
|
+
entry?.status === "pending" && entry.role === transcript.role
|
|
406
|
+
);
|
|
407
|
+
});
|
|
408
|
+
if (reservedId) {
|
|
409
|
+
items.set(reservedId, {
|
|
410
|
+
status: "completed",
|
|
411
|
+
role: transcript.role,
|
|
412
|
+
transcript,
|
|
413
|
+
});
|
|
414
|
+
} else {
|
|
415
|
+
publish(transcript);
|
|
416
|
+
}
|
|
417
|
+
} else {
|
|
418
|
+
if (settledItemIds.has(itemId)) return;
|
|
419
|
+
reserve(itemId, transcript.role);
|
|
420
|
+
items.set(itemId, {
|
|
421
|
+
status: "completed",
|
|
422
|
+
role: transcript.role,
|
|
423
|
+
transcript,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
} else if (
|
|
427
|
+
event.type === "conversation.item.input_audio_transcription.failed" ||
|
|
428
|
+
event.type === "conversation.item.deleted"
|
|
429
|
+
) {
|
|
430
|
+
skip(event.item_id);
|
|
431
|
+
} else if (event.type === "response.done") {
|
|
432
|
+
const response = event.response;
|
|
433
|
+
const output =
|
|
434
|
+
response && typeof response === "object"
|
|
435
|
+
? (response as { output?: unknown }).output
|
|
436
|
+
: undefined;
|
|
437
|
+
if (Array.isArray(output)) {
|
|
438
|
+
for (const item of output) {
|
|
439
|
+
if (!item || typeof item !== "object") continue;
|
|
440
|
+
const record = item as Record<string, unknown>;
|
|
441
|
+
if (record.type === "message") skip(record.id);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
drain();
|
|
446
|
+
},
|
|
447
|
+
reset() {
|
|
448
|
+
order.length = 0;
|
|
449
|
+
items.clear();
|
|
450
|
+
settledItemIds.clear();
|
|
451
|
+
receivedTranscriptIds.clear();
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function createRealtimeVoiceGreetingEvent(): Record<string, unknown> {
|
|
457
|
+
return {
|
|
458
|
+
type: "response.create",
|
|
459
|
+
response: {
|
|
460
|
+
output_modalities: ["audio"],
|
|
461
|
+
instructions:
|
|
462
|
+
'Say exactly: "How can I help you?" Do not add anything else.',
|
|
463
|
+
// Do not set max_output_tokens here. Realtime counts assistant audio at
|
|
464
|
+
// roughly one token per 50 ms, so a text-sized cap can cut speech off.
|
|
465
|
+
// The exact-response instruction already keeps this greeting bounded.
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function createRealtimeVoiceGreetingStarter(
|
|
471
|
+
send: (event: Record<string, unknown>) => void,
|
|
472
|
+
): { start: () => boolean; reset: () => void } {
|
|
473
|
+
let started = false;
|
|
474
|
+
return {
|
|
475
|
+
start() {
|
|
476
|
+
if (started) return false;
|
|
477
|
+
started = true;
|
|
478
|
+
send(createRealtimeVoiceGreetingEvent());
|
|
479
|
+
return true;
|
|
480
|
+
},
|
|
481
|
+
reset() {
|
|
482
|
+
started = false;
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Voice mode owns the chat only temporarily. Restore the captured transcript
|
|
489
|
+
* when it is still the user's active thread (or the chat has no active thread)
|
|
490
|
+
* but never pull them back after they deliberately selected another thread.
|
|
491
|
+
*/
|
|
492
|
+
export function shouldRestoreRealtimeVoiceTranscriptThread(
|
|
493
|
+
transcriptThreadId: string | undefined,
|
|
494
|
+
activeThreadId: string | undefined,
|
|
495
|
+
): transcriptThreadId is string {
|
|
496
|
+
return Boolean(
|
|
497
|
+
transcriptThreadId &&
|
|
498
|
+
(!activeThreadId || activeThreadId === transcriptThreadId),
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export function extractCompletedRealtimeVoiceTranscript(
|
|
503
|
+
event: RealtimeServerEvent,
|
|
504
|
+
): CompletedRealtimeVoiceTranscript | null {
|
|
505
|
+
const userCompleted =
|
|
506
|
+
event.type === "conversation.item.input_audio_transcription.completed";
|
|
507
|
+
const assistantCompleted =
|
|
508
|
+
event.type === "response.output_audio_transcript.done";
|
|
509
|
+
if (!userCompleted && !assistantCompleted) return null;
|
|
510
|
+
const text =
|
|
511
|
+
typeof event.transcript === "string" ? event.transcript.trim() : "";
|
|
512
|
+
if (!text) return null;
|
|
513
|
+
const providerId = [event.item_id, event.response_id, event.event_id].find(
|
|
514
|
+
(value): value is string => typeof value === "string" && value.length > 0,
|
|
515
|
+
);
|
|
516
|
+
return {
|
|
517
|
+
role: userCompleted ? "user" : "assistant",
|
|
518
|
+
text,
|
|
519
|
+
...(providerId ? { providerId } : {}),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export interface RealtimeVoiceModeProviderProps {
|
|
524
|
+
children: ReactNode;
|
|
525
|
+
browserTabId?: string;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function errorMessage(error: unknown): string {
|
|
529
|
+
return error instanceof Error ? error.message : String(error);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export function isRealtimeVoiceAbortError(error: unknown): boolean {
|
|
533
|
+
return Boolean(
|
|
534
|
+
error &&
|
|
535
|
+
typeof error === "object" &&
|
|
536
|
+
(error as { name?: unknown }).name === "AbortError",
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export function isRealtimeVoiceSetupRequiredError(error: unknown): boolean {
|
|
541
|
+
return Boolean(
|
|
542
|
+
error &&
|
|
543
|
+
typeof error === "object" &&
|
|
544
|
+
(error as { status?: unknown }).status === 409,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export function createRealtimeVoiceConnectionTimeout(
|
|
549
|
+
onTimeout: () => void,
|
|
550
|
+
timeoutMs = REALTIME_VOICE_CONNECTION_TIMEOUT_MS,
|
|
551
|
+
): () => void {
|
|
552
|
+
const timeout = window.setTimeout(onTimeout, timeoutMs);
|
|
553
|
+
return () => window.clearTimeout(timeout);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function createRealtimeVoiceConnectionGate(
|
|
557
|
+
onTimeout: () => void,
|
|
558
|
+
timeoutMs = REALTIME_VOICE_CONNECTION_TIMEOUT_MS,
|
|
559
|
+
): {
|
|
560
|
+
markTransportReady: () => void;
|
|
561
|
+
markSessionCreated: () => void;
|
|
562
|
+
cancel: () => void;
|
|
563
|
+
} {
|
|
564
|
+
const cancel = createRealtimeVoiceConnectionTimeout(onTimeout, timeoutMs);
|
|
565
|
+
return {
|
|
566
|
+
// RTC connectivity alone is not enough: the session may never finish its
|
|
567
|
+
// Realtime handshake. Keep the deadline armed until session.created.
|
|
568
|
+
markTransportReady() {},
|
|
569
|
+
markSessionCreated: cancel,
|
|
570
|
+
cancel,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function readErrorResponse(response: Response): Promise<string> {
|
|
575
|
+
const raw = await response.text().catch(() => "");
|
|
576
|
+
if (!raw) return response.statusText || `HTTP ${response.status}`;
|
|
577
|
+
try {
|
|
578
|
+
const parsed = JSON.parse(raw) as { error?: unknown; message?: unknown };
|
|
579
|
+
return String(parsed.error ?? parsed.message ?? raw);
|
|
580
|
+
} catch {
|
|
581
|
+
return raw.slice(0, 500);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
interface RealtimeVoiceSessionOptions {
|
|
586
|
+
browserTabId?: string;
|
|
587
|
+
signal?: AbortSignal;
|
|
588
|
+
preferences?: RealtimeVoicePreferences;
|
|
589
|
+
browserLanguages?: readonly string[];
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export async function createRealtimeVoiceSessionWithCapability(
|
|
593
|
+
offerSdp: string,
|
|
594
|
+
options: RealtimeVoiceSessionOptions = {},
|
|
595
|
+
): Promise<RealtimeVoiceSessionAnswer> {
|
|
596
|
+
const preferences = options.preferences;
|
|
597
|
+
const response = await fetch(REALTIME_VOICE_SESSION_PATH, {
|
|
598
|
+
method: "POST",
|
|
599
|
+
credentials: "same-origin",
|
|
600
|
+
headers: {
|
|
601
|
+
"Content-Type": "application/sdp",
|
|
602
|
+
...(options.browserTabId
|
|
603
|
+
? { "X-Agent-Native-Browser-Tab": options.browserTabId }
|
|
604
|
+
: {}),
|
|
605
|
+
...(preferences
|
|
606
|
+
? {
|
|
607
|
+
"X-Agent-Native-Realtime-Language": resolveRealtimeVoiceLanguage(
|
|
608
|
+
preferences.language,
|
|
609
|
+
options.browserLanguages,
|
|
610
|
+
),
|
|
611
|
+
"X-Agent-Native-Realtime-Intelligence": preferences.intelligence,
|
|
612
|
+
"X-Agent-Native-Realtime-Voice": preferences.voice,
|
|
613
|
+
}
|
|
614
|
+
: {}),
|
|
615
|
+
},
|
|
616
|
+
body: offerSdp,
|
|
617
|
+
signal: options.signal,
|
|
618
|
+
});
|
|
619
|
+
if (!response.ok) {
|
|
620
|
+
const message = await readErrorResponse(response);
|
|
621
|
+
const error = new Error(message);
|
|
622
|
+
(error as { status?: number }).status = response.status;
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
const sdp = await response.text();
|
|
626
|
+
const capability = response.headers.get(REALTIME_VOICE_CAPABILITY_HEADER);
|
|
627
|
+
return {
|
|
628
|
+
sdp,
|
|
629
|
+
...(capability ? { capability } : {}),
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Backwards-compatible SDP-only session helper for existing client callers. */
|
|
634
|
+
export async function createRealtimeVoiceSession(
|
|
635
|
+
offerSdp: string,
|
|
636
|
+
options: RealtimeVoiceSessionOptions = {},
|
|
637
|
+
): Promise<string> {
|
|
638
|
+
return (await createRealtimeVoiceSessionWithCapability(offerSdp, options))
|
|
639
|
+
.sdp;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export async function executeRealtimeVoiceTool(input: {
|
|
643
|
+
name: string;
|
|
644
|
+
args: Record<string, unknown>;
|
|
645
|
+
callId: string;
|
|
646
|
+
sessionId?: string;
|
|
647
|
+
browserTabId?: string;
|
|
648
|
+
capability?: string;
|
|
649
|
+
signal?: AbortSignal;
|
|
650
|
+
}): Promise<RealtimeVoiceToolResult> {
|
|
651
|
+
const response = await fetch(REALTIME_VOICE_TOOL_PATH, {
|
|
652
|
+
method: "POST",
|
|
653
|
+
credentials: "same-origin",
|
|
654
|
+
headers: {
|
|
655
|
+
"Content-Type": "application/json",
|
|
656
|
+
...(input.browserTabId
|
|
657
|
+
? { "X-Agent-Native-Browser-Tab": input.browserTabId }
|
|
658
|
+
: {}),
|
|
659
|
+
...(input.capability
|
|
660
|
+
? { [REALTIME_VOICE_CAPABILITY_HEADER]: input.capability }
|
|
661
|
+
: {}),
|
|
662
|
+
},
|
|
663
|
+
body: JSON.stringify({
|
|
664
|
+
name: input.name,
|
|
665
|
+
args: input.args,
|
|
666
|
+
callId: input.callId,
|
|
667
|
+
sessionId: input.sessionId,
|
|
668
|
+
browserTabId: input.browserTabId,
|
|
669
|
+
}),
|
|
670
|
+
signal: input.signal,
|
|
671
|
+
});
|
|
672
|
+
if (!response.ok) {
|
|
673
|
+
throw new Error(await readErrorResponse(response));
|
|
674
|
+
}
|
|
675
|
+
return (await response.json()) as RealtimeVoiceToolResult;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function jsonByteLength(value: unknown): number {
|
|
679
|
+
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function normalizeRealtimeVoiceFunctionTool(
|
|
683
|
+
value: unknown,
|
|
684
|
+
): RealtimeVoiceFunctionTool | null {
|
|
685
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
686
|
+
const record = value as Record<string, unknown>;
|
|
687
|
+
if (
|
|
688
|
+
record.type !== "function" ||
|
|
689
|
+
typeof record.name !== "string" ||
|
|
690
|
+
!REALTIME_VOICE_TOOL_NAME.test(record.name)
|
|
691
|
+
) {
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
if (
|
|
695
|
+
record.description !== undefined &&
|
|
696
|
+
typeof record.description !== "string"
|
|
697
|
+
) {
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
if (
|
|
701
|
+
record.parameters !== undefined &&
|
|
702
|
+
(!record.parameters ||
|
|
703
|
+
typeof record.parameters !== "object" ||
|
|
704
|
+
Array.isArray(record.parameters))
|
|
705
|
+
) {
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
if (jsonByteLength(record) > REALTIME_VOICE_MAX_TOOL_SCHEMA_BYTES) {
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
return JSON.parse(JSON.stringify(record)) as RealtimeVoiceFunctionTool;
|
|
713
|
+
} catch {
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export function extractRealtimeVoiceSessionTools(
|
|
719
|
+
event: RealtimeServerEvent,
|
|
720
|
+
): RealtimeVoiceFunctionTool[] | null {
|
|
721
|
+
if (event.type !== "session.created" && event.type !== "session.updated") {
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
const session = event.session;
|
|
725
|
+
if (!session || typeof session !== "object") return null;
|
|
726
|
+
const tools = (session as { tools?: unknown }).tools;
|
|
727
|
+
if (!Array.isArray(tools)) return null;
|
|
728
|
+
return tools
|
|
729
|
+
.map(normalizeRealtimeVoiceFunctionTool)
|
|
730
|
+
.filter((tool): tool is RealtimeVoiceFunctionTool => Boolean(tool));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function createRealtimeVoiceToolManifestUpdate(
|
|
734
|
+
tools: RealtimeVoiceFunctionTool[],
|
|
735
|
+
eventId?: string,
|
|
736
|
+
): Record<string, unknown> {
|
|
737
|
+
return {
|
|
738
|
+
type: "session.update",
|
|
739
|
+
...(eventId ? { event_id: eventId } : {}),
|
|
740
|
+
session: {
|
|
741
|
+
type: "realtime",
|
|
742
|
+
tools,
|
|
743
|
+
tool_choice: "auto",
|
|
744
|
+
},
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Merge search-discovered schemas into the live manifest. Pinned navigation
|
|
750
|
+
* and discovery tools stay available, then the newest discoveries take the
|
|
751
|
+
* remaining slots ahead of lower-priority tools from the original manifest.
|
|
752
|
+
*/
|
|
753
|
+
export function mergeRealtimeVoiceToolManifest(
|
|
754
|
+
currentTools: readonly RealtimeVoiceFunctionTool[],
|
|
755
|
+
expandedTools: readonly RealtimeVoiceFunctionTool[],
|
|
756
|
+
): RealtimeVoiceFunctionTool[] {
|
|
757
|
+
const current = new Map<string, RealtimeVoiceFunctionTool>();
|
|
758
|
+
for (const value of currentTools) {
|
|
759
|
+
const tool = normalizeRealtimeVoiceFunctionTool(value);
|
|
760
|
+
if (tool) current.set(tool.name, tool);
|
|
761
|
+
}
|
|
762
|
+
const expanded = new Map<string, RealtimeVoiceFunctionTool>();
|
|
763
|
+
for (const value of expandedTools) {
|
|
764
|
+
const tool = normalizeRealtimeVoiceFunctionTool(value);
|
|
765
|
+
if (tool) expanded.set(tool.name, tool);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const candidates: RealtimeVoiceFunctionTool[] = [];
|
|
769
|
+
const seen = new Set<string>();
|
|
770
|
+
const add = (tool: RealtimeVoiceFunctionTool | undefined) => {
|
|
771
|
+
if (!tool || seen.has(tool.name)) return;
|
|
772
|
+
seen.add(tool.name);
|
|
773
|
+
candidates.push(tool);
|
|
774
|
+
};
|
|
775
|
+
for (const name of REALTIME_VOICE_PRIORITY_TOOL_NAMES) {
|
|
776
|
+
add(expanded.get(name) ?? current.get(name));
|
|
777
|
+
}
|
|
778
|
+
for (const tool of expanded.values()) add(tool);
|
|
779
|
+
for (const tool of current.values()) add(tool);
|
|
780
|
+
|
|
781
|
+
const packed: RealtimeVoiceFunctionTool[] = [];
|
|
782
|
+
for (const tool of candidates) {
|
|
783
|
+
if (packed.length >= REALTIME_VOICE_MAX_TOOLS) break;
|
|
784
|
+
const candidate = [...packed, tool];
|
|
785
|
+
if (
|
|
786
|
+
jsonByteLength(
|
|
787
|
+
createRealtimeVoiceToolManifestUpdate(
|
|
788
|
+
candidate,
|
|
789
|
+
"realtime_tool_manifest_999999999",
|
|
790
|
+
),
|
|
791
|
+
) <= REALTIME_VOICE_MAX_SESSION_BYTES
|
|
792
|
+
) {
|
|
793
|
+
packed.push(tool);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return packed;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function createRealtimeVoiceToolResultEvent(
|
|
800
|
+
result: RealtimeVoiceToolResult,
|
|
801
|
+
): Record<string, unknown> {
|
|
802
|
+
const modelResult = {
|
|
803
|
+
callId: result.callId,
|
|
804
|
+
status: result.status,
|
|
805
|
+
output: result.output,
|
|
806
|
+
...(result.approvalKey ? { approvalKey: result.approvalKey } : {}),
|
|
807
|
+
};
|
|
808
|
+
return {
|
|
809
|
+
type: "conversation.item.create",
|
|
810
|
+
item: {
|
|
811
|
+
type: "function_call_output",
|
|
812
|
+
call_id: result.callId,
|
|
813
|
+
output: JSON.stringify(modelResult),
|
|
814
|
+
},
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export interface RealtimeVoiceToolManifestCoordinator {
|
|
819
|
+
enqueue: (result: RealtimeVoiceToolResult) => void;
|
|
820
|
+
setSessionTools: (tools: readonly RealtimeVoiceFunctionTool[]) => void;
|
|
821
|
+
handleError: (eventId: string | undefined, message?: string) => boolean;
|
|
822
|
+
reset: () => void;
|
|
823
|
+
getTools: () => readonly RealtimeVoiceFunctionTool[];
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Realtime treats `session.update.tools` as a full replacement. Serialize
|
|
828
|
+
* discovery updates and wait for a confirming `session.updated` manifest
|
|
829
|
+
* before returning the search result and allowing the next model response.
|
|
830
|
+
*/
|
|
831
|
+
export function createRealtimeVoiceToolManifestCoordinator(
|
|
832
|
+
send: (event: Record<string, unknown>) => void,
|
|
833
|
+
timeoutMs = REALTIME_VOICE_TOOL_UPDATE_TIMEOUT_MS,
|
|
834
|
+
): RealtimeVoiceToolManifestCoordinator {
|
|
835
|
+
let currentTools: RealtimeVoiceFunctionTool[] = [];
|
|
836
|
+
const queue: RealtimeVoiceToolResult[] = [];
|
|
837
|
+
let updateSequence = 0;
|
|
838
|
+
let active:
|
|
839
|
+
| {
|
|
840
|
+
result: RealtimeVoiceToolResult;
|
|
841
|
+
expectedNames: string[];
|
|
842
|
+
eventId: string;
|
|
843
|
+
timer: ReturnType<typeof setTimeout>;
|
|
844
|
+
}
|
|
845
|
+
| undefined;
|
|
846
|
+
|
|
847
|
+
const finish = (failureMessage?: string) => {
|
|
848
|
+
if (!active) return;
|
|
849
|
+
const { result, timer } = active;
|
|
850
|
+
clearTimeout(timer);
|
|
851
|
+
active = undefined;
|
|
852
|
+
send(
|
|
853
|
+
createRealtimeVoiceToolResultEvent(
|
|
854
|
+
failureMessage
|
|
855
|
+
? {
|
|
856
|
+
callId: result.callId,
|
|
857
|
+
status: "failed",
|
|
858
|
+
output: `${failureMessage} The discovered tools were not added to this voice session. Original tool result: ${result.output}`,
|
|
859
|
+
}
|
|
860
|
+
: result,
|
|
861
|
+
),
|
|
862
|
+
);
|
|
863
|
+
send({ type: "response.create" });
|
|
864
|
+
drain();
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
const drain = () => {
|
|
868
|
+
if (active) return;
|
|
869
|
+
const result = queue.shift();
|
|
870
|
+
if (!result) return;
|
|
871
|
+
const expanded = Array.isArray(result.expandedTools)
|
|
872
|
+
? result.expandedTools
|
|
873
|
+
.map(normalizeRealtimeVoiceFunctionTool)
|
|
874
|
+
.filter((tool): tool is RealtimeVoiceFunctionTool => Boolean(tool))
|
|
875
|
+
: [];
|
|
876
|
+
if (result.status !== "completed" || expanded.length === 0) {
|
|
877
|
+
send(createRealtimeVoiceToolResultEvent(result));
|
|
878
|
+
send({ type: "response.create" });
|
|
879
|
+
drain();
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const merged = mergeRealtimeVoiceToolManifest(currentTools, expanded);
|
|
884
|
+
const mergedNames = new Set(merged.map((tool) => tool.name));
|
|
885
|
+
const expectedNames = expanded
|
|
886
|
+
.map((tool) => tool.name)
|
|
887
|
+
.filter((name) => mergedNames.has(name));
|
|
888
|
+
if (expectedNames.length === 0) {
|
|
889
|
+
send(createRealtimeVoiceToolResultEvent(result));
|
|
890
|
+
send({ type: "response.create" });
|
|
891
|
+
drain();
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const eventId = `realtime_tool_manifest_${++updateSequence}`;
|
|
896
|
+
active = {
|
|
897
|
+
result,
|
|
898
|
+
expectedNames,
|
|
899
|
+
eventId,
|
|
900
|
+
timer: setTimeout(
|
|
901
|
+
() => finish("Timed out while installing the discovered tools."),
|
|
902
|
+
timeoutMs,
|
|
903
|
+
),
|
|
904
|
+
};
|
|
905
|
+
send(createRealtimeVoiceToolManifestUpdate(merged, eventId));
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
return {
|
|
909
|
+
enqueue(result) {
|
|
910
|
+
queue.push(result);
|
|
911
|
+
drain();
|
|
912
|
+
},
|
|
913
|
+
setSessionTools(tools) {
|
|
914
|
+
currentTools = mergeRealtimeVoiceToolManifest([], tools);
|
|
915
|
+
if (!active) return;
|
|
916
|
+
const names = new Set(currentTools.map((tool) => tool.name));
|
|
917
|
+
if (active.expectedNames.every((name) => names.has(name))) finish();
|
|
918
|
+
},
|
|
919
|
+
handleError(eventId, message) {
|
|
920
|
+
if (!active || !eventId || active.eventId !== eventId) return false;
|
|
921
|
+
finish(message || "The provider rejected the discovered tool update.");
|
|
922
|
+
return true;
|
|
923
|
+
},
|
|
924
|
+
reset() {
|
|
925
|
+
if (active) clearTimeout(active.timer);
|
|
926
|
+
active = undefined;
|
|
927
|
+
queue.length = 0;
|
|
928
|
+
currentTools = [];
|
|
929
|
+
},
|
|
930
|
+
getTools() {
|
|
931
|
+
return currentTools;
|
|
932
|
+
},
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
export function extractRealtimeVoiceFunctionCalls(
|
|
937
|
+
event: RealtimeServerEvent,
|
|
938
|
+
): Array<{ name: string; callId: string; argumentsText: string }> {
|
|
939
|
+
if (event.type === "response.function_call_arguments.done") {
|
|
940
|
+
const name = typeof event.name === "string" ? event.name : "";
|
|
941
|
+
const callId = typeof event.call_id === "string" ? event.call_id : "";
|
|
942
|
+
if (!name || !callId) return [];
|
|
943
|
+
return [
|
|
944
|
+
{
|
|
945
|
+
name,
|
|
946
|
+
callId,
|
|
947
|
+
argumentsText:
|
|
948
|
+
typeof event.arguments === "string" ? event.arguments : "{}",
|
|
949
|
+
},
|
|
950
|
+
];
|
|
951
|
+
}
|
|
952
|
+
if (event.type !== "response.done") return [];
|
|
953
|
+
const response = event.response;
|
|
954
|
+
if (!response || typeof response !== "object") return [];
|
|
955
|
+
const output = (response as { output?: unknown }).output;
|
|
956
|
+
if (!Array.isArray(output)) return [];
|
|
957
|
+
return output.flatMap((item) => {
|
|
958
|
+
if (!item || typeof item !== "object") return [];
|
|
959
|
+
const record = item as Record<string, unknown>;
|
|
960
|
+
if (record.type !== "function_call") return [];
|
|
961
|
+
const name = typeof record.name === "string" ? record.name : "";
|
|
962
|
+
const callId = typeof record.call_id === "string" ? record.call_id : "";
|
|
963
|
+
if (!name || !callId) return [];
|
|
964
|
+
return [
|
|
965
|
+
{
|
|
966
|
+
name,
|
|
967
|
+
callId,
|
|
968
|
+
argumentsText:
|
|
969
|
+
typeof record.arguments === "string" ? record.arguments : "{}",
|
|
970
|
+
},
|
|
971
|
+
];
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function parseFunctionArguments(text: string): Record<string, unknown> {
|
|
976
|
+
const parsed = JSON.parse(text || "{}");
|
|
977
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
978
|
+
throw new Error("The voice model returned invalid tool arguments.");
|
|
979
|
+
}
|
|
980
|
+
return parsed as Record<string, unknown>;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function sendDataChannelEvent(
|
|
984
|
+
channel: RTCDataChannel | null,
|
|
985
|
+
event: Record<string, unknown>,
|
|
986
|
+
): void {
|
|
987
|
+
if (!channel || channel.readyState !== "open") return;
|
|
988
|
+
channel.send(JSON.stringify(event));
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function openOpenAiKeySettings(): void {
|
|
992
|
+
if (typeof window === "undefined") return;
|
|
993
|
+
window.location.hash = "#secrets:OPENAI_API_KEY";
|
|
994
|
+
window.dispatchEvent(new Event("agent-panel:open"));
|
|
995
|
+
window.dispatchEvent(
|
|
996
|
+
new CustomEvent("agent-panel:open-settings", {
|
|
997
|
+
detail: { section: "secrets" },
|
|
998
|
+
}),
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
export function listenForRealtimeVoicePageHide(
|
|
1003
|
+
cleanup: () => void,
|
|
1004
|
+
): () => void {
|
|
1005
|
+
window.addEventListener("pagehide", cleanup);
|
|
1006
|
+
return () => window.removeEventListener("pagehide", cleanup);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function voiceCopy(t: (key: string) => string): RealtimeVoiceModeCopy {
|
|
1010
|
+
return {
|
|
1011
|
+
entryButtonLabel: t("agentPanel.voiceMode.entryButtonLabel"),
|
|
1012
|
+
promptTitle: t("agentPanel.voiceMode.promptTitle"),
|
|
1013
|
+
promptDescription: t("agentPanel.voiceMode.promptDescription"),
|
|
1014
|
+
setupTitle: t("agentPanel.voiceMode.setupTitle"),
|
|
1015
|
+
setupDescription: t("agentPanel.voiceMode.setupDescription"),
|
|
1016
|
+
connectBuilder: t("agentPanel.voiceMode.connectBuilder"),
|
|
1017
|
+
useOpenAiKey: t("agentPanel.voiceMode.useOpenAiKey"),
|
|
1018
|
+
startWithOpenAiKey: t("agentPanel.voiceMode.startWithOpenAiKey"),
|
|
1019
|
+
startVoiceMode: t("agentPanel.voiceMode.start"),
|
|
1020
|
+
keepDictating: t("agentPanel.voiceMode.keepDictating"),
|
|
1021
|
+
showChat: t("agentPanel.voiceMode.showChat"),
|
|
1022
|
+
hideChat: t("agentPanel.voiceMode.hideChat"),
|
|
1023
|
+
endVoiceMode: t("agentPanel.voiceMode.end"),
|
|
1024
|
+
voiceSettings: t("agentPanel.voiceMode.voiceSettings"),
|
|
1025
|
+
settings: {
|
|
1026
|
+
language: t("agentPanel.voiceMode.settings.language"),
|
|
1027
|
+
autoLanguage: t("agentPanel.voiceMode.settings.autoLanguage"),
|
|
1028
|
+
languages: {
|
|
1029
|
+
en: t("agentPanel.voiceMode.settings.languages.en"),
|
|
1030
|
+
es: t("agentPanel.voiceMode.settings.languages.es"),
|
|
1031
|
+
fr: t("agentPanel.voiceMode.settings.languages.fr"),
|
|
1032
|
+
de: t("agentPanel.voiceMode.settings.languages.de"),
|
|
1033
|
+
it: t("agentPanel.voiceMode.settings.languages.it"),
|
|
1034
|
+
pt: t("agentPanel.voiceMode.settings.languages.pt"),
|
|
1035
|
+
ja: t("agentPanel.voiceMode.settings.languages.ja"),
|
|
1036
|
+
ko: t("agentPanel.voiceMode.settings.languages.ko"),
|
|
1037
|
+
zh: t("agentPanel.voiceMode.settings.languages.zh"),
|
|
1038
|
+
},
|
|
1039
|
+
intelligence: t("agentPanel.voiceMode.settings.intelligence"),
|
|
1040
|
+
intelligenceLevels: {
|
|
1041
|
+
instant: t("agentPanel.voiceMode.settings.intelligenceLevels.instant"),
|
|
1042
|
+
balanced: t(
|
|
1043
|
+
"agentPanel.voiceMode.settings.intelligenceLevels.balanced",
|
|
1044
|
+
),
|
|
1045
|
+
deep: t("agentPanel.voiceMode.settings.intelligenceLevels.deep"),
|
|
1046
|
+
},
|
|
1047
|
+
voiceStyle: t("agentPanel.voiceMode.settings.voiceStyle"),
|
|
1048
|
+
microphone: t("agentPanel.voiceMode.settings.microphone"),
|
|
1049
|
+
defaultMicrophone: t("agentPanel.voiceMode.settings.defaultMicrophone"),
|
|
1050
|
+
microphoneSwitchFailed: t(
|
|
1051
|
+
"agentPanel.voiceMode.settings.microphoneSwitchFailed",
|
|
1052
|
+
),
|
|
1053
|
+
voiceChangePending: t("agentPanel.voiceMode.settings.voiceChangePending"),
|
|
1054
|
+
voiceDescriptions: {
|
|
1055
|
+
marin: t("agentPanel.voiceMode.settings.voiceDescriptions.marin"),
|
|
1056
|
+
cedar: t("agentPanel.voiceMode.settings.voiceDescriptions.cedar"),
|
|
1057
|
+
coral: t("agentPanel.voiceMode.settings.voiceDescriptions.coral"),
|
|
1058
|
+
sage: t("agentPanel.voiceMode.settings.voiceDescriptions.sage"),
|
|
1059
|
+
verse: t("agentPanel.voiceMode.settings.voiceDescriptions.verse"),
|
|
1060
|
+
alloy: t("agentPanel.voiceMode.settings.voiceDescriptions.alloy"),
|
|
1061
|
+
ash: t("agentPanel.voiceMode.settings.voiceDescriptions.ash"),
|
|
1062
|
+
ballad: t("agentPanel.voiceMode.settings.voiceDescriptions.ballad"),
|
|
1063
|
+
echo: t("agentPanel.voiceMode.settings.voiceDescriptions.echo"),
|
|
1064
|
+
shimmer: t("agentPanel.voiceMode.settings.voiceDescriptions.shimmer"),
|
|
1065
|
+
},
|
|
1066
|
+
},
|
|
1067
|
+
status: {
|
|
1068
|
+
connecting: t("agentPanel.voiceMode.status.connecting"),
|
|
1069
|
+
listening: t("agentPanel.voiceMode.status.listening"),
|
|
1070
|
+
speaking: t("agentPanel.voiceMode.status.speaking"),
|
|
1071
|
+
working: t("agentPanel.voiceMode.status.working"),
|
|
1072
|
+
error: t("agentPanel.voiceMode.status.error"),
|
|
1073
|
+
ending: t("agentPanel.voiceMode.status.ending"),
|
|
1074
|
+
},
|
|
1075
|
+
errors: {
|
|
1076
|
+
unsupported: t("agentPanel.voiceMode.errors.unsupported"),
|
|
1077
|
+
responseFailed: t("agentPanel.voiceMode.errors.responseFailed"),
|
|
1078
|
+
sessionFailed: t("agentPanel.voiceMode.errors.sessionFailed"),
|
|
1079
|
+
channelDisconnected: t("agentPanel.voiceMode.errors.channelDisconnected"),
|
|
1080
|
+
connectionTimedOut: t("agentPanel.voiceMode.errors.connectionTimedOut"),
|
|
1081
|
+
connectionFailed: t("agentPanel.voiceMode.errors.connectionFailed"),
|
|
1082
|
+
offerFailed: t("agentPanel.voiceMode.errors.offerFailed"),
|
|
1083
|
+
},
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
export function useRealtimeVoiceModeCopy(): RealtimeVoiceModeCopy {
|
|
1088
|
+
const { translate: t } = useComposerRuntimeAdapters();
|
|
1089
|
+
return useMemo(() => voiceCopy(t!), [t]);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function useRealtimeVoiceModeController(
|
|
1093
|
+
browserTabId?: string,
|
|
1094
|
+
copy?: RealtimeVoiceModeCopy,
|
|
1095
|
+
): RealtimeVoiceModeApi {
|
|
1096
|
+
const adapters = useComposerRuntimeAdapters();
|
|
1097
|
+
const [state, setState] = useState<"idle" | RealtimeVoiceModeState>("idle");
|
|
1098
|
+
const [error, setError] = useState<string | null>(null);
|
|
1099
|
+
const [chatVisible, setChatVisible] = useState(false);
|
|
1100
|
+
const [preferences, setPreferences] = useState(
|
|
1101
|
+
DEFAULT_REALTIME_VOICE_PREFERENCES,
|
|
1102
|
+
);
|
|
1103
|
+
const [voiceChangePending, setVoiceChangePending] = useState(false);
|
|
1104
|
+
const [microphones, setMicrophones] = useState<RealtimeVoiceMicrophone[]>([]);
|
|
1105
|
+
const [microphoneDeviceId, setMicrophoneDeviceId] = useState(
|
|
1106
|
+
readRealtimeVoiceMicrophoneId,
|
|
1107
|
+
);
|
|
1108
|
+
const [microphoneSwitching, setMicrophoneSwitching] = useState(false);
|
|
1109
|
+
const [microphoneError, setMicrophoneError] = useState<string | null>(null);
|
|
1110
|
+
const [audioLevels] = useState(createRealtimeVoiceAudioLevelStore);
|
|
1111
|
+
const stateRef = useRef(state);
|
|
1112
|
+
const preferencesRef = useRef(preferences);
|
|
1113
|
+
const hasOutputAudioRef = useRef(false);
|
|
1114
|
+
const peerRef = useRef<RTCPeerConnection | null>(null);
|
|
1115
|
+
const channelRef = useRef<RTCDataChannel | null>(null);
|
|
1116
|
+
const streamRef = useRef<MediaStream | null>(null);
|
|
1117
|
+
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
1118
|
+
const audioContextRef = useRef<AudioContext | null>(null);
|
|
1119
|
+
const inputAnalyserRef = useRef<AnalyserNode | null>(null);
|
|
1120
|
+
const outputAnalyserRef = useRef<AnalyserNode | null>(null);
|
|
1121
|
+
const inputMeterBufferRef = useRef<Uint8Array<ArrayBuffer> | null>(null);
|
|
1122
|
+
const outputMeterBufferRef = useRef<Uint8Array<ArrayBuffer> | null>(null);
|
|
1123
|
+
const inputSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
|
1124
|
+
const outputSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
|
1125
|
+
const meterFrameRef = useRef<number | null>(null);
|
|
1126
|
+
const lastMeterSampleRef = useRef(0);
|
|
1127
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
1128
|
+
const connectionGateRef = useRef<ReturnType<
|
|
1129
|
+
typeof createRealtimeVoiceConnectionGate
|
|
1130
|
+
> | null>(null);
|
|
1131
|
+
const handledCallsRef = useRef(new Set<string>());
|
|
1132
|
+
const sessionIdRef = useRef<string | undefined>(undefined);
|
|
1133
|
+
const capabilityRef = useRef<string | undefined>(undefined);
|
|
1134
|
+
const transportGenerationRef = useRef(0);
|
|
1135
|
+
const startedAtRef = useRef<string | undefined>(undefined);
|
|
1136
|
+
const lastUserTextRef = useRef("");
|
|
1137
|
+
const lastAssistantTextRef = useRef("");
|
|
1138
|
+
const transcriptThreadIdRef = useRef<string | undefined>(undefined);
|
|
1139
|
+
const transcriptSequenceRef = useRef(0);
|
|
1140
|
+
const preferencesHydratedRef = useRef(false);
|
|
1141
|
+
const preferencesEditedRef = useRef(false);
|
|
1142
|
+
const preferenceWriteChainRef = useRef<Promise<void>>(Promise.resolve());
|
|
1143
|
+
const toolManifestCoordinator = useMemo(
|
|
1144
|
+
() =>
|
|
1145
|
+
createRealtimeVoiceToolManifestCoordinator((event) => {
|
|
1146
|
+
sendDataChannelEvent(channelRef.current, event);
|
|
1147
|
+
}),
|
|
1148
|
+
[],
|
|
1149
|
+
);
|
|
1150
|
+
const switchMicrophoneRef = useRef<
|
|
1151
|
+
(deviceId: string, force?: boolean) => Promise<void>
|
|
1152
|
+
>(async () => undefined);
|
|
1153
|
+
|
|
1154
|
+
useEffect(() => {
|
|
1155
|
+
stateRef.current = state;
|
|
1156
|
+
}, [state]);
|
|
1157
|
+
|
|
1158
|
+
useEffect(() => {
|
|
1159
|
+
preferencesRef.current = preferences;
|
|
1160
|
+
}, [preferences]);
|
|
1161
|
+
|
|
1162
|
+
const hydratePreferences = useCallback(async () => {
|
|
1163
|
+
if (preferencesHydratedRef.current) return;
|
|
1164
|
+
try {
|
|
1165
|
+
const stored = await adapters.voice!.readAppState!(
|
|
1166
|
+
REALTIME_VOICE_PREFERENCES_KEY,
|
|
1167
|
+
);
|
|
1168
|
+
if (!preferencesEditedRef.current && stored != null) {
|
|
1169
|
+
const next = normalizeRealtimeVoicePreferences(stored);
|
|
1170
|
+
preferencesRef.current = next;
|
|
1171
|
+
setPreferences(next);
|
|
1172
|
+
}
|
|
1173
|
+
} catch {
|
|
1174
|
+
// Preferences are optional; keep safe defaults when storage is unavailable.
|
|
1175
|
+
} finally {
|
|
1176
|
+
preferencesHydratedRef.current = true;
|
|
1177
|
+
}
|
|
1178
|
+
}, [adapters]);
|
|
1179
|
+
|
|
1180
|
+
const syncAppState = useCallback(
|
|
1181
|
+
(nextState: "idle" | RealtimeVoiceModeState) => {
|
|
1182
|
+
const value =
|
|
1183
|
+
nextState === "idle"
|
|
1184
|
+
? null
|
|
1185
|
+
: {
|
|
1186
|
+
active: true,
|
|
1187
|
+
status: nextState,
|
|
1188
|
+
model: "gpt-realtime-2.1",
|
|
1189
|
+
startedAt: startedAtRef.current,
|
|
1190
|
+
sessionId: sessionIdRef.current,
|
|
1191
|
+
browserTabId,
|
|
1192
|
+
preferences: preferencesRef.current,
|
|
1193
|
+
lastUserText: lastUserTextRef.current || undefined,
|
|
1194
|
+
lastAssistantText: lastAssistantTextRef.current || undefined,
|
|
1195
|
+
};
|
|
1196
|
+
void Promise.resolve(
|
|
1197
|
+
adapters.voice!.setAppState!(REALTIME_VOICE_STATE_KEY, value),
|
|
1198
|
+
).catch(() => undefined);
|
|
1199
|
+
},
|
|
1200
|
+
[adapters, browserTabId],
|
|
1201
|
+
);
|
|
1202
|
+
|
|
1203
|
+
const transition = useCallback(
|
|
1204
|
+
(nextState: "idle" | RealtimeVoiceModeState) => {
|
|
1205
|
+
stateRef.current = nextState;
|
|
1206
|
+
setState(nextState);
|
|
1207
|
+
syncAppState(nextState);
|
|
1208
|
+
},
|
|
1209
|
+
[syncAppState],
|
|
1210
|
+
);
|
|
1211
|
+
|
|
1212
|
+
const savePreferences = useCallback(
|
|
1213
|
+
(next: RealtimeVoicePreferences) => {
|
|
1214
|
+
preferencesEditedRef.current = true;
|
|
1215
|
+
preferencesHydratedRef.current = true;
|
|
1216
|
+
preferencesRef.current = next;
|
|
1217
|
+
setPreferences(next);
|
|
1218
|
+
preferenceWriteChainRef.current = preferenceWriteChainRef.current
|
|
1219
|
+
.catch(() => undefined)
|
|
1220
|
+
.then(async () => {
|
|
1221
|
+
await adapters.voice!.setAppState!(
|
|
1222
|
+
REALTIME_VOICE_PREFERENCES_KEY,
|
|
1223
|
+
next,
|
|
1224
|
+
);
|
|
1225
|
+
})
|
|
1226
|
+
.catch(() => undefined);
|
|
1227
|
+
},
|
|
1228
|
+
[adapters],
|
|
1229
|
+
);
|
|
1230
|
+
|
|
1231
|
+
const updateLivePreferences = useCallback(
|
|
1232
|
+
(next: RealtimeVoicePreferences, includeVoice = false) => {
|
|
1233
|
+
sendDataChannelEvent(
|
|
1234
|
+
channelRef.current,
|
|
1235
|
+
createRealtimeVoicePreferenceUpdate(next, {
|
|
1236
|
+
browserLanguages:
|
|
1237
|
+
typeof navigator === "undefined" ? [] : navigator.languages,
|
|
1238
|
+
includeVoice,
|
|
1239
|
+
}),
|
|
1240
|
+
);
|
|
1241
|
+
},
|
|
1242
|
+
[],
|
|
1243
|
+
);
|
|
1244
|
+
|
|
1245
|
+
const setLanguage = useCallback(
|
|
1246
|
+
(language: RealtimeVoiceLanguage) => {
|
|
1247
|
+
const next = { ...preferencesRef.current, language };
|
|
1248
|
+
savePreferences(next);
|
|
1249
|
+
updateLivePreferences(next);
|
|
1250
|
+
},
|
|
1251
|
+
[savePreferences, updateLivePreferences],
|
|
1252
|
+
);
|
|
1253
|
+
|
|
1254
|
+
const setIntelligence = useCallback(
|
|
1255
|
+
(intelligence: RealtimeVoiceIntelligence) => {
|
|
1256
|
+
const next = { ...preferencesRef.current, intelligence };
|
|
1257
|
+
savePreferences(next);
|
|
1258
|
+
updateLivePreferences(next);
|
|
1259
|
+
},
|
|
1260
|
+
[savePreferences, updateLivePreferences],
|
|
1261
|
+
);
|
|
1262
|
+
|
|
1263
|
+
const setVoice = useCallback(
|
|
1264
|
+
(voice: RealtimeVoice) => {
|
|
1265
|
+
const next = { ...preferencesRef.current, voice };
|
|
1266
|
+
savePreferences(next);
|
|
1267
|
+
if (hasOutputAudioRef.current) {
|
|
1268
|
+
setVoiceChangePending(true);
|
|
1269
|
+
updateLivePreferences(next);
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
setVoiceChangePending(false);
|
|
1273
|
+
updateLivePreferences(next, true);
|
|
1274
|
+
},
|
|
1275
|
+
[savePreferences, updateLivePreferences],
|
|
1276
|
+
);
|
|
1277
|
+
|
|
1278
|
+
const refreshMicrophones = useCallback(async (): Promise<
|
|
1279
|
+
RealtimeVoiceMicrophone[] | null
|
|
1280
|
+
> => {
|
|
1281
|
+
const mediaDevices = navigator.mediaDevices;
|
|
1282
|
+
if (!mediaDevices?.enumerateDevices) return null;
|
|
1283
|
+
try {
|
|
1284
|
+
const devices = await mediaDevices.enumerateDevices();
|
|
1285
|
+
const inputs = devices
|
|
1286
|
+
.filter(
|
|
1287
|
+
(device) =>
|
|
1288
|
+
device.kind === "audioinput" &&
|
|
1289
|
+
device.deviceId &&
|
|
1290
|
+
device.deviceId !== "default" &&
|
|
1291
|
+
device.deviceId !== "communications",
|
|
1292
|
+
)
|
|
1293
|
+
.map((device, index) => ({
|
|
1294
|
+
deviceId: device.deviceId,
|
|
1295
|
+
label: device.label || `Microphone ${index + 1}`,
|
|
1296
|
+
}));
|
|
1297
|
+
setMicrophones(inputs);
|
|
1298
|
+
return inputs;
|
|
1299
|
+
} catch {
|
|
1300
|
+
// Enumeration is progressive enhancement; system default remains usable.
|
|
1301
|
+
return null;
|
|
1302
|
+
}
|
|
1303
|
+
}, []);
|
|
1304
|
+
|
|
1305
|
+
const startMeterLoop = useCallback(() => {
|
|
1306
|
+
if (meterFrameRef.current !== null) return;
|
|
1307
|
+
const sample = (timestamp: number) => {
|
|
1308
|
+
meterFrameRef.current = requestAnimationFrame(sample);
|
|
1309
|
+
if (timestamp - lastMeterSampleRef.current < 50) return;
|
|
1310
|
+
lastMeterSampleRef.current = timestamp;
|
|
1311
|
+
|
|
1312
|
+
const current = audioLevels.getSnapshot();
|
|
1313
|
+
let input = current.input;
|
|
1314
|
+
let output = current.output;
|
|
1315
|
+
const inputAnalyser = inputAnalyserRef.current;
|
|
1316
|
+
const inputBuffer = inputMeterBufferRef.current;
|
|
1317
|
+
if (inputAnalyser && inputBuffer) {
|
|
1318
|
+
inputAnalyser.getByteTimeDomainData(inputBuffer);
|
|
1319
|
+
input = smoothRealtimeVoiceLevel(
|
|
1320
|
+
input,
|
|
1321
|
+
normalizeRealtimeVoiceRms(inputBuffer),
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
const outputAnalyser = outputAnalyserRef.current;
|
|
1325
|
+
const outputBuffer = outputMeterBufferRef.current;
|
|
1326
|
+
if (outputAnalyser && outputBuffer) {
|
|
1327
|
+
outputAnalyser.getByteTimeDomainData(outputBuffer);
|
|
1328
|
+
output = smoothRealtimeVoiceLevel(
|
|
1329
|
+
output,
|
|
1330
|
+
normalizeRealtimeVoiceRms(outputBuffer),
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
audioLevels.set({ input, output });
|
|
1334
|
+
};
|
|
1335
|
+
meterFrameRef.current = requestAnimationFrame(sample);
|
|
1336
|
+
}, [audioLevels]);
|
|
1337
|
+
|
|
1338
|
+
const attachAudioMeter = useCallback(
|
|
1339
|
+
(stream: MediaStream, channel: "input" | "output") => {
|
|
1340
|
+
try {
|
|
1341
|
+
const AudioCtor =
|
|
1342
|
+
window.AudioContext ??
|
|
1343
|
+
(
|
|
1344
|
+
window as typeof window & {
|
|
1345
|
+
webkitAudioContext?: typeof AudioContext;
|
|
1346
|
+
}
|
|
1347
|
+
).webkitAudioContext;
|
|
1348
|
+
if (!AudioCtor) return;
|
|
1349
|
+
const context = audioContextRef.current ?? new AudioCtor();
|
|
1350
|
+
audioContextRef.current = context;
|
|
1351
|
+
void context.resume().catch(() => undefined);
|
|
1352
|
+
const analyser = context.createAnalyser();
|
|
1353
|
+
analyser.fftSize = 512;
|
|
1354
|
+
const source = context.createMediaStreamSource(stream);
|
|
1355
|
+
source.connect(analyser);
|
|
1356
|
+
const buffer = new Uint8Array(analyser.frequencyBinCount);
|
|
1357
|
+
if (channel === "input") {
|
|
1358
|
+
inputSourceRef.current?.disconnect();
|
|
1359
|
+
inputSourceRef.current = source;
|
|
1360
|
+
inputAnalyserRef.current = analyser;
|
|
1361
|
+
inputMeterBufferRef.current = buffer;
|
|
1362
|
+
} else {
|
|
1363
|
+
outputSourceRef.current?.disconnect();
|
|
1364
|
+
outputSourceRef.current = source;
|
|
1365
|
+
outputAnalyserRef.current = analyser;
|
|
1366
|
+
outputMeterBufferRef.current = buffer;
|
|
1367
|
+
}
|
|
1368
|
+
startMeterLoop();
|
|
1369
|
+
} catch {
|
|
1370
|
+
// Audio metering is visual-only; keep the realtime call healthy.
|
|
1371
|
+
}
|
|
1372
|
+
},
|
|
1373
|
+
[startMeterLoop],
|
|
1374
|
+
);
|
|
1375
|
+
|
|
1376
|
+
const setMicrophone = useCallback(
|
|
1377
|
+
async (deviceId: string, force = false) => {
|
|
1378
|
+
if ((!force && deviceId === microphoneDeviceId) || microphoneSwitching) {
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
const mediaDevices = navigator.mediaDevices;
|
|
1382
|
+
const peer = peerRef.current;
|
|
1383
|
+
if (!mediaDevices?.getUserMedia || !peer) return;
|
|
1384
|
+
|
|
1385
|
+
setMicrophoneSwitching(true);
|
|
1386
|
+
setMicrophoneError(null);
|
|
1387
|
+
try {
|
|
1388
|
+
const replacementStream = await replaceRealtimeVoiceMicrophone({
|
|
1389
|
+
mediaDevices,
|
|
1390
|
+
peer,
|
|
1391
|
+
currentStream: streamRef.current,
|
|
1392
|
+
deviceId,
|
|
1393
|
+
});
|
|
1394
|
+
const replacementTrack = replacementStream.getAudioTracks()[0];
|
|
1395
|
+
streamRef.current = replacementStream;
|
|
1396
|
+
if (replacementTrack) {
|
|
1397
|
+
replacementTrack.onended = () => {
|
|
1398
|
+
void switchMicrophoneRef.current("default", true);
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
attachAudioMeter(streamRef.current, "input");
|
|
1402
|
+
writeRealtimeVoiceMicrophoneId(deviceId);
|
|
1403
|
+
setMicrophoneDeviceId(deviceId);
|
|
1404
|
+
await refreshMicrophones();
|
|
1405
|
+
} catch {
|
|
1406
|
+
setMicrophoneError(
|
|
1407
|
+
copy?.settings.microphoneSwitchFailed ??
|
|
1408
|
+
"Could not switch microphones. Your current microphone is still active.",
|
|
1409
|
+
);
|
|
1410
|
+
} finally {
|
|
1411
|
+
setMicrophoneSwitching(false);
|
|
1412
|
+
}
|
|
1413
|
+
},
|
|
1414
|
+
[
|
|
1415
|
+
attachAudioMeter,
|
|
1416
|
+
copy,
|
|
1417
|
+
microphoneDeviceId,
|
|
1418
|
+
microphoneSwitching,
|
|
1419
|
+
refreshMicrophones,
|
|
1420
|
+
],
|
|
1421
|
+
);
|
|
1422
|
+
switchMicrophoneRef.current = setMicrophone;
|
|
1423
|
+
|
|
1424
|
+
const cleanupTransport = useCallback(() => {
|
|
1425
|
+
connectionGateRef.current?.cancel();
|
|
1426
|
+
connectionGateRef.current = null;
|
|
1427
|
+
transportGenerationRef.current += 1;
|
|
1428
|
+
capabilityRef.current = undefined;
|
|
1429
|
+
abortRef.current?.abort();
|
|
1430
|
+
abortRef.current = null;
|
|
1431
|
+
channelRef.current?.close();
|
|
1432
|
+
channelRef.current = null;
|
|
1433
|
+
peerRef.current?.close();
|
|
1434
|
+
peerRef.current = null;
|
|
1435
|
+
for (const track of streamRef.current?.getTracks() ?? []) {
|
|
1436
|
+
track.onended = null;
|
|
1437
|
+
track.stop();
|
|
1438
|
+
}
|
|
1439
|
+
streamRef.current = null;
|
|
1440
|
+
if (audioRef.current) {
|
|
1441
|
+
audioRef.current.pause();
|
|
1442
|
+
audioRef.current.srcObject = null;
|
|
1443
|
+
}
|
|
1444
|
+
audioRef.current = null;
|
|
1445
|
+
if (meterFrameRef.current !== null) {
|
|
1446
|
+
cancelAnimationFrame(meterFrameRef.current);
|
|
1447
|
+
meterFrameRef.current = null;
|
|
1448
|
+
}
|
|
1449
|
+
inputSourceRef.current?.disconnect();
|
|
1450
|
+
outputSourceRef.current?.disconnect();
|
|
1451
|
+
inputSourceRef.current = null;
|
|
1452
|
+
outputSourceRef.current = null;
|
|
1453
|
+
inputAnalyserRef.current = null;
|
|
1454
|
+
outputAnalyserRef.current = null;
|
|
1455
|
+
inputMeterBufferRef.current = null;
|
|
1456
|
+
outputMeterBufferRef.current = null;
|
|
1457
|
+
lastMeterSampleRef.current = 0;
|
|
1458
|
+
const audioContext = audioContextRef.current;
|
|
1459
|
+
audioContextRef.current = null;
|
|
1460
|
+
if (audioContext) void audioContext.close().catch(() => undefined);
|
|
1461
|
+
audioLevels.reset();
|
|
1462
|
+
handledCallsRef.current.clear();
|
|
1463
|
+
toolManifestCoordinator.reset();
|
|
1464
|
+
}, [audioLevels, toolManifestCoordinator]);
|
|
1465
|
+
|
|
1466
|
+
const fail = useCallback(
|
|
1467
|
+
(message: string, options?: { openKeySettings?: boolean }) => {
|
|
1468
|
+
cleanupTransport();
|
|
1469
|
+
setError(message);
|
|
1470
|
+
transition("error");
|
|
1471
|
+
if (options?.openKeySettings) openOpenAiKeySettings();
|
|
1472
|
+
},
|
|
1473
|
+
[cleanupTransport, transition],
|
|
1474
|
+
);
|
|
1475
|
+
|
|
1476
|
+
const handleFunctionCall = useCallback(
|
|
1477
|
+
async (call: { name: string; callId: string; argumentsText: string }) => {
|
|
1478
|
+
if (handledCallsRef.current.has(call.callId)) return;
|
|
1479
|
+
handledCallsRef.current.add(call.callId);
|
|
1480
|
+
const transportGeneration = transportGenerationRef.current;
|
|
1481
|
+
const transportChannel = channelRef.current;
|
|
1482
|
+
transition("working");
|
|
1483
|
+
let result: RealtimeVoiceToolResult;
|
|
1484
|
+
try {
|
|
1485
|
+
const args = parseFunctionArguments(call.argumentsText);
|
|
1486
|
+
result = await executeRealtimeVoiceTool({
|
|
1487
|
+
name: call.name,
|
|
1488
|
+
args,
|
|
1489
|
+
callId: call.callId,
|
|
1490
|
+
sessionId: sessionIdRef.current,
|
|
1491
|
+
browserTabId,
|
|
1492
|
+
capability: capabilityRef.current,
|
|
1493
|
+
signal: abortRef.current?.signal,
|
|
1494
|
+
});
|
|
1495
|
+
} catch (toolError) {
|
|
1496
|
+
result = {
|
|
1497
|
+
callId: call.callId,
|
|
1498
|
+
status: "failed",
|
|
1499
|
+
output: errorMessage(toolError),
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
if (
|
|
1503
|
+
transportGeneration !== transportGenerationRef.current ||
|
|
1504
|
+
channelRef.current !== transportChannel
|
|
1505
|
+
) {
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
toolManifestCoordinator.enqueue(result);
|
|
1509
|
+
},
|
|
1510
|
+
[browserTabId, toolManifestCoordinator, transition],
|
|
1511
|
+
);
|
|
1512
|
+
|
|
1513
|
+
const publishCompletedTranscript = useCallback(
|
|
1514
|
+
(transcript: CompletedRealtimeVoiceTranscript) => {
|
|
1515
|
+
const threadId = transcriptThreadIdRef.current;
|
|
1516
|
+
if (!threadId) return;
|
|
1517
|
+
const sessionIdentity =
|
|
1518
|
+
sessionIdRef.current ?? startedAtRef.current ?? "pending";
|
|
1519
|
+
const providerIdentity =
|
|
1520
|
+
transcript.providerId ?? `sequence-${++transcriptSequenceRef.current}`;
|
|
1521
|
+
realtimeVoiceTranscriptRegistry.publish({
|
|
1522
|
+
id: `realtime-voice:${sessionIdentity}:${transcript.role}:${providerIdentity}`,
|
|
1523
|
+
threadId,
|
|
1524
|
+
role: transcript.role,
|
|
1525
|
+
text: transcript.text,
|
|
1526
|
+
createdAt: new Date().toISOString(),
|
|
1527
|
+
});
|
|
1528
|
+
},
|
|
1529
|
+
[],
|
|
1530
|
+
);
|
|
1531
|
+
|
|
1532
|
+
const transcriptSequencer = useMemo(
|
|
1533
|
+
() => createRealtimeVoiceTranscriptSequencer(publishCompletedTranscript),
|
|
1534
|
+
[publishCompletedTranscript],
|
|
1535
|
+
);
|
|
1536
|
+
const greetingStarter = useMemo(
|
|
1537
|
+
() =>
|
|
1538
|
+
createRealtimeVoiceGreetingStarter((event) => {
|
|
1539
|
+
sendDataChannelEvent(channelRef.current, event);
|
|
1540
|
+
}),
|
|
1541
|
+
[],
|
|
1542
|
+
);
|
|
1543
|
+
|
|
1544
|
+
const handleServerEvent = useCallback(
|
|
1545
|
+
(event: RealtimeServerEvent) => {
|
|
1546
|
+
transcriptSequencer.handle(event);
|
|
1547
|
+
const sessionTools = extractRealtimeVoiceSessionTools(event);
|
|
1548
|
+
if (sessionTools) toolManifestCoordinator.setSessionTools(sessionTools);
|
|
1549
|
+
if (event.type === "session.created") {
|
|
1550
|
+
connectionGateRef.current?.markSessionCreated();
|
|
1551
|
+
connectionGateRef.current = null;
|
|
1552
|
+
const session = event.session;
|
|
1553
|
+
if (session && typeof session === "object") {
|
|
1554
|
+
const id = (session as { id?: unknown }).id;
|
|
1555
|
+
if (typeof id === "string") sessionIdRef.current = id;
|
|
1556
|
+
}
|
|
1557
|
+
updateLivePreferences(preferencesRef.current, true);
|
|
1558
|
+
greetingStarter.start();
|
|
1559
|
+
transition("working");
|
|
1560
|
+
} else if (event.type === "input_audio_buffer.speech_started") {
|
|
1561
|
+
transition("listening");
|
|
1562
|
+
} else if (event.type === "input_audio_buffer.speech_stopped") {
|
|
1563
|
+
transition("working");
|
|
1564
|
+
} else if (event.type === "response.created") {
|
|
1565
|
+
// From this point onward the response is committed to audio output, so
|
|
1566
|
+
// changing voices risks violating Realtime's per-session voice lock.
|
|
1567
|
+
hasOutputAudioRef.current = true;
|
|
1568
|
+
lastAssistantTextRef.current = "";
|
|
1569
|
+
transition("working");
|
|
1570
|
+
} else if (event.type === "response.output_audio_transcript.delta") {
|
|
1571
|
+
hasOutputAudioRef.current = true;
|
|
1572
|
+
if (typeof event.delta === "string") {
|
|
1573
|
+
lastAssistantTextRef.current += event.delta;
|
|
1574
|
+
}
|
|
1575
|
+
transition("speaking");
|
|
1576
|
+
} else if (event.type === "response.output_audio_transcript.done") {
|
|
1577
|
+
if (typeof event.transcript === "string") {
|
|
1578
|
+
lastAssistantTextRef.current = event.transcript;
|
|
1579
|
+
}
|
|
1580
|
+
syncAppState("speaking");
|
|
1581
|
+
} else if (
|
|
1582
|
+
event.type === "conversation.item.input_audio_transcription.completed"
|
|
1583
|
+
) {
|
|
1584
|
+
if (typeof event.transcript === "string") {
|
|
1585
|
+
lastUserTextRef.current = event.transcript;
|
|
1586
|
+
}
|
|
1587
|
+
syncAppState("working");
|
|
1588
|
+
} else if (event.type === "response.done") {
|
|
1589
|
+
const response = event.response;
|
|
1590
|
+
const status =
|
|
1591
|
+
response && typeof response === "object"
|
|
1592
|
+
? (response as { status?: unknown }).status
|
|
1593
|
+
: undefined;
|
|
1594
|
+
if (status === "failed") {
|
|
1595
|
+
fail(
|
|
1596
|
+
copy?.errors.responseFailed ??
|
|
1597
|
+
"OpenAI could not complete the voice response.",
|
|
1598
|
+
);
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
} else if (event.type === "error") {
|
|
1602
|
+
const detail = event.error;
|
|
1603
|
+
const message =
|
|
1604
|
+
detail && typeof detail === "object"
|
|
1605
|
+
? String((detail as { message?: unknown }).message ?? "")
|
|
1606
|
+
: typeof detail === "string"
|
|
1607
|
+
? detail
|
|
1608
|
+
: "";
|
|
1609
|
+
const expansionEventId =
|
|
1610
|
+
detail && typeof detail === "object"
|
|
1611
|
+
? ((detail as { event_id?: unknown }).event_id ?? event.event_id)
|
|
1612
|
+
: event.event_id;
|
|
1613
|
+
if (
|
|
1614
|
+
toolManifestCoordinator.handleError(
|
|
1615
|
+
typeof expansionEventId === "string" ? expansionEventId : undefined,
|
|
1616
|
+
message,
|
|
1617
|
+
)
|
|
1618
|
+
) {
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
fail(
|
|
1622
|
+
message ||
|
|
1623
|
+
copy?.errors.sessionFailed ||
|
|
1624
|
+
"The realtime voice session encountered an error.",
|
|
1625
|
+
);
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
const calls = extractRealtimeVoiceFunctionCalls(event);
|
|
1630
|
+
for (const call of calls) void handleFunctionCall(call);
|
|
1631
|
+
if (event.type === "response.done" && calls.length === 0) {
|
|
1632
|
+
transition("listening");
|
|
1633
|
+
}
|
|
1634
|
+
},
|
|
1635
|
+
[
|
|
1636
|
+
copy,
|
|
1637
|
+
fail,
|
|
1638
|
+
greetingStarter,
|
|
1639
|
+
handleFunctionCall,
|
|
1640
|
+
syncAppState,
|
|
1641
|
+
transcriptSequencer,
|
|
1642
|
+
transition,
|
|
1643
|
+
toolManifestCoordinator,
|
|
1644
|
+
updateLivePreferences,
|
|
1645
|
+
],
|
|
1646
|
+
);
|
|
1647
|
+
|
|
1648
|
+
const start = useCallback(async () => {
|
|
1649
|
+
if (stateRef.current !== "idle") return;
|
|
1650
|
+
if (
|
|
1651
|
+
typeof RTCPeerConnection === "undefined" ||
|
|
1652
|
+
!navigator.mediaDevices?.getUserMedia
|
|
1653
|
+
) {
|
|
1654
|
+
fail(
|
|
1655
|
+
copy?.errors.unsupported ??
|
|
1656
|
+
"This browser does not support realtime voice conversations.",
|
|
1657
|
+
);
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
setError(null);
|
|
1661
|
+
startedAtRef.current = new Date().toISOString();
|
|
1662
|
+
lastUserTextRef.current = "";
|
|
1663
|
+
lastAssistantTextRef.current = "";
|
|
1664
|
+
sessionIdRef.current = undefined;
|
|
1665
|
+
hasOutputAudioRef.current = false;
|
|
1666
|
+
greetingStarter.reset();
|
|
1667
|
+
setVoiceChangePending(false);
|
|
1668
|
+
transcriptThreadIdRef.current =
|
|
1669
|
+
realtimeVoiceTranscriptRegistry.activeThreadId();
|
|
1670
|
+
transcriptSequenceRef.current = 0;
|
|
1671
|
+
transcriptSequencer.reset();
|
|
1672
|
+
transition("connecting");
|
|
1673
|
+
setChatVisible(false);
|
|
1674
|
+
window.dispatchEvent(new Event("agent-panel:close"));
|
|
1675
|
+
|
|
1676
|
+
await hydratePreferences();
|
|
1677
|
+
if ((stateRef.current as string) !== "connecting") return;
|
|
1678
|
+
|
|
1679
|
+
const abortController = new AbortController();
|
|
1680
|
+
abortRef.current = abortController;
|
|
1681
|
+
const isCurrentAttempt = () =>
|
|
1682
|
+
abortRef.current === abortController && !abortController.signal.aborted;
|
|
1683
|
+
const connectionGate = createRealtimeVoiceConnectionGate(() => {
|
|
1684
|
+
if (!isCurrentAttempt()) return;
|
|
1685
|
+
fail(
|
|
1686
|
+
copy?.errors.connectionTimedOut ??
|
|
1687
|
+
"The realtime voice connection timed out.",
|
|
1688
|
+
);
|
|
1689
|
+
});
|
|
1690
|
+
connectionGateRef.current = connectionGate;
|
|
1691
|
+
try {
|
|
1692
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
1693
|
+
audio: createRealtimeVoiceAudioConstraints(
|
|
1694
|
+
readRealtimeVoiceMicrophoneId(),
|
|
1695
|
+
),
|
|
1696
|
+
});
|
|
1697
|
+
if (!isCurrentAttempt()) {
|
|
1698
|
+
for (const track of stream.getTracks()) track.stop();
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
streamRef.current = stream;
|
|
1702
|
+
for (const track of stream.getAudioTracks()) {
|
|
1703
|
+
track.onended = () => {
|
|
1704
|
+
void switchMicrophoneRef.current("default", true);
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
attachAudioMeter(stream, "input");
|
|
1708
|
+
void refreshMicrophones().then((inputs) => {
|
|
1709
|
+
const selected = readRealtimeVoiceMicrophoneId();
|
|
1710
|
+
if (
|
|
1711
|
+
inputs &&
|
|
1712
|
+
selected !== "default" &&
|
|
1713
|
+
!inputs.some((device) => device.deviceId === selected)
|
|
1714
|
+
) {
|
|
1715
|
+
// The persisted device was only an ideal preference, so getUserMedia
|
|
1716
|
+
// already selected a usable fallback for this new session.
|
|
1717
|
+
writeRealtimeVoiceMicrophoneId("default");
|
|
1718
|
+
setMicrophoneDeviceId("default");
|
|
1719
|
+
}
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
const peer = new RTCPeerConnection();
|
|
1723
|
+
peerRef.current = peer;
|
|
1724
|
+
for (const track of stream.getAudioTracks()) peer.addTrack(track, stream);
|
|
1725
|
+
|
|
1726
|
+
const audio = document.createElement("audio");
|
|
1727
|
+
audio.autoplay = true;
|
|
1728
|
+
audio.setAttribute("playsinline", "");
|
|
1729
|
+
audioRef.current = audio;
|
|
1730
|
+
peer.ontrack = (trackEvent) => {
|
|
1731
|
+
const remoteStream = trackEvent.streams[0] ?? null;
|
|
1732
|
+
audio.srcObject = remoteStream;
|
|
1733
|
+
if (remoteStream) attachAudioMeter(remoteStream, "output");
|
|
1734
|
+
void audio.play().catch(() => undefined);
|
|
1735
|
+
};
|
|
1736
|
+
|
|
1737
|
+
const channel = peer.createDataChannel("oai-events");
|
|
1738
|
+
channelRef.current = channel;
|
|
1739
|
+
channel.onopen = connectionGate.markTransportReady;
|
|
1740
|
+
channel.onmessage = (messageEvent) => {
|
|
1741
|
+
if (!isCurrentAttempt()) return;
|
|
1742
|
+
try {
|
|
1743
|
+
handleServerEvent(JSON.parse(String(messageEvent.data)));
|
|
1744
|
+
} catch {
|
|
1745
|
+
// Ignore malformed provider events without ending a healthy call.
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
channel.onerror = () => {
|
|
1749
|
+
if (!isCurrentAttempt()) return;
|
|
1750
|
+
fail(
|
|
1751
|
+
copy?.errors.channelDisconnected ??
|
|
1752
|
+
"The realtime voice control channel disconnected.",
|
|
1753
|
+
);
|
|
1754
|
+
};
|
|
1755
|
+
peer.onconnectionstatechange = () => {
|
|
1756
|
+
if (!isCurrentAttempt()) return;
|
|
1757
|
+
if (peer.connectionState === "connected") {
|
|
1758
|
+
connectionGate.markTransportReady();
|
|
1759
|
+
}
|
|
1760
|
+
if (peer.connectionState === "failed") {
|
|
1761
|
+
fail(
|
|
1762
|
+
copy?.errors.connectionFailed ??
|
|
1763
|
+
"The realtime voice connection failed.",
|
|
1764
|
+
);
|
|
1765
|
+
}
|
|
1766
|
+
};
|
|
1767
|
+
|
|
1768
|
+
const offer = await peer.createOffer();
|
|
1769
|
+
if (!isCurrentAttempt()) return;
|
|
1770
|
+
await peer.setLocalDescription(offer);
|
|
1771
|
+
if (!isCurrentAttempt()) return;
|
|
1772
|
+
if (!offer.sdp) {
|
|
1773
|
+
throw new Error(
|
|
1774
|
+
copy?.errors.offerFailed ??
|
|
1775
|
+
"The browser did not create an audio offer.",
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
const answer = await createRealtimeVoiceSessionWithCapability(offer.sdp, {
|
|
1779
|
+
browserTabId,
|
|
1780
|
+
signal: abortController.signal,
|
|
1781
|
+
preferences: preferencesRef.current,
|
|
1782
|
+
browserLanguages: navigator.languages,
|
|
1783
|
+
});
|
|
1784
|
+
if (!isCurrentAttempt()) return;
|
|
1785
|
+
capabilityRef.current = answer.capability;
|
|
1786
|
+
await peer.setRemoteDescription({ type: "answer", sdp: answer.sdp });
|
|
1787
|
+
} catch (startError) {
|
|
1788
|
+
// Ending a connection aborts the SDP request by design. A superseded
|
|
1789
|
+
// attempt must never resurrect the dock or surface that cancellation as
|
|
1790
|
+
// an error after cleanup.
|
|
1791
|
+
if (
|
|
1792
|
+
isRealtimeVoiceAbortError(startError) ||
|
|
1793
|
+
abortController.signal.aborted ||
|
|
1794
|
+
abortRef.current !== abortController
|
|
1795
|
+
) {
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
if (isRealtimeVoiceSetupRequiredError(startError)) {
|
|
1799
|
+
// Credentials can disappear after the entry-point status check. Treat
|
|
1800
|
+
// the authoritative setup response as a gate, not as an active voice
|
|
1801
|
+
// session failure: clean up the mic/RTC attempt, reopen chat, and
|
|
1802
|
+
// refresh both the chat and voice provider setup surfaces.
|
|
1803
|
+
cleanupTransport();
|
|
1804
|
+
setError(null);
|
|
1805
|
+
startedAtRef.current = undefined;
|
|
1806
|
+
sessionIdRef.current = undefined;
|
|
1807
|
+
transcriptThreadIdRef.current = undefined;
|
|
1808
|
+
transition("idle");
|
|
1809
|
+
setChatVisible(true);
|
|
1810
|
+
window.dispatchEvent(new Event("agent-panel:open"));
|
|
1811
|
+
window.dispatchEvent(new Event("agent-engine:configured-changed"));
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
const status = (startError as { status?: unknown })?.status;
|
|
1815
|
+
fail(errorMessage(startError), { openKeySettings: status === 400 });
|
|
1816
|
+
}
|
|
1817
|
+
}, [
|
|
1818
|
+
attachAudioMeter,
|
|
1819
|
+
browserTabId,
|
|
1820
|
+
cleanupTransport,
|
|
1821
|
+
copy,
|
|
1822
|
+
fail,
|
|
1823
|
+
greetingStarter,
|
|
1824
|
+
handleServerEvent,
|
|
1825
|
+
hydratePreferences,
|
|
1826
|
+
refreshMicrophones,
|
|
1827
|
+
transcriptSequencer,
|
|
1828
|
+
transition,
|
|
1829
|
+
]);
|
|
1830
|
+
|
|
1831
|
+
const end = useCallback(() => {
|
|
1832
|
+
if (stateRef.current === "idle" || stateRef.current === "ending") return;
|
|
1833
|
+
const transcriptThreadId = transcriptThreadIdRef.current;
|
|
1834
|
+
const activeThreadId = realtimeVoiceTranscriptRegistry.activeThreadId();
|
|
1835
|
+
transition("ending");
|
|
1836
|
+
cleanupTransport();
|
|
1837
|
+
setError(null);
|
|
1838
|
+
sessionIdRef.current = undefined;
|
|
1839
|
+
startedAtRef.current = undefined;
|
|
1840
|
+
transcriptThreadIdRef.current = undefined;
|
|
1841
|
+
setChatVisible(true);
|
|
1842
|
+
if (
|
|
1843
|
+
shouldRestoreRealtimeVoiceTranscriptThread(
|
|
1844
|
+
transcriptThreadId,
|
|
1845
|
+
activeThreadId,
|
|
1846
|
+
)
|
|
1847
|
+
) {
|
|
1848
|
+
adapters.agentChat!.requestThreadOpen!({
|
|
1849
|
+
threadId: transcriptThreadId,
|
|
1850
|
+
// The request is delivered asynchronously. Re-checking this at the
|
|
1851
|
+
// receiver prevents a navigation that happened during that gap from
|
|
1852
|
+
// being overwritten.
|
|
1853
|
+
onlyIfActiveThreadId: transcriptThreadId,
|
|
1854
|
+
});
|
|
1855
|
+
} else {
|
|
1856
|
+
window.dispatchEvent(new Event("agent-panel:open"));
|
|
1857
|
+
}
|
|
1858
|
+
transition("idle");
|
|
1859
|
+
}, [adapters, cleanupTransport, transition]);
|
|
1860
|
+
|
|
1861
|
+
const toggleChat = useCallback(() => {
|
|
1862
|
+
setChatVisible((current) => !current);
|
|
1863
|
+
window.dispatchEvent(new Event("agent-panel:toggle"));
|
|
1864
|
+
}, []);
|
|
1865
|
+
|
|
1866
|
+
useEffect(() => {
|
|
1867
|
+
const mediaDevices = navigator.mediaDevices;
|
|
1868
|
+
if (!mediaDevices?.addEventListener) return;
|
|
1869
|
+
const handleDeviceChange = () => {
|
|
1870
|
+
const selected = readRealtimeVoiceMicrophoneId();
|
|
1871
|
+
void refreshMicrophones().then((inputs) => {
|
|
1872
|
+
if (
|
|
1873
|
+
inputs &&
|
|
1874
|
+
selected !== "default" &&
|
|
1875
|
+
!inputs.some((device) => device.deviceId === selected)
|
|
1876
|
+
) {
|
|
1877
|
+
void setMicrophone("default");
|
|
1878
|
+
}
|
|
1879
|
+
});
|
|
1880
|
+
};
|
|
1881
|
+
mediaDevices.addEventListener("devicechange", handleDeviceChange);
|
|
1882
|
+
return () =>
|
|
1883
|
+
mediaDevices.removeEventListener("devicechange", handleDeviceChange);
|
|
1884
|
+
}, [refreshMicrophones, setMicrophone]);
|
|
1885
|
+
|
|
1886
|
+
useEffect(() => {
|
|
1887
|
+
return adapters.voice!.subscribeSidebarState!((detail) => {
|
|
1888
|
+
if (detail && typeof detail.open === "boolean") {
|
|
1889
|
+
setChatVisible(detail.open);
|
|
1890
|
+
}
|
|
1891
|
+
});
|
|
1892
|
+
}, [adapters]);
|
|
1893
|
+
|
|
1894
|
+
useEffect(() => {
|
|
1895
|
+
const stopListening = listenForRealtimeVoicePageHide(cleanupTransport);
|
|
1896
|
+
return () => {
|
|
1897
|
+
stopListening();
|
|
1898
|
+
cleanupTransport();
|
|
1899
|
+
};
|
|
1900
|
+
}, [cleanupTransport]);
|
|
1901
|
+
|
|
1902
|
+
return {
|
|
1903
|
+
state,
|
|
1904
|
+
active: state !== "idle",
|
|
1905
|
+
errorMessage: error,
|
|
1906
|
+
chatVisible,
|
|
1907
|
+
audioLevels,
|
|
1908
|
+
preferences,
|
|
1909
|
+
microphones,
|
|
1910
|
+
microphoneDeviceId,
|
|
1911
|
+
microphoneSwitching,
|
|
1912
|
+
microphoneError,
|
|
1913
|
+
voiceChangePending,
|
|
1914
|
+
setLanguage,
|
|
1915
|
+
setIntelligence,
|
|
1916
|
+
setVoice,
|
|
1917
|
+
setMicrophone,
|
|
1918
|
+
start,
|
|
1919
|
+
end,
|
|
1920
|
+
toggleChat,
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
const RealtimeVoiceModeContext = createContext<RealtimeVoiceModeApi | null>(
|
|
1925
|
+
null,
|
|
1926
|
+
);
|
|
1927
|
+
|
|
1928
|
+
export function RealtimeVoiceModeProvider({
|
|
1929
|
+
children,
|
|
1930
|
+
browserTabId,
|
|
1931
|
+
}: RealtimeVoiceModeProviderProps) {
|
|
1932
|
+
const adapters = useComposerRuntimeAdapters();
|
|
1933
|
+
const resolvedBrowserTabId = useMemo(
|
|
1934
|
+
() => browserTabId ?? adapters.voice!.getBrowserTabId!(),
|
|
1935
|
+
[adapters, browserTabId],
|
|
1936
|
+
);
|
|
1937
|
+
const copy = useRealtimeVoiceModeCopy();
|
|
1938
|
+
const voice = useRealtimeVoiceModeController(resolvedBrowserTabId, copy);
|
|
1939
|
+
const inlineSettings = useMemo(
|
|
1940
|
+
() => ({
|
|
1941
|
+
dialogLabel: copy.voiceSettings,
|
|
1942
|
+
...(voice.voiceChangePending
|
|
1943
|
+
? { appliesNextConversationNote: copy.settings.voiceChangePending }
|
|
1944
|
+
: {}),
|
|
1945
|
+
...(voice.microphoneError
|
|
1946
|
+
? { microphoneError: voice.microphoneError }
|
|
1947
|
+
: {}),
|
|
1948
|
+
microphone: {
|
|
1949
|
+
label: copy.settings.microphone,
|
|
1950
|
+
value: voice.microphoneDeviceId,
|
|
1951
|
+
disabled: voice.microphoneSwitching,
|
|
1952
|
+
options: [
|
|
1953
|
+
{
|
|
1954
|
+
value: "default",
|
|
1955
|
+
label: copy.settings.defaultMicrophone,
|
|
1956
|
+
},
|
|
1957
|
+
...voice.microphones.map((microphone) => ({
|
|
1958
|
+
value: microphone.deviceId,
|
|
1959
|
+
label: microphone.label,
|
|
1960
|
+
})),
|
|
1961
|
+
],
|
|
1962
|
+
onValueChange: (value: string) => {
|
|
1963
|
+
void voice.setMicrophone(value);
|
|
1964
|
+
},
|
|
1965
|
+
},
|
|
1966
|
+
language: {
|
|
1967
|
+
label: copy.settings.language,
|
|
1968
|
+
value: voice.preferences.language,
|
|
1969
|
+
options: REALTIME_VOICE_LANGUAGES.map((language) => ({
|
|
1970
|
+
value: language,
|
|
1971
|
+
label:
|
|
1972
|
+
language === "auto"
|
|
1973
|
+
? copy.settings.autoLanguage
|
|
1974
|
+
: copy.settings.languages[language],
|
|
1975
|
+
})),
|
|
1976
|
+
onValueChange: (value: string) => {
|
|
1977
|
+
if (isOneOf(REALTIME_VOICE_LANGUAGES, value)) {
|
|
1978
|
+
voice.setLanguage(value);
|
|
1979
|
+
}
|
|
1980
|
+
},
|
|
1981
|
+
},
|
|
1982
|
+
intelligence: {
|
|
1983
|
+
label: copy.settings.intelligence,
|
|
1984
|
+
value: voice.preferences.intelligence,
|
|
1985
|
+
options: REALTIME_VOICE_INTELLIGENCE_LEVELS.map((intelligence) => ({
|
|
1986
|
+
value: intelligence,
|
|
1987
|
+
label: copy.settings.intelligenceLevels[intelligence],
|
|
1988
|
+
})),
|
|
1989
|
+
onValueChange: (value: string) => {
|
|
1990
|
+
if (isOneOf(REALTIME_VOICE_INTELLIGENCE_LEVELS, value)) {
|
|
1991
|
+
voice.setIntelligence(value);
|
|
1992
|
+
}
|
|
1993
|
+
},
|
|
1994
|
+
},
|
|
1995
|
+
voiceStyle: {
|
|
1996
|
+
label: copy.settings.voiceStyle,
|
|
1997
|
+
value: voice.preferences.voice,
|
|
1998
|
+
options: REALTIME_VOICES.map((voiceName) => ({
|
|
1999
|
+
value: voiceName,
|
|
2000
|
+
label: `${voiceName[0]!.toUpperCase()}${voiceName.slice(1)}`,
|
|
2001
|
+
description: copy.settings.voiceDescriptions[voiceName],
|
|
2002
|
+
})),
|
|
2003
|
+
onValueChange: (value: string) => {
|
|
2004
|
+
if (isOneOf(REALTIME_VOICES, value)) voice.setVoice(value);
|
|
2005
|
+
},
|
|
2006
|
+
},
|
|
2007
|
+
}),
|
|
2008
|
+
[copy, voice],
|
|
2009
|
+
);
|
|
2010
|
+
|
|
2011
|
+
return (
|
|
2012
|
+
<RealtimeVoiceModeContext.Provider value={voice}>
|
|
2013
|
+
{children}
|
|
2014
|
+
{voice.active && typeof document !== "undefined"
|
|
2015
|
+
? createPortal(
|
|
2016
|
+
<RealtimeVoiceModeDock
|
|
2017
|
+
state={voice.state === "idle" ? "ending" : voice.state}
|
|
2018
|
+
copy={copy}
|
|
2019
|
+
chatVisible={voice.chatVisible}
|
|
2020
|
+
audioLevels={voice.audioLevels}
|
|
2021
|
+
onToggleChat={voice.toggleChat}
|
|
2022
|
+
onEndVoiceMode={voice.end}
|
|
2023
|
+
settings={inlineSettings}
|
|
2024
|
+
errorMessage={voice.errorMessage}
|
|
2025
|
+
/>,
|
|
2026
|
+
document.body,
|
|
2027
|
+
)
|
|
2028
|
+
: null}
|
|
2029
|
+
</RealtimeVoiceModeContext.Provider>
|
|
2030
|
+
);
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
/**
|
|
2034
|
+
* Ensure standalone/full-page composers get realtime voice without nesting a
|
|
2035
|
+
* second session owner inside the persistent AgentSidebar provider.
|
|
2036
|
+
*/
|
|
2037
|
+
export function RealtimeVoiceModeBoundary({
|
|
2038
|
+
children,
|
|
2039
|
+
browserTabId,
|
|
2040
|
+
}: RealtimeVoiceModeProviderProps) {
|
|
2041
|
+
const existing = useRealtimeVoiceModeOptional();
|
|
2042
|
+
if (existing) {
|
|
2043
|
+
return (
|
|
2044
|
+
<RealtimeVoiceModeComposerSurface>
|
|
2045
|
+
{children}
|
|
2046
|
+
</RealtimeVoiceModeComposerSurface>
|
|
2047
|
+
);
|
|
2048
|
+
}
|
|
2049
|
+
return (
|
|
2050
|
+
<RealtimeVoiceModeProvider browserTabId={browserTabId}>
|
|
2051
|
+
<RealtimeVoiceModeComposerSurface>
|
|
2052
|
+
{children}
|
|
2053
|
+
</RealtimeVoiceModeComposerSurface>
|
|
2054
|
+
</RealtimeVoiceModeProvider>
|
|
2055
|
+
);
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
/** Hide a composer while voice owns input; the dock can reveal it on demand. */
|
|
2059
|
+
function RealtimeVoiceModeComposerSurface({
|
|
2060
|
+
children,
|
|
2061
|
+
}: Pick<RealtimeVoiceModeProviderProps, "children">) {
|
|
2062
|
+
const voice = useRealtimeVoiceModeOptional();
|
|
2063
|
+
if (voice?.active && !voice.chatVisible) return null;
|
|
2064
|
+
return children;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
export function useRealtimeVoiceMode(): RealtimeVoiceModeApi {
|
|
2068
|
+
const value = useContext(RealtimeVoiceModeContext);
|
|
2069
|
+
if (!value) {
|
|
2070
|
+
throw new Error(
|
|
2071
|
+
"useRealtimeVoiceMode must be used inside RealtimeVoiceModeProvider.",
|
|
2072
|
+
);
|
|
2073
|
+
}
|
|
2074
|
+
return value;
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
export function useRealtimeVoiceModeOptional(): RealtimeVoiceModeApi | null {
|
|
2078
|
+
return useContext(RealtimeVoiceModeContext);
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
export async function readRealtimeVoiceContext(): Promise<{
|
|
2082
|
+
navigation: unknown;
|
|
2083
|
+
url: unknown;
|
|
2084
|
+
}> {
|
|
2085
|
+
return readRealtimeVoiceContextWith({});
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
export async function readRealtimeVoiceContextWith(options: {
|
|
2089
|
+
readAppState?: (key: string) => Promise<unknown> | unknown;
|
|
2090
|
+
resolvePath?: (path: string) => string;
|
|
2091
|
+
}): Promise<{ navigation: unknown; url: unknown }> {
|
|
2092
|
+
if (options.readAppState) {
|
|
2093
|
+
const [navigation, url] = await Promise.all([
|
|
2094
|
+
Promise.resolve(options.readAppState("navigation")).catch(() => null),
|
|
2095
|
+
Promise.resolve(options.readAppState("__url__")).catch(() => null),
|
|
2096
|
+
]);
|
|
2097
|
+
return { navigation, url };
|
|
2098
|
+
}
|
|
2099
|
+
const resolvePath = options.resolvePath ?? ((path: string) => path);
|
|
2100
|
+
const read = async (key: string) => {
|
|
2101
|
+
const response = await fetch(
|
|
2102
|
+
resolvePath(
|
|
2103
|
+
`/_agent-native/application-state/${encodeURIComponent(key)}`,
|
|
2104
|
+
),
|
|
2105
|
+
);
|
|
2106
|
+
if (!response.ok) return null;
|
|
2107
|
+
const body = (await response.json()) as { value?: unknown } | unknown;
|
|
2108
|
+
return body && typeof body === "object" && "value" in body
|
|
2109
|
+
? ((body as { value?: unknown }).value ?? null)
|
|
2110
|
+
: body;
|
|
2111
|
+
};
|
|
2112
|
+
const [navigation, url] = await Promise.all([
|
|
2113
|
+
read("navigation").catch(() => null),
|
|
2114
|
+
read("__url__").catch(() => null),
|
|
2115
|
+
]);
|
|
2116
|
+
return { navigation, url };
|
|
2117
|
+
}
|