@agent-native/toolkit 0.221.0-same-host-mcp-20261027-20261027081850 → 0.221.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/agent-native.eject.json +16 -1
- package/dist/app/agentkit/react/components.d.ts +5 -3
- package/dist/app/agentkit/react/components.d.ts.map +1 -1
- package/dist/app/agentkit/react/components.js +103 -42
- package/dist/app/agentkit/react/components.js.map +1 -1
- package/dist/app/agentkit/react/styles.css +93 -65
- package/dist/app/agentkit/react/voice-transcripts.d.ts +22 -1
- package/dist/app/agentkit/react/voice-transcripts.d.ts.map +1 -1
- package/dist/app/agentkit/react/voice-transcripts.js +209 -8
- package/dist/app/agentkit/react/voice-transcripts.js.map +1 -1
- package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
- package/dist/app/chat/AgentKitAssistantChat.js +55 -40
- package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
- package/dist/app/chat/agentkit-chat/guided-questions.d.ts +1 -0
- package/dist/app/chat/agentkit-chat/guided-questions.d.ts.map +1 -1
- package/dist/app/chat/agentkit-chat/guided-questions.js +18 -6
- package/dist/app/chat/agentkit-chat/guided-questions.js.map +1 -1
- package/dist/app/chat/agentkit-chat/index.d.ts +1 -1
- package/dist/app/chat/agentkit-chat/index.d.ts.map +1 -1
- package/dist/app/chat/agentkit-chat/index.js +1 -1
- package/dist/app/chat/agentkit-chat/index.js.map +1 -1
- package/dist/app/chat/chat/run-recovery.d.ts.map +1 -1
- package/dist/app/chat/chat/run-recovery.js +5 -1
- package/dist/app/chat/chat/run-recovery.js.map +1 -1
- package/dist/app/chat/composer/runtime-adapters.d.ts.map +1 -1
- package/dist/app/chat/composer/runtime-adapters.js +2 -0
- package/dist/app/chat/composer/runtime-adapters.js.map +1 -1
- package/dist/app/chat/reply-connection-suggestion.d.ts +29 -0
- package/dist/app/chat/reply-connection-suggestion.d.ts.map +1 -0
- package/dist/app/chat/reply-connection-suggestion.js +42 -0
- package/dist/app/chat/reply-connection-suggestion.js.map +1 -0
- package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ar-SA.js +7 -0
- package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
- package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/de-DE.js +7 -0
- package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
- package/dist/app/i18n/catalogs/en-US.d.ts +7 -0
- package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/en-US.js +7 -0
- package/dist/app/i18n/catalogs/en-US.js.map +1 -1
- package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/es-ES.js +7 -0
- package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
- package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/fr-FR.js +7 -0
- package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
- package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/hi-IN.js +7 -0
- package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
- package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ja-JP.js +7 -0
- package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
- package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ko-KR.js +7 -0
- package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
- package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/pt-BR.js +7 -0
- package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
- package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/zh-CN.js +7 -0
- package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
- package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/zh-TW.js +7 -0
- package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
- package/dist/app/mcp-approval/McpApprovalReview.d.ts +25 -0
- package/dist/app/mcp-approval/McpApprovalReview.d.ts.map +1 -0
- package/dist/app/mcp-approval/McpApprovalReview.js +342 -0
- package/dist/app/mcp-approval/McpApprovalReview.js.map +1 -0
- package/dist/app/onboarding/FirstRunOnboarding.d.ts.map +1 -1
- package/dist/app/onboarding/FirstRunOnboarding.js +299 -71
- package/dist/app/onboarding/FirstRunOnboarding.js.map +1 -1
- package/dist/app/onboarding/GettingStartedCard.js +1 -1
- package/dist/app/onboarding/GettingStartedCard.js.map +1 -1
- package/dist/app/onboarding/first-run-builder-credits-flag.d.ts +2 -0
- package/dist/app/onboarding/first-run-builder-credits-flag.d.ts.map +1 -0
- package/dist/app/onboarding/first-run-builder-credits-flag.js +8 -0
- package/dist/app/onboarding/first-run-builder-credits-flag.js.map +1 -0
- package/dist/app/providers/AppProviders.d.ts.map +1 -1
- package/dist/app/providers/AppProviders.js +14 -1
- package/dist/app/providers/AppProviders.js.map +1 -1
- package/dist/app/resources/McpConnectionSuggestion.d.ts +2 -4
- package/dist/app/resources/McpConnectionSuggestion.d.ts.map +1 -1
- package/dist/app/resources/McpConnectionSuggestion.js +59 -36
- package/dist/app/resources/McpConnectionSuggestion.js.map +1 -1
- package/dist/app/settings/account/PreferencesSettings.d.ts.map +1 -1
- package/dist/app/settings/account/PreferencesSettings.js +14 -1
- package/dist/app/settings/account/PreferencesSettings.js.map +1 -1
- package/dist/app/settings/account/ThemeFamilySettings.js +2 -2
- package/dist/app/settings/account/ThemeFamilySettings.js.map +1 -1
- package/dist/app/settings/account/search-entries.d.ts +1 -0
- package/dist/app/settings/account/search-entries.d.ts.map +1 -1
- package/dist/app/settings/account/search-entries.js +5 -0
- package/dist/app/settings/account/search-entries.js.map +1 -1
- package/dist/app/settings/shell/SettingsShell.d.ts.map +1 -1
- package/dist/app/settings/shell/SettingsShell.js +26 -2
- package/dist/app/settings/shell/SettingsShell.js.map +1 -1
- package/dist/app/shared/AppShellSkeleton.d.ts.map +1 -1
- package/dist/app/shared/AppShellSkeleton.js +4 -10
- package/dist/app/shared/AppShellSkeleton.js.map +1 -1
- package/dist/app/shared/EnvironmentBadge.d.ts +2 -1
- package/dist/app/shared/EnvironmentBadge.d.ts.map +1 -1
- package/dist/app/shared/EnvironmentBadge.js +2 -3
- package/dist/app/shared/EnvironmentBadge.js.map +1 -1
- package/dist/app/shared/is-builder-io-employee.d.ts +8 -0
- package/dist/app/shared/is-builder-io-employee.d.ts.map +1 -0
- package/dist/app/shared/is-builder-io-employee.js +10 -0
- package/dist/app/shared/is-builder-io-employee.js.map +1 -0
- package/dist/app/styles/agent-conversation.css +30 -17
- package/dist/app/styles/theme-families.css +22 -5
- package/dist/app/styles/tokens/agent-kit.css +3 -3
- package/dist/composer/PromptComposer.d.ts +15 -3
- package/dist/composer/PromptComposer.d.ts.map +1 -1
- package/dist/composer/PromptComposer.js +74 -16
- package/dist/composer/PromptComposer.js.map +1 -1
- package/dist/composer/RealtimeVoiceMode.d.ts +7 -0
- package/dist/composer/RealtimeVoiceMode.d.ts.map +1 -1
- package/dist/composer/RealtimeVoiceMode.js +9 -2
- package/dist/composer/RealtimeVoiceMode.js.map +1 -1
- package/dist/composer/TiptapComposer.d.ts +7 -0
- package/dist/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/composer/TiptapComposer.js +12 -4
- package/dist/composer/TiptapComposer.js.map +1 -1
- package/dist/composer/VoiceButton.d.ts +2 -0
- package/dist/composer/VoiceButton.d.ts.map +1 -1
- package/dist/composer/VoiceButton.js +5 -4
- package/dist/composer/VoiceButton.js.map +1 -1
- package/dist/composer/index.d.ts +2 -2
- package/dist/composer/index.d.ts.map +1 -1
- package/dist/composer/index.js +1 -1
- package/dist/composer/index.js.map +1 -1
- package/dist/composer/prompt-attachments.d.ts +20 -0
- package/dist/composer/prompt-attachments.d.ts.map +1 -1
- package/dist/composer/prompt-attachments.js +188 -11
- package/dist/composer/prompt-attachments.js.map +1 -1
- package/dist/composer/realtime-voice-transcript.d.ts +27 -0
- package/dist/composer/realtime-voice-transcript.d.ts.map +1 -1
- package/dist/composer/realtime-voice-transcript.js +34 -1
- package/dist/composer/realtime-voice-transcript.js.map +1 -1
- package/dist/composer/realtime-voice-turn-metrics.d.ts +43 -0
- package/dist/composer/realtime-voice-turn-metrics.d.ts.map +1 -0
- package/dist/composer/realtime-voice-turn-metrics.js +132 -0
- package/dist/composer/realtime-voice-turn-metrics.js.map +1 -0
- package/dist/composer/runtime-adapters.d.ts +4 -1
- package/dist/composer/runtime-adapters.d.ts.map +1 -1
- package/dist/composer/runtime-adapters.js +2 -1
- package/dist/composer/runtime-adapters.js.map +1 -1
- package/dist/composer/useRealtimeVoiceMode.d.ts +61 -9
- package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -1
- package/dist/composer/useRealtimeVoiceMode.js +536 -99
- package/dist/composer/useRealtimeVoiceMode.js.map +1 -1
- package/dist/composer/voice-chat-icon.d.ts +7 -0
- package/dist/composer/voice-chat-icon.d.ts.map +1 -0
- package/dist/composer/voice-chat-icon.js +14 -0
- package/dist/composer/voice-chat-icon.js.map +1 -0
- package/dist/editor/DragHandle.d.ts +12 -1
- package/dist/editor/DragHandle.d.ts.map +1 -1
- package/dist/editor/DragHandle.js +165 -41
- package/dist/editor/DragHandle.js.map +1 -1
- package/dist/editor/RegistryBlockNode.d.ts +20 -0
- package/dist/editor/RegistryBlockNode.d.ts.map +1 -1
- package/dist/editor/RegistryBlockNode.js +58 -13
- package/dist/editor/RegistryBlockNode.js.map +1 -1
- package/dist/editor/index.d.ts +2 -2
- package/dist/editor/index.d.ts.map +1 -1
- package/dist/editor/index.js +1 -1
- package/dist/editor/index.js.map +1 -1
- package/dist/styles.css +2 -0
- package/dist/ui/dropdown-menu.d.ts.map +1 -1
- package/dist/ui/dropdown-menu.js +5 -1
- package/dist/ui/dropdown-menu.js.map +1 -1
- package/package.json +14 -4
- package/src/app/agentkit/react/components.regenerate-readiness.spec.tsx +3 -1
- package/src/app/agentkit/react/components.spec.tsx +24 -4
- package/src/app/agentkit/react/components.tsx +174 -44
- package/src/app/agentkit/react/composer-context.spec.tsx +3 -0
- package/src/app/agentkit/react/disclosures.spec.tsx +124 -0
- package/src/app/agentkit/react/lifecycle.spec.tsx +8 -2
- package/src/app/agentkit/react/message-actions.spec.tsx +161 -5
- package/src/app/agentkit/react/saved-reply-reload.spec.tsx +206 -0
- package/src/app/agentkit/react/styles.css +93 -65
- package/src/app/agentkit/react/voice-transcripts.spec.tsx +239 -4
- package/src/app/agentkit/react/voice-transcripts.ts +245 -8
- package/src/app/chat/AgentKitAssistantChat.spec.tsx +59 -11
- package/src/app/chat/AgentKitAssistantChat.tsx +74 -62
- package/src/app/chat/agentkit-chat/guided-answers-context.spec.ts +9 -0
- package/src/app/chat/agentkit-chat/guided-questions.tsx +17 -7
- package/src/app/chat/agentkit-chat/index.ts +1 -0
- package/src/app/chat/chat/run-recovery.spec.tsx +45 -0
- package/src/app/chat/chat/run-recovery.tsx +6 -1
- package/src/app/chat/composer/runtime-adapters.tsx +2 -0
- package/src/app/chat/reply-connection-suggestion.spec.ts +121 -0
- package/src/app/chat/reply-connection-suggestion.ts +76 -0
- package/src/app/i18n/catalogs/ar-SA.ts +10 -0
- package/src/app/i18n/catalogs/de-DE.ts +10 -0
- package/src/app/i18n/catalogs/en-US.ts +10 -0
- package/src/app/i18n/catalogs/es-ES.ts +10 -0
- package/src/app/i18n/catalogs/fr-FR.ts +10 -0
- package/src/app/i18n/catalogs/hi-IN.ts +10 -0
- package/src/app/i18n/catalogs/ja-JP.ts +10 -0
- package/src/app/i18n/catalogs/ko-KR.ts +10 -0
- package/src/app/i18n/catalogs/pt-BR.ts +10 -0
- package/src/app/i18n/catalogs/zh-CN.ts +10 -0
- package/src/app/i18n/catalogs/zh-TW.ts +10 -0
- package/src/app/mcp-approval/McpApprovalReview.spec.tsx +687 -0
- package/src/app/mcp-approval/McpApprovalReview.tsx +913 -0
- package/src/app/onboarding/FirstRunOnboarding.spec.tsx +736 -26
- package/src/app/onboarding/FirstRunOnboarding.tsx +473 -145
- package/src/app/onboarding/GettingStartedCard.tsx +1 -1
- package/src/app/onboarding/first-run-builder-credits-flag.spec.ts +13 -0
- package/src/app/onboarding/first-run-builder-credits-flag.ts +9 -0
- package/src/app/providers/AppProviders.spec.tsx +109 -0
- package/src/app/providers/AppProviders.tsx +53 -1
- package/src/app/resources/McpConnectionSuggestion.spec.ts +77 -12
- package/src/app/resources/McpConnectionSuggestion.tsx +69 -51
- package/src/app/settings/VoiceChatSettings.spec.tsx +6 -1
- package/src/app/settings/account/PreferencesSettings.spec.tsx +73 -0
- package/src/app/settings/account/PreferencesSettings.tsx +24 -7
- package/src/app/settings/account/ThemeFamilySettings.tsx +2 -2
- package/src/app/settings/account/search-entries.spec.ts +14 -0
- package/src/app/settings/account/search-entries.ts +9 -0
- package/src/app/settings/shell/SettingsShell.tsx +29 -2
- package/src/app/shared/AppShellSkeleton.spec.tsx +2 -2
- package/src/app/shared/AppShellSkeleton.tsx +3 -16
- package/src/app/shared/EnvironmentBadge.tsx +3 -4
- package/src/app/shared/is-builder-io-employee.ts +17 -0
- package/src/app/styles/agent-conversation.css +30 -17
- package/src/app/styles/theme-families.css +22 -5
- package/src/app/styles/theme-families.spec.ts +73 -0
- package/src/app/styles/tokens/agent-kit.css +3 -3
- package/src/composer/AgentSuggestionBar.overflow.spec.tsx +1 -1
- package/src/composer/AttachmentChip.spec.tsx +87 -0
- package/src/composer/PromptComposer.context.spec.tsx +8 -4
- package/src/composer/PromptComposer.setup-card.spec.tsx +170 -0
- package/src/composer/PromptComposer.tsx +125 -14
- package/src/composer/RealtimeVoiceMode.spec.tsx +48 -0
- package/src/composer/RealtimeVoiceMode.tsx +45 -2
- package/src/composer/TiptapComposer.tsx +25 -0
- package/src/composer/TiptapComposer.voice-toolbar.spec.tsx +4 -1
- package/src/composer/VoiceButton.tsx +8 -3
- package/src/composer/index.ts +4 -0
- package/src/composer/prompt-attachments.spec.ts +160 -8
- package/src/composer/prompt-attachments.ts +221 -12
- package/src/composer/realtime-voice-transcript.spec.ts +36 -0
- package/src/composer/realtime-voice-transcript.ts +56 -1
- package/src/composer/realtime-voice-turn-metrics.spec.ts +175 -0
- package/src/composer/realtime-voice-turn-metrics.ts +192 -0
- package/src/composer/runtime-adapters.tsx +5 -1
- package/src/composer/useRealtimeVoiceMode.session.spec.tsx +487 -2
- package/src/composer/useRealtimeVoiceMode.spec.ts +267 -65
- package/src/composer/useRealtimeVoiceMode.tsx +716 -139
- package/src/composer/voice-chat-icon.tsx +19 -0
- package/src/editor/DragHandle.spec.ts +217 -0
- package/src/editor/DragHandle.ts +214 -55
- package/src/editor/RegistryBlockNode.spec.ts +172 -2
- package/src/editor/RegistryBlockNode.tsx +91 -13
- package/src/editor/index.ts +4 -0
- package/src/styles.css +2 -0
- package/src/ui/dropdown-menu.tsx +4 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TranscriptGrouper, type TranscriptSegment } from "openai/helpers/live";
|
|
1
2
|
import {
|
|
2
3
|
createContext,
|
|
3
4
|
useCallback,
|
|
@@ -15,7 +16,11 @@ import {
|
|
|
15
16
|
smoothRealtimeVoiceLevel,
|
|
16
17
|
type RealtimeVoiceAudioLevelStore,
|
|
17
18
|
} from "./realtime-voice-audio-level.js";
|
|
18
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
realtimeVoiceTranscriptRegistry,
|
|
21
|
+
type RealtimeVoiceWorkCall,
|
|
22
|
+
} from "./realtime-voice-transcript.js";
|
|
23
|
+
import { createRealtimeVoiceTurnMetrics } from "./realtime-voice-turn-metrics.js";
|
|
19
24
|
import type {
|
|
20
25
|
RealtimeVoiceModeCopy,
|
|
21
26
|
RealtimeVoiceModeInlineSettings,
|
|
@@ -38,19 +43,25 @@ const REALTIME_VOICE_TOOL_PATH = "/_agent-native/realtime-voice/tool";
|
|
|
38
43
|
const REALTIME_VOICE_CAPABILITY_HEADER = "X-Agent-Native-Realtime-Capability";
|
|
39
44
|
const REALTIME_VOICE_PROTOCOL_HEADER = "X-Agent-Native-Realtime-Protocol";
|
|
40
45
|
const REALTIME_VOICE_MODEL_HEADER = "X-Agent-Native-Realtime-Model";
|
|
46
|
+
const REALTIME_VOICE_INTELLIGENCE_HEADER =
|
|
47
|
+
"X-Agent-Native-Realtime-Intelligence";
|
|
48
|
+
const REALTIME_VOICE_PRIORITY_TOOLS_HEADER =
|
|
49
|
+
"X-Agent-Native-Realtime-Priority-Tools";
|
|
41
50
|
const REALTIME_VOICE_CONNECTION_TIMEOUT_MS = 15_000;
|
|
42
51
|
const REALTIME_VOICE_MAX_TOOLS = 32;
|
|
43
52
|
const REALTIME_VOICE_MAX_TOOL_SCHEMA_BYTES = 32_000;
|
|
44
53
|
const REALTIME_VOICE_MAX_SESSION_BYTES = 64_000;
|
|
45
54
|
const REALTIME_VOICE_TOOL_UPDATE_TIMEOUT_MS = 5_000;
|
|
46
55
|
const REALTIME_VOICE_LIVE_CLOSE_DRAIN_TIMEOUT_MS = 2_000;
|
|
47
|
-
const REALTIME_VOICE_LIVE_TRANSCRIPT_GAP_MS = 750;
|
|
48
56
|
const REALTIME_VOICE_PRIORITY_TOOL_NAMES = [
|
|
49
57
|
"navigate",
|
|
50
58
|
"set-url-path",
|
|
51
59
|
"set-search-params",
|
|
52
60
|
"view-screen",
|
|
61
|
+
"chat-history",
|
|
53
62
|
"tool-search",
|
|
63
|
+
"web-search",
|
|
64
|
+
"web-request",
|
|
54
65
|
] as const;
|
|
55
66
|
const REALTIME_VOICE_TOOL_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
56
67
|
const REALTIME_VOICE_MICROPHONE_STORAGE_KEY =
|
|
@@ -96,12 +107,18 @@ export interface RealtimeVoicePreferences {
|
|
|
96
107
|
language: RealtimeVoiceLanguage;
|
|
97
108
|
intelligence: RealtimeVoiceIntelligence;
|
|
98
109
|
voice: RealtimeVoice;
|
|
110
|
+
/**
|
|
111
|
+
* False until the user picks a level; until then the app's default applies.
|
|
112
|
+
* Omitted means `intelligence` was chosen.
|
|
113
|
+
*/
|
|
114
|
+
intelligenceChosen?: boolean;
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
export const DEFAULT_REALTIME_VOICE_PREFERENCES: RealtimeVoicePreferences = {
|
|
102
118
|
language: "auto",
|
|
103
119
|
intelligence: "instant",
|
|
104
120
|
voice: "marin",
|
|
121
|
+
intelligenceChosen: false,
|
|
105
122
|
};
|
|
106
123
|
|
|
107
124
|
export const REALTIME_VOICE_AUDIO_CONSTRAINTS: MediaTrackConstraints = {
|
|
@@ -130,8 +147,10 @@ export interface RealtimeVoiceToolResult {
|
|
|
130
147
|
status: "completed" | "failed" | "approval_required";
|
|
131
148
|
output: string;
|
|
132
149
|
approvalKey?: string;
|
|
150
|
+
approvalId?: string;
|
|
133
151
|
expandedTools?: RealtimeVoiceFunctionTool[];
|
|
134
152
|
capability?: string;
|
|
153
|
+
readOnly?: boolean;
|
|
135
154
|
}
|
|
136
155
|
|
|
137
156
|
export interface RealtimeVoiceSessionAnswer {
|
|
@@ -139,6 +158,10 @@ export interface RealtimeVoiceSessionAnswer {
|
|
|
139
158
|
capability?: string;
|
|
140
159
|
protocol?: RealtimeVoiceProtocol;
|
|
141
160
|
model?: string;
|
|
161
|
+
/** The level the session runs at, after the app's default. */
|
|
162
|
+
intelligence?: RealtimeVoiceIntelligence;
|
|
163
|
+
/** App edit tools the session loaded, kept through tool expansions. */
|
|
164
|
+
priorityToolNames?: string[];
|
|
142
165
|
}
|
|
143
166
|
|
|
144
167
|
export interface RealtimeVoiceFunctionTool {
|
|
@@ -169,10 +192,31 @@ export interface RealtimeVoiceModeApi {
|
|
|
169
192
|
setMicrophone: (deviceId: string) => Promise<void>;
|
|
170
193
|
setInputMuted: (muted: boolean) => void;
|
|
171
194
|
setOutputMuted: (muted: boolean) => void;
|
|
172
|
-
start: () => Promise<void>;
|
|
195
|
+
start: (options?: RealtimeVoiceStartOptions) => Promise<void>;
|
|
173
196
|
end: () => void;
|
|
174
197
|
/** Clears a failed session so voice chat can start again. */
|
|
175
198
|
dismissError: () => void;
|
|
199
|
+
/** The tool running now, latest first started; null while none runs. */
|
|
200
|
+
currentToolName: string | null;
|
|
201
|
+
/** Tool calls waiting for the user to approve them, oldest first. */
|
|
202
|
+
approvals: readonly RealtimeVoiceApproval[];
|
|
203
|
+
/** Runs an approved call and tells the assistant how it went. */
|
|
204
|
+
resolveApproval: (callId: string, approved: boolean) => Promise<void>;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface RealtimeVoiceApproval {
|
|
208
|
+
/** The provider's id for the call that asked. */
|
|
209
|
+
callId: string;
|
|
210
|
+
toolName: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface RealtimeVoiceStartOptions {
|
|
214
|
+
/**
|
|
215
|
+
* The thread that receives the session's transcripts. Defaults to the
|
|
216
|
+
* thread on screen at start; pass one when the session starts before its
|
|
217
|
+
* thread is shown, such as from a page that hands off to the chat.
|
|
218
|
+
*/
|
|
219
|
+
threadId?: string;
|
|
176
220
|
}
|
|
177
221
|
|
|
178
222
|
export interface RealtimeVoiceMicrophone {
|
|
@@ -294,19 +338,37 @@ export function normalizeRealtimeVoicePreferences(
|
|
|
294
338
|
value && typeof value === "object" && !Array.isArray(value)
|
|
295
339
|
? (value as Record<string, unknown>)
|
|
296
340
|
: {};
|
|
341
|
+
const storedIntelligence = isOneOf(
|
|
342
|
+
REALTIME_VOICE_INTELLIGENCE_LEVELS,
|
|
343
|
+
record.intelligence,
|
|
344
|
+
)
|
|
345
|
+
? record.intelligence
|
|
346
|
+
: undefined;
|
|
347
|
+
// Saves from before `intelligenceChosen` stored "instant" for everyone, so
|
|
348
|
+
// only a non-default level among them shows a pick.
|
|
349
|
+
const chosenIntelligence =
|
|
350
|
+
typeof record.intelligenceChosen === "boolean"
|
|
351
|
+
? record.intelligenceChosen
|
|
352
|
+
? storedIntelligence
|
|
353
|
+
: undefined
|
|
354
|
+
: storedIntelligence !== DEFAULT_REALTIME_VOICE_PREFERENCES.intelligence
|
|
355
|
+
? storedIntelligence
|
|
356
|
+
: undefined;
|
|
357
|
+
// A level saved as not chosen is the app default a session last reported.
|
|
358
|
+
const appDefaultIntelligence =
|
|
359
|
+
record.intelligenceChosen === false ? storedIntelligence : undefined;
|
|
297
360
|
return {
|
|
298
361
|
language: isOneOf(REALTIME_VOICE_LANGUAGES, record.language)
|
|
299
362
|
? record.language
|
|
300
363
|
: DEFAULT_REALTIME_VOICE_PREFERENCES.language,
|
|
301
|
-
intelligence:
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
? record.intelligence
|
|
306
|
-
: DEFAULT_REALTIME_VOICE_PREFERENCES.intelligence,
|
|
364
|
+
intelligence:
|
|
365
|
+
chosenIntelligence ??
|
|
366
|
+
appDefaultIntelligence ??
|
|
367
|
+
DEFAULT_REALTIME_VOICE_PREFERENCES.intelligence,
|
|
307
368
|
voice: isOneOf(REALTIME_VOICES, record.voice)
|
|
308
369
|
? record.voice
|
|
309
370
|
: DEFAULT_REALTIME_VOICE_PREFERENCES.voice,
|
|
371
|
+
intelligenceChosen: chosenIntelligence !== undefined,
|
|
310
372
|
};
|
|
311
373
|
}
|
|
312
374
|
|
|
@@ -327,10 +389,16 @@ export function resolveRealtimeVoiceLanguage(
|
|
|
327
389
|
export function realtimeVoiceReasoningEffort(
|
|
328
390
|
intelligence: RealtimeVoiceIntelligence,
|
|
329
391
|
protocol?: RealtimeVoiceProtocol,
|
|
330
|
-
): "minimal" | "low" | "medium" {
|
|
392
|
+
): "minimal" | "low" | "medium" | "high" {
|
|
393
|
+
// The delegated Live model rejects "minimal", so its levels start at "low".
|
|
394
|
+
if (protocol === "live") {
|
|
395
|
+
if (intelligence === "balanced") return "medium";
|
|
396
|
+
if (intelligence === "deep") return "high";
|
|
397
|
+
return "low";
|
|
398
|
+
}
|
|
331
399
|
if (intelligence === "balanced") return "low";
|
|
332
400
|
if (intelligence === "deep") return "medium";
|
|
333
|
-
return
|
|
401
|
+
return "minimal";
|
|
334
402
|
}
|
|
335
403
|
|
|
336
404
|
export function createRealtimeVoicePreferenceUpdate(
|
|
@@ -398,6 +466,111 @@ export function createRealtimeVoiceLanguageUpdate(
|
|
|
398
466
|
};
|
|
399
467
|
}
|
|
400
468
|
|
|
469
|
+
interface RealtimeVoiceWorkSegment {
|
|
470
|
+
id: string;
|
|
471
|
+
threadId: string;
|
|
472
|
+
/** The GPT-Live delegation the calls ran under; one delegation is one request. */
|
|
473
|
+
delegationId: string | undefined;
|
|
474
|
+
calls: RealtimeVoiceWorkCall[];
|
|
475
|
+
/** The reply is over; the segment ends once its last call does. */
|
|
476
|
+
closing: boolean;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function publishRealtimeVoiceWorkSegment(
|
|
480
|
+
segment: RealtimeVoiceWorkSegment,
|
|
481
|
+
completedAt?: string,
|
|
482
|
+
): void {
|
|
483
|
+
if (segment.calls.length === 0) return;
|
|
484
|
+
realtimeVoiceTranscriptRegistry.publishWork({
|
|
485
|
+
id: segment.id,
|
|
486
|
+
threadId: segment.threadId,
|
|
487
|
+
calls: segment.calls.map((call) => ({ ...call })),
|
|
488
|
+
...(completedAt ? { completedAt } : {}),
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The reply's open work a new call joins, or new work; none without a thread.
|
|
494
|
+
* A call from a different delegation is a new request.
|
|
495
|
+
*/
|
|
496
|
+
function openRealtimeVoiceWorkSegment(
|
|
497
|
+
ref: { current: RealtimeVoiceWorkSegment | null },
|
|
498
|
+
threadId: string | undefined,
|
|
499
|
+
callId: string,
|
|
500
|
+
delegationId: string | undefined,
|
|
501
|
+
): RealtimeVoiceWorkSegment | null {
|
|
502
|
+
if (!threadId) return null;
|
|
503
|
+
const current = ref.current;
|
|
504
|
+
if (
|
|
505
|
+
current?.threadId === threadId &&
|
|
506
|
+
(!current.delegationId ||
|
|
507
|
+
!delegationId ||
|
|
508
|
+
current.delegationId === delegationId)
|
|
509
|
+
) {
|
|
510
|
+
current.delegationId ??= delegationId;
|
|
511
|
+
return current;
|
|
512
|
+
}
|
|
513
|
+
if (current) closeRealtimeVoiceWorkSegment(ref, current);
|
|
514
|
+
const segment = {
|
|
515
|
+
id: callId,
|
|
516
|
+
threadId,
|
|
517
|
+
delegationId,
|
|
518
|
+
calls: [],
|
|
519
|
+
closing: false,
|
|
520
|
+
};
|
|
521
|
+
ref.current = segment;
|
|
522
|
+
return segment;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Ends a reply's work. It ends at its last call's completion, so the reply
|
|
527
|
+
* that follows the work reads below it.
|
|
528
|
+
*/
|
|
529
|
+
interface PendingApproval {
|
|
530
|
+
callId: string;
|
|
531
|
+
name: string;
|
|
532
|
+
args: Record<string, unknown>;
|
|
533
|
+
/** The server's approval record; a decision names exactly this one. */
|
|
534
|
+
approvalId: string;
|
|
535
|
+
workCall: RealtimeVoiceWorkCall;
|
|
536
|
+
workSegment: RealtimeVoiceWorkSegment | null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Ends a call that waited for approval: `result` is null when it never ran. */
|
|
540
|
+
function finishPendingApproval(
|
|
541
|
+
pending: PendingApproval,
|
|
542
|
+
result: RealtimeVoiceToolResult | null,
|
|
543
|
+
): void {
|
|
544
|
+
const { workCall, workSegment } = pending;
|
|
545
|
+
workCall.status =
|
|
546
|
+
!result || result.status === "approval_required"
|
|
547
|
+
? "cancelled"
|
|
548
|
+
: result.status;
|
|
549
|
+
if (result) workCall.output = result.output;
|
|
550
|
+
workCall.completedAt = new Date().toISOString();
|
|
551
|
+
if (!workSegment) return;
|
|
552
|
+
if (workSegment.closing) {
|
|
553
|
+
closeRealtimeVoiceWorkSegment({ current: null }, workSegment);
|
|
554
|
+
} else {
|
|
555
|
+
publishRealtimeVoiceWorkSegment(workSegment);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function closeRealtimeVoiceWorkSegment(
|
|
560
|
+
ref: { current: RealtimeVoiceWorkSegment | null },
|
|
561
|
+
segment: RealtimeVoiceWorkSegment | null = ref.current,
|
|
562
|
+
): void {
|
|
563
|
+
if (!segment) return;
|
|
564
|
+
if (ref.current === segment) ref.current = null;
|
|
565
|
+
segment.closing = true;
|
|
566
|
+
if (segment.calls.some((call) => call.status === "running")) return;
|
|
567
|
+
const completedAt = segment.calls
|
|
568
|
+
.map((call) => call.completedAt ?? call.startedAt)
|
|
569
|
+
.sort()
|
|
570
|
+
.at(-1);
|
|
571
|
+
publishRealtimeVoiceWorkSegment(segment, completedAt);
|
|
572
|
+
}
|
|
573
|
+
|
|
401
574
|
export interface CompletedRealtimeVoiceTranscript {
|
|
402
575
|
role: "user" | "assistant";
|
|
403
576
|
text: string;
|
|
@@ -410,56 +583,78 @@ export interface RealtimeVoiceTranscriptSequencer {
|
|
|
410
583
|
reset: (options?: { preserveQueuedTranscripts?: boolean }) => void;
|
|
411
584
|
}
|
|
412
585
|
|
|
586
|
+
/**
|
|
587
|
+
* A GPT-Live turn as the transcript grouper sees it. Declared here so the
|
|
588
|
+
* toolkit's public types never pull in the OpenAI SDK's Node globals.
|
|
589
|
+
*/
|
|
590
|
+
export interface RealtimeVoiceLiveTurn {
|
|
591
|
+
id: string;
|
|
592
|
+
speaker: CompletedRealtimeVoiceTranscript["role"];
|
|
593
|
+
text: string;
|
|
594
|
+
startMs: number;
|
|
595
|
+
endMs: number;
|
|
596
|
+
}
|
|
597
|
+
|
|
413
598
|
interface SequencedRealtimeVoiceTranscript {
|
|
414
599
|
status: "pending" | "completed" | "skipped";
|
|
415
600
|
role?: CompletedRealtimeVoiceTranscript["role"];
|
|
416
601
|
transcript?: CompletedRealtimeVoiceTranscript;
|
|
417
602
|
}
|
|
418
603
|
|
|
419
|
-
interface LiveRealtimeVoiceTranscriptBuffer {
|
|
420
|
-
role: CompletedRealtimeVoiceTranscript["role"];
|
|
421
|
-
text: string;
|
|
422
|
-
lastEndMs?: number;
|
|
423
|
-
sequence: number;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
604
|
/**
|
|
427
605
|
* Orders provider transcripts into conversation order. `publish` receives each
|
|
428
606
|
* final transcript; `stream`, when given, receives the growing text of a turn
|
|
429
|
-
* under the same `providerId` its final transcript will carry.
|
|
607
|
+
* under the same `providerId` its final transcript will carry. GPT-Live turns
|
|
608
|
+
* come from the SDK's transcript grouper, which splits on speaker changes and
|
|
609
|
+
* two-second silences and drops short backchannels heard over the reply.
|
|
430
610
|
*/
|
|
431
611
|
export function createRealtimeVoiceTranscriptSequencer(
|
|
432
612
|
publish: (transcript: CompletedRealtimeVoiceTranscript) => void,
|
|
433
613
|
stream?: (transcript: CompletedRealtimeVoiceTranscript) => void,
|
|
614
|
+
/** Each GPT-Live turn as it grows, with its place on the session clock. */
|
|
615
|
+
onLiveTurn?: (turn: RealtimeVoiceLiveTurn) => void,
|
|
434
616
|
): RealtimeVoiceTranscriptSequencer {
|
|
435
617
|
const order: string[] = [];
|
|
436
618
|
const items = new Map<string, SequencedRealtimeVoiceTranscript>();
|
|
437
619
|
const settledItemIds = new Set<string>();
|
|
438
620
|
const receivedTranscriptIds = new Set<string>();
|
|
439
|
-
const liveBuffers = new Map<
|
|
440
|
-
CompletedRealtimeVoiceTranscript["role"],
|
|
441
|
-
LiveRealtimeVoiceTranscriptBuffer
|
|
442
|
-
>();
|
|
443
621
|
const streamingText = new Map<string, string>();
|
|
444
622
|
const streamedText = new Map<string, string>();
|
|
445
|
-
let
|
|
623
|
+
let liveGrouper: TranscriptGrouper | undefined;
|
|
624
|
+
|
|
625
|
+
const liveTranscript = (segment: TranscriptSegment) => {
|
|
626
|
+
const text = segment.text.trim();
|
|
627
|
+
if (!text) return undefined;
|
|
628
|
+
return stream
|
|
629
|
+
? { role: segment.speaker, text, providerId: segment.id }
|
|
630
|
+
: { role: segment.speaker, text };
|
|
631
|
+
};
|
|
446
632
|
|
|
447
|
-
|
|
448
|
-
|
|
633
|
+
// Listeners check that their grouper is still current, so a discarded one
|
|
634
|
+
// never publishes what it closes on the way out.
|
|
635
|
+
const openLiveGrouper = () => {
|
|
636
|
+
if (liveGrouper) return liveGrouper;
|
|
637
|
+
const grouper = new TranscriptGrouper();
|
|
638
|
+
grouper.on("segment.updated", (segment) => {
|
|
639
|
+
if (grouper !== liveGrouper) return;
|
|
640
|
+
onLiveTurn?.(segment);
|
|
641
|
+
const transcript = liveTranscript(segment);
|
|
642
|
+
if (stream && transcript) stream(transcript);
|
|
643
|
+
});
|
|
644
|
+
grouper.on("segment.closed", ({ segment }) => {
|
|
645
|
+
const transcript = liveTranscript(segment);
|
|
646
|
+
if (grouper === liveGrouper && transcript) publish(transcript);
|
|
647
|
+
});
|
|
648
|
+
liveGrouper = grouper;
|
|
649
|
+
return grouper;
|
|
650
|
+
};
|
|
449
651
|
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const text = buffer.text.trim();
|
|
457
|
-
if (!text) return;
|
|
458
|
-
publish(
|
|
459
|
-
stream
|
|
460
|
-
? { role, text, providerId: liveProviderId(buffer) }
|
|
461
|
-
: { role, text },
|
|
462
|
-
);
|
|
652
|
+
const closeLiveGrouper = (keepText: boolean) => {
|
|
653
|
+
const grouper = liveGrouper;
|
|
654
|
+
if (!grouper) return;
|
|
655
|
+
if (!keepText) liveGrouper = undefined;
|
|
656
|
+
grouper.close();
|
|
657
|
+
liveGrouper = undefined;
|
|
463
658
|
};
|
|
464
659
|
|
|
465
660
|
// A turn appears once every earlier turn has appeared, so a fast reply
|
|
@@ -485,14 +680,6 @@ export function createRealtimeVoiceTranscriptSequencer(
|
|
|
485
680
|
}
|
|
486
681
|
};
|
|
487
682
|
|
|
488
|
-
const flushLiveTranscripts = () => {
|
|
489
|
-
for (const buffer of [...liveBuffers.values()].sort(
|
|
490
|
-
(left, right) => left.sequence - right.sequence,
|
|
491
|
-
)) {
|
|
492
|
-
flushLiveTranscript(buffer.role);
|
|
493
|
-
}
|
|
494
|
-
};
|
|
495
|
-
|
|
496
683
|
const reserve = (
|
|
497
684
|
id: string,
|
|
498
685
|
role?: CompletedRealtimeVoiceTranscript["role"],
|
|
@@ -536,37 +723,22 @@ export function createRealtimeVoiceTranscriptSequencer(
|
|
|
536
723
|
event.type === "session.input_transcript.delta" ||
|
|
537
724
|
event.type === "session.output_transcript.delta"
|
|
538
725
|
) {
|
|
539
|
-
const
|
|
726
|
+
const type =
|
|
540
727
|
event.type === "session.input_transcript.delta"
|
|
541
|
-
? "
|
|
542
|
-
: "
|
|
543
|
-
const
|
|
544
|
-
typeof event.start_ms === "number" ? event.start_ms : undefined;
|
|
545
|
-
const endMs = typeof event.end_ms === "number" ? event.end_ms : startMs;
|
|
546
|
-
let buffer = liveBuffers.get(role);
|
|
728
|
+
? "session.input_transcript.delta"
|
|
729
|
+
: "session.output_transcript.delta";
|
|
730
|
+
const { event_id, delta, start_ms, end_ms } = event;
|
|
547
731
|
if (
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
732
|
+
typeof event_id !== "string" ||
|
|
733
|
+
typeof delta !== "string" ||
|
|
734
|
+
typeof start_ms !== "number" ||
|
|
735
|
+
typeof end_ms !== "number"
|
|
552
736
|
) {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
if (!buffer) {
|
|
557
|
-
buffer = {
|
|
558
|
-
role,
|
|
559
|
-
text: "",
|
|
560
|
-
sequence: ++liveSequence,
|
|
561
|
-
};
|
|
562
|
-
liveBuffers.set(role, buffer);
|
|
563
|
-
}
|
|
564
|
-
if (typeof event.delta === "string") buffer.text += event.delta;
|
|
565
|
-
if (endMs !== undefined) buffer.lastEndMs = endMs;
|
|
566
|
-
const text = buffer.text.trim();
|
|
567
|
-
if (stream && text) {
|
|
568
|
-
stream({ role, text, providerId: liveProviderId(buffer) });
|
|
737
|
+
throw new Error(
|
|
738
|
+
"GPT-Live sent a transcript delta without an id, text, or timing.",
|
|
739
|
+
);
|
|
569
740
|
}
|
|
741
|
+
openLiveGrouper().push({ type, event_id, delta, start_ms, end_ms });
|
|
570
742
|
return;
|
|
571
743
|
}
|
|
572
744
|
if (
|
|
@@ -591,7 +763,7 @@ export function createRealtimeVoiceTranscriptSequencer(
|
|
|
591
763
|
emitStreams();
|
|
592
764
|
return;
|
|
593
765
|
}
|
|
594
|
-
if (event.type === "session.closed")
|
|
766
|
+
if (event.type === "session.closed") closeLiveGrouper(true);
|
|
595
767
|
if (
|
|
596
768
|
event.type === "conversation.item.added" ||
|
|
597
769
|
event.type === "conversation.item.created"
|
|
@@ -669,7 +841,7 @@ export function createRealtimeVoiceTranscriptSequencer(
|
|
|
669
841
|
emitStreams();
|
|
670
842
|
},
|
|
671
843
|
flush() {
|
|
672
|
-
|
|
844
|
+
closeLiveGrouper(true);
|
|
673
845
|
if (!stream) return;
|
|
674
846
|
// The session is over: a turn that streamed but never got its final
|
|
675
847
|
// transcript keeps the text that was heard.
|
|
@@ -696,9 +868,10 @@ export function createRealtimeVoiceTranscriptSequencer(
|
|
|
696
868
|
}
|
|
697
869
|
},
|
|
698
870
|
reset(options) {
|
|
699
|
-
|
|
700
|
-
|
|
871
|
+
// A failed response leaves what was already heard; a new session
|
|
872
|
+
// starts clean.
|
|
701
873
|
if (options?.preserveQueuedTranscripts) return;
|
|
874
|
+
closeLiveGrouper(false);
|
|
702
875
|
order.length = 0;
|
|
703
876
|
items.clear();
|
|
704
877
|
settledItemIds.clear();
|
|
@@ -985,7 +1158,12 @@ export async function createRealtimeVoiceSessionWithCapability(
|
|
|
985
1158
|
preferences.language,
|
|
986
1159
|
options.browserLanguages,
|
|
987
1160
|
),
|
|
988
|
-
|
|
1161
|
+
...(preferences.intelligenceChosen !== false
|
|
1162
|
+
? {
|
|
1163
|
+
[REALTIME_VOICE_INTELLIGENCE_HEADER]:
|
|
1164
|
+
preferences.intelligence,
|
|
1165
|
+
}
|
|
1166
|
+
: {}),
|
|
989
1167
|
"X-Agent-Native-Realtime-Voice": preferences.voice,
|
|
990
1168
|
}
|
|
991
1169
|
: {}),
|
|
@@ -1008,11 +1186,29 @@ export async function createRealtimeVoiceSessionWithCapability(
|
|
|
1008
1186
|
: undefined;
|
|
1009
1187
|
const modelHeader = response.headers.get(REALTIME_VOICE_MODEL_HEADER);
|
|
1010
1188
|
const model = modelHeader?.trim() || undefined;
|
|
1189
|
+
const intelligenceHeader = response.headers
|
|
1190
|
+
.get(REALTIME_VOICE_INTELLIGENCE_HEADER)
|
|
1191
|
+
?.trim();
|
|
1192
|
+
const intelligence = isOneOf(
|
|
1193
|
+
REALTIME_VOICE_INTELLIGENCE_LEVELS,
|
|
1194
|
+
intelligenceHeader,
|
|
1195
|
+
)
|
|
1196
|
+
? intelligenceHeader
|
|
1197
|
+
: undefined;
|
|
1198
|
+
const priorityToolNames = (
|
|
1199
|
+
response.headers.get(REALTIME_VOICE_PRIORITY_TOOLS_HEADER) ?? ""
|
|
1200
|
+
)
|
|
1201
|
+
.split(",")
|
|
1202
|
+
.map((name) => name.trim())
|
|
1203
|
+
.filter((name) => REALTIME_VOICE_TOOL_NAME.test(name))
|
|
1204
|
+
.slice(0, REALTIME_VOICE_MAX_TOOLS);
|
|
1011
1205
|
return {
|
|
1012
1206
|
sdp,
|
|
1013
1207
|
...(capability ? { capability } : {}),
|
|
1014
1208
|
...(protocol ? { protocol } : {}),
|
|
1015
1209
|
...(model ? { model } : {}),
|
|
1210
|
+
...(intelligence ? { intelligence } : {}),
|
|
1211
|
+
...(priorityToolNames.length > 0 ? { priorityToolNames } : {}),
|
|
1016
1212
|
};
|
|
1017
1213
|
}
|
|
1018
1214
|
|
|
@@ -1032,10 +1228,14 @@ export async function executeRealtimeVoiceTool(input: {
|
|
|
1032
1228
|
name: string;
|
|
1033
1229
|
args: Record<string, unknown>;
|
|
1034
1230
|
callId: string;
|
|
1035
|
-
sessionId?: string;
|
|
1036
|
-
browserTabId?: string;
|
|
1037
|
-
capability?: string;
|
|
1038
|
-
signal?: AbortSignal;
|
|
1231
|
+
sessionId?: string | undefined;
|
|
1232
|
+
browserTabId?: string | undefined;
|
|
1233
|
+
capability?: string | undefined;
|
|
1234
|
+
signal?: AbortSignal | undefined;
|
|
1235
|
+
/** Runs the call this approval record was asked for, once. */
|
|
1236
|
+
approvalId?: string | undefined;
|
|
1237
|
+
/** Declines that approval record instead of running the call. */
|
|
1238
|
+
declinedApprovalId?: string | undefined;
|
|
1039
1239
|
}): Promise<RealtimeVoiceToolResult> {
|
|
1040
1240
|
const response = await fetch(REALTIME_VOICE_TOOL_PATH, {
|
|
1041
1241
|
method: "POST",
|
|
@@ -1055,6 +1255,10 @@ export async function executeRealtimeVoiceTool(input: {
|
|
|
1055
1255
|
callId: input.callId,
|
|
1056
1256
|
sessionId: input.sessionId,
|
|
1057
1257
|
browserTabId: input.browserTabId,
|
|
1258
|
+
...(input.approvalId ? { approvalId: input.approvalId } : {}),
|
|
1259
|
+
...(input.declinedApprovalId
|
|
1260
|
+
? { declinedApprovalId: input.declinedApprovalId }
|
|
1261
|
+
: {}),
|
|
1058
1262
|
}),
|
|
1059
1263
|
signal: input.signal,
|
|
1060
1264
|
});
|
|
@@ -1146,7 +1350,11 @@ function createRealtimeVoiceToolManifestUpdate(
|
|
|
1146
1350
|
? {
|
|
1147
1351
|
delegation: {
|
|
1148
1352
|
type: "responses",
|
|
1149
|
-
|
|
1353
|
+
// Strict schemas make every optional parameter required.
|
|
1354
|
+
responses: {
|
|
1355
|
+
tools: tools.map((tool) => ({ ...tool, strict: false })),
|
|
1356
|
+
tool_choice: "auto",
|
|
1357
|
+
},
|
|
1150
1358
|
},
|
|
1151
1359
|
}
|
|
1152
1360
|
: {
|
|
@@ -1161,6 +1369,7 @@ export function mergeRealtimeVoiceToolManifest(
|
|
|
1161
1369
|
currentTools: readonly RealtimeVoiceFunctionTool[],
|
|
1162
1370
|
expandedTools: readonly RealtimeVoiceFunctionTool[],
|
|
1163
1371
|
protocol: RealtimeVoiceProtocol = "realtime",
|
|
1372
|
+
appPriorityToolNames: readonly string[] = [],
|
|
1164
1373
|
): RealtimeVoiceFunctionTool[] {
|
|
1165
1374
|
const current = new Map<string, RealtimeVoiceFunctionTool>();
|
|
1166
1375
|
for (const value of currentTools) {
|
|
@@ -1180,7 +1389,10 @@ export function mergeRealtimeVoiceToolManifest(
|
|
|
1180
1389
|
seen.add(tool.name);
|
|
1181
1390
|
candidates.push(tool);
|
|
1182
1391
|
};
|
|
1183
|
-
for (const name of
|
|
1392
|
+
for (const name of [
|
|
1393
|
+
...REALTIME_VOICE_PRIORITY_TOOL_NAMES,
|
|
1394
|
+
...appPriorityToolNames,
|
|
1395
|
+
]) {
|
|
1184
1396
|
add(expanded.get(name) ?? current.get(name));
|
|
1185
1397
|
}
|
|
1186
1398
|
for (const tool of expanded.values()) add(tool);
|
|
@@ -1205,6 +1417,31 @@ export function mergeRealtimeVoiceToolManifest(
|
|
|
1205
1417
|
return packed;
|
|
1206
1418
|
}
|
|
1207
1419
|
|
|
1420
|
+
/** Tells the assistant what the user decided about a call that asked. */
|
|
1421
|
+
export function createRealtimeVoiceApprovalOutcomeEvent(
|
|
1422
|
+
toolName: string,
|
|
1423
|
+
result: RealtimeVoiceToolResult | null,
|
|
1424
|
+
protocol: RealtimeVoiceProtocol = "realtime",
|
|
1425
|
+
): Record<string, unknown> {
|
|
1426
|
+
const text = !result
|
|
1427
|
+
? `The user declined ${toolName}, so it did not run.`
|
|
1428
|
+
: result.status === "approval_required"
|
|
1429
|
+
? `${toolName} asked for approval again, so it has not run. The user can approve it on screen.`
|
|
1430
|
+
: `The user approved ${toolName}. ${JSON.stringify({
|
|
1431
|
+
status: result.status,
|
|
1432
|
+
output: result.output,
|
|
1433
|
+
})}`;
|
|
1434
|
+
return {
|
|
1435
|
+
type:
|
|
1436
|
+
protocol === "live" ? "response.item.create" : "conversation.item.create",
|
|
1437
|
+
item: {
|
|
1438
|
+
type: "message",
|
|
1439
|
+
role: "system",
|
|
1440
|
+
content: [{ type: "input_text", text }],
|
|
1441
|
+
},
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1208
1445
|
function createRealtimeVoiceToolResultEvent(
|
|
1209
1446
|
result: RealtimeVoiceToolResult,
|
|
1210
1447
|
protocol: RealtimeVoiceProtocol = "realtime",
|
|
@@ -1229,6 +1466,7 @@ function createRealtimeVoiceToolResultEvent(
|
|
|
1229
1466
|
export interface RealtimeVoiceToolManifestCoordinator {
|
|
1230
1467
|
enqueue: (result: RealtimeVoiceToolResult) => void;
|
|
1231
1468
|
setProtocol: (protocol: RealtimeVoiceProtocol) => void;
|
|
1469
|
+
setPriorityToolNames: (names: readonly string[]) => void;
|
|
1232
1470
|
setSessionTools: (tools: readonly RealtimeVoiceFunctionTool[]) => void;
|
|
1233
1471
|
handleError: (eventId: string | undefined, message?: string) => boolean;
|
|
1234
1472
|
reset: (options?: { preserveSessionTools?: boolean }) => void;
|
|
@@ -1241,6 +1479,7 @@ export function createRealtimeVoiceToolManifestCoordinator(
|
|
|
1241
1479
|
): RealtimeVoiceToolManifestCoordinator {
|
|
1242
1480
|
let currentTools: RealtimeVoiceFunctionTool[] = [];
|
|
1243
1481
|
let protocol: RealtimeVoiceProtocol = "realtime";
|
|
1482
|
+
let priorityToolNames: readonly string[] = [];
|
|
1244
1483
|
const queue: RealtimeVoiceToolResult[] = [];
|
|
1245
1484
|
let updateSequence = 0;
|
|
1246
1485
|
let active:
|
|
@@ -1293,6 +1532,7 @@ export function createRealtimeVoiceToolManifestCoordinator(
|
|
|
1293
1532
|
currentTools,
|
|
1294
1533
|
expanded,
|
|
1295
1534
|
protocol,
|
|
1535
|
+
priorityToolNames,
|
|
1296
1536
|
);
|
|
1297
1537
|
const mergedNames = new Set(merged.map((tool) => tool.name));
|
|
1298
1538
|
const expectedNames = expanded
|
|
@@ -1326,8 +1566,16 @@ export function createRealtimeVoiceToolManifestCoordinator(
|
|
|
1326
1566
|
setProtocol(nextProtocol) {
|
|
1327
1567
|
protocol = nextProtocol;
|
|
1328
1568
|
},
|
|
1569
|
+
setPriorityToolNames(names) {
|
|
1570
|
+
priorityToolNames = [...names];
|
|
1571
|
+
},
|
|
1329
1572
|
setSessionTools(tools) {
|
|
1330
|
-
currentTools = mergeRealtimeVoiceToolManifest(
|
|
1573
|
+
currentTools = mergeRealtimeVoiceToolManifest(
|
|
1574
|
+
[],
|
|
1575
|
+
tools,
|
|
1576
|
+
protocol,
|
|
1577
|
+
priorityToolNames,
|
|
1578
|
+
);
|
|
1331
1579
|
if (!active) return;
|
|
1332
1580
|
const names = new Set(currentTools.map((tool) => tool.name));
|
|
1333
1581
|
if (active.expectedNames.every((name) => names.has(name))) finish();
|
|
@@ -1341,7 +1589,10 @@ export function createRealtimeVoiceToolManifestCoordinator(
|
|
|
1341
1589
|
if (active) clearTimeout(active.timer);
|
|
1342
1590
|
active = undefined;
|
|
1343
1591
|
queue.length = 0;
|
|
1344
|
-
if (!options?.preserveSessionTools)
|
|
1592
|
+
if (!options?.preserveSessionTools) {
|
|
1593
|
+
currentTools = [];
|
|
1594
|
+
priorityToolNames = [];
|
|
1595
|
+
}
|
|
1345
1596
|
protocol = "realtime";
|
|
1346
1597
|
},
|
|
1347
1598
|
getTools() {
|
|
@@ -1482,6 +1733,16 @@ export function createRealtimeVoiceModeCopy(
|
|
|
1482
1733
|
voiceSettings: t("agentChat.voiceMode.voiceSettings", {
|
|
1483
1734
|
defaultValue: "Voice settings",
|
|
1484
1735
|
}),
|
|
1736
|
+
thinking: t("agentChat.status.thinking", { defaultValue: "Thinking" }),
|
|
1737
|
+
approval: {
|
|
1738
|
+
question: (toolLabel) =>
|
|
1739
|
+
t("agentChat.approval.question", {
|
|
1740
|
+
tool: toolLabel,
|
|
1741
|
+
defaultValue: "Approve to run {{tool}}?",
|
|
1742
|
+
}),
|
|
1743
|
+
approve: t("agentChat.approval.approve", { defaultValue: "Approve" }),
|
|
1744
|
+
deny: t("agentChat.approval.deny", { defaultValue: "Deny" }),
|
|
1745
|
+
},
|
|
1485
1746
|
settings: {
|
|
1486
1747
|
language: t("agentChat.voiceMode.settings.language", {
|
|
1487
1748
|
defaultValue: "Language",
|
|
@@ -1693,10 +1954,37 @@ function useRealtimeVoiceModeController(
|
|
|
1693
1954
|
const lastUserTextRef = useRef("");
|
|
1694
1955
|
const lastAssistantTextRef = useRef("");
|
|
1695
1956
|
const transcriptThreadIdRef = useRef<string | undefined>(undefined);
|
|
1957
|
+
const requestedTranscriptThreadIdRef = useRef<string | undefined>(undefined);
|
|
1696
1958
|
const liveCloseDrainTimerRef = useRef<number | null>(null);
|
|
1697
1959
|
const liveCloseDrainFinishRef = useRef<(() => void) | null>(null);
|
|
1698
1960
|
const toolAbortControllersRef = useRef(new Set<AbortController>());
|
|
1961
|
+
const pendingApprovalsRef = useRef(new Map<string, PendingApproval>());
|
|
1962
|
+
const runningToolsRef = useRef(new Map<string, string>());
|
|
1963
|
+
const [currentToolName, setCurrentToolName] = useState<string | null>(null);
|
|
1964
|
+
const trackRunningTool = useCallback(
|
|
1965
|
+
(callId: string, name: string | null) => {
|
|
1966
|
+
const running = runningToolsRef.current;
|
|
1967
|
+
if (name) running.set(callId, name);
|
|
1968
|
+
else running.delete(callId);
|
|
1969
|
+
setCurrentToolName([...running.values()].at(-1) ?? null);
|
|
1970
|
+
},
|
|
1971
|
+
[],
|
|
1972
|
+
);
|
|
1973
|
+
const [approvals, setApprovals] = useState<readonly RealtimeVoiceApproval[]>(
|
|
1974
|
+
[],
|
|
1975
|
+
);
|
|
1976
|
+
const syncApprovals = useCallback(() => {
|
|
1977
|
+
setApprovals(
|
|
1978
|
+
[...pendingApprovalsRef.current.values()].map(({ callId, name }) => ({
|
|
1979
|
+
callId,
|
|
1980
|
+
toolName: name,
|
|
1981
|
+
})),
|
|
1982
|
+
);
|
|
1983
|
+
}, []);
|
|
1699
1984
|
const transcriptSequenceRef = useRef(0);
|
|
1985
|
+
const transcriptCreatedAtRef = useRef(new Map<string, string>());
|
|
1986
|
+
const workSegmentRef = useRef<RealtimeVoiceWorkSegment | null>(null);
|
|
1987
|
+
const responseCallCountsRef = useRef(new Map<string, number>());
|
|
1700
1988
|
const preferencesHydratedRef = useRef(false);
|
|
1701
1989
|
const preferencesEditedRef = useRef(false);
|
|
1702
1990
|
const preferenceWriteChainRef = useRef<Promise<void>>(Promise.resolve());
|
|
@@ -1707,6 +1995,17 @@ function useRealtimeVoiceModeController(
|
|
|
1707
1995
|
}),
|
|
1708
1996
|
[],
|
|
1709
1997
|
);
|
|
1998
|
+
// The metrics outlive adapter changes such as a locale switch: the
|
|
1999
|
+
// transport cleanup depends on them, and a new instance would end the call.
|
|
2000
|
+
const trackEventRef = useRef(adapters.voice.trackEvent);
|
|
2001
|
+
trackEventRef.current = adapters.voice.trackEvent;
|
|
2002
|
+
const turnMetrics = useMemo(
|
|
2003
|
+
() =>
|
|
2004
|
+
createRealtimeVoiceTurnMetrics((name, properties) =>
|
|
2005
|
+
trackEventRef.current(name, properties),
|
|
2006
|
+
),
|
|
2007
|
+
[],
|
|
2008
|
+
);
|
|
1710
2009
|
const toolManifestCoordinator = useMemo(
|
|
1711
2010
|
() =>
|
|
1712
2011
|
createRealtimeVoiceToolManifestCoordinator((event) => {
|
|
@@ -1809,6 +2108,11 @@ function useRealtimeVoiceModeController(
|
|
|
1809
2108
|
|
|
1810
2109
|
const updateLivePreferences = useCallback(
|
|
1811
2110
|
(next: RealtimeVoicePreferences, includeVoice = false) => {
|
|
2111
|
+
turnMetrics.setContext({
|
|
2112
|
+
protocol: protocolRef.current,
|
|
2113
|
+
intelligence: next.intelligence,
|
|
2114
|
+
intelligenceSource: next.intelligenceChosen ? "user" : "default",
|
|
2115
|
+
});
|
|
1812
2116
|
sendDataChannelEvent(
|
|
1813
2117
|
channelRef.current,
|
|
1814
2118
|
createRealtimeVoicePreferenceUpdate(next, {
|
|
@@ -1819,7 +2123,7 @@ function useRealtimeVoiceModeController(
|
|
|
1819
2123
|
}),
|
|
1820
2124
|
);
|
|
1821
2125
|
},
|
|
1822
|
-
[],
|
|
2126
|
+
[turnMetrics],
|
|
1823
2127
|
);
|
|
1824
2128
|
|
|
1825
2129
|
const setLanguage = useCallback(
|
|
@@ -1839,7 +2143,11 @@ function useRealtimeVoiceModeController(
|
|
|
1839
2143
|
|
|
1840
2144
|
const setIntelligence = useCallback(
|
|
1841
2145
|
(intelligence: RealtimeVoiceIntelligence) => {
|
|
1842
|
-
const next = {
|
|
2146
|
+
const next = {
|
|
2147
|
+
...preferencesRef.current,
|
|
2148
|
+
intelligence,
|
|
2149
|
+
intelligenceChosen: true,
|
|
2150
|
+
};
|
|
1843
2151
|
savePreferences(next);
|
|
1844
2152
|
updateLivePreferences(next);
|
|
1845
2153
|
},
|
|
@@ -1994,6 +2302,19 @@ function useRealtimeVoiceModeController(
|
|
|
1994
2302
|
switchMicrophoneRef.current = setMicrophone;
|
|
1995
2303
|
|
|
1996
2304
|
const cleanupTransport = useCallback(() => {
|
|
2305
|
+
// Teardown keeps what was heard now, so no grouper timer publishes into
|
|
2306
|
+
// the chat after the session is gone.
|
|
2307
|
+
flushTranscriptsRef.current?.();
|
|
2308
|
+
turnMetrics.sessionEnded();
|
|
2309
|
+
for (const pending of pendingApprovalsRef.current.values()) {
|
|
2310
|
+
finishPendingApproval(pending, null);
|
|
2311
|
+
}
|
|
2312
|
+
pendingApprovalsRef.current.clear();
|
|
2313
|
+
syncApprovals();
|
|
2314
|
+
runningToolsRef.current.clear();
|
|
2315
|
+
setCurrentToolName(null);
|
|
2316
|
+
closeRealtimeVoiceWorkSegment(workSegmentRef);
|
|
2317
|
+
responseCallCountsRef.current.clear();
|
|
1997
2318
|
if (liveCloseDrainTimerRef.current !== null) {
|
|
1998
2319
|
window.clearTimeout(liveCloseDrainTimerRef.current);
|
|
1999
2320
|
liveCloseDrainTimerRef.current = null;
|
|
@@ -2044,7 +2365,9 @@ function useRealtimeVoiceModeController(
|
|
|
2044
2365
|
abortActiveToolCalls,
|
|
2045
2366
|
audioLevels,
|
|
2046
2367
|
responseCoordinator,
|
|
2368
|
+
syncApprovals,
|
|
2047
2369
|
toolManifestCoordinator,
|
|
2370
|
+
turnMetrics,
|
|
2048
2371
|
]);
|
|
2049
2372
|
|
|
2050
2373
|
const fail = useCallback(
|
|
@@ -2058,8 +2381,68 @@ function useRealtimeVoiceModeController(
|
|
|
2058
2381
|
[cleanupTransport, transition],
|
|
2059
2382
|
);
|
|
2060
2383
|
|
|
2384
|
+
const publishTranscript = useCallback(
|
|
2385
|
+
(
|
|
2386
|
+
transcript: CompletedRealtimeVoiceTranscript,
|
|
2387
|
+
status: "streaming" | "complete",
|
|
2388
|
+
) => {
|
|
2389
|
+
const threadId = transcriptThreadIdRef.current;
|
|
2390
|
+
if (!threadId) return;
|
|
2391
|
+
// The session id arrives after the first events; the start time keeps
|
|
2392
|
+
// every update of one turn under the same message id.
|
|
2393
|
+
const sessionIdentity = startedAtRef.current ?? "pending";
|
|
2394
|
+
const providerIdentity =
|
|
2395
|
+
transcript.providerId ?? `sequence-${++transcriptSequenceRef.current}`;
|
|
2396
|
+
const id = `realtime-voice:${sessionIdentity}:${transcript.role}:${providerIdentity}`;
|
|
2397
|
+
// Work rows sit before the first message created after their call, so a
|
|
2398
|
+
// turn keeps the time it started however late its final text arrives.
|
|
2399
|
+
let createdAt = transcriptCreatedAtRef.current.get(id);
|
|
2400
|
+
if (!createdAt) {
|
|
2401
|
+
createdAt = new Date().toISOString();
|
|
2402
|
+
transcriptCreatedAtRef.current.set(id, createdAt);
|
|
2403
|
+
}
|
|
2404
|
+
realtimeVoiceTranscriptRegistry.publish({
|
|
2405
|
+
id,
|
|
2406
|
+
threadId,
|
|
2407
|
+
role: transcript.role,
|
|
2408
|
+
text: transcript.text,
|
|
2409
|
+
createdAt,
|
|
2410
|
+
status,
|
|
2411
|
+
});
|
|
2412
|
+
},
|
|
2413
|
+
[],
|
|
2414
|
+
);
|
|
2415
|
+
|
|
2416
|
+
const transcriptSequencer = useMemo(
|
|
2417
|
+
() =>
|
|
2418
|
+
createRealtimeVoiceTranscriptSequencer(
|
|
2419
|
+
(transcript) => publishTranscript(transcript, "complete"),
|
|
2420
|
+
(transcript) => publishTranscript(transcript, "streaming"),
|
|
2421
|
+
(turn) => {
|
|
2422
|
+
if (turn.speaker !== "user") return;
|
|
2423
|
+
turnMetrics.userTurnUpdated(performance.now(), turn.id, turn.endMs);
|
|
2424
|
+
},
|
|
2425
|
+
),
|
|
2426
|
+
[publishTranscript, turnMetrics],
|
|
2427
|
+
);
|
|
2428
|
+
flushTranscriptsRef.current = transcriptSequencer.flush;
|
|
2429
|
+
const greetingStarter = useMemo(
|
|
2430
|
+
() =>
|
|
2431
|
+
createRealtimeVoiceGreetingStarter((event) => {
|
|
2432
|
+
if (event.type === "response.create") {
|
|
2433
|
+
responseCoordinator.request(event);
|
|
2434
|
+
} else {
|
|
2435
|
+
sendDataChannelEvent(channelRef.current, event);
|
|
2436
|
+
}
|
|
2437
|
+
}),
|
|
2438
|
+
[responseCoordinator],
|
|
2439
|
+
);
|
|
2440
|
+
|
|
2061
2441
|
const handleFunctionCall = useCallback(
|
|
2062
|
-
async (
|
|
2442
|
+
async (
|
|
2443
|
+
call: { name: string; callId: string; argumentsText: string },
|
|
2444
|
+
delegationId: string | undefined,
|
|
2445
|
+
) => {
|
|
2063
2446
|
if (handledCallsRef.current.has(call.callId)) return;
|
|
2064
2447
|
handledCallsRef.current.add(call.callId);
|
|
2065
2448
|
const transportGeneration = transportGenerationRef.current;
|
|
@@ -2067,9 +2450,28 @@ function useRealtimeVoiceModeController(
|
|
|
2067
2450
|
const toolAbortController = new AbortController();
|
|
2068
2451
|
toolAbortControllersRef.current.add(toolAbortController);
|
|
2069
2452
|
transition("working");
|
|
2453
|
+
trackRunningTool(call.callId, call.name);
|
|
2454
|
+
const finishTurnToolCall = turnMetrics.toolCallStarted();
|
|
2455
|
+
const workCall: RealtimeVoiceWorkCall = {
|
|
2456
|
+
id: call.callId,
|
|
2457
|
+
name: call.name,
|
|
2458
|
+
status: "running",
|
|
2459
|
+
startedAt: new Date().toISOString(),
|
|
2460
|
+
};
|
|
2461
|
+
const workSegment = openRealtimeVoiceWorkSegment(
|
|
2462
|
+
workSegmentRef,
|
|
2463
|
+
transcriptThreadIdRef.current,
|
|
2464
|
+
call.callId,
|
|
2465
|
+
delegationId,
|
|
2466
|
+
);
|
|
2070
2467
|
let result: RealtimeVoiceToolResult;
|
|
2071
2468
|
try {
|
|
2072
2469
|
const args = parseFunctionArguments(call.argumentsText);
|
|
2470
|
+
workCall.input = args;
|
|
2471
|
+
if (workSegment) {
|
|
2472
|
+
workSegment.calls.push(workCall);
|
|
2473
|
+
publishRealtimeVoiceWorkSegment(workSegment);
|
|
2474
|
+
}
|
|
2073
2475
|
result = await executeRealtimeVoiceTool({
|
|
2074
2476
|
name: call.name,
|
|
2075
2477
|
args,
|
|
@@ -2087,7 +2489,41 @@ function useRealtimeVoiceModeController(
|
|
|
2087
2489
|
};
|
|
2088
2490
|
} finally {
|
|
2089
2491
|
toolAbortControllersRef.current.delete(toolAbortController);
|
|
2492
|
+
trackRunningTool(call.callId, null);
|
|
2493
|
+
}
|
|
2494
|
+
// Session teardown aborted the call, so it did not fail. A call that
|
|
2495
|
+
// asks for approval stays running until the user decides.
|
|
2496
|
+
const approvalId =
|
|
2497
|
+
!toolAbortController.signal.aborted &&
|
|
2498
|
+
result.status === "approval_required" &&
|
|
2499
|
+
workCall.input &&
|
|
2500
|
+
transportGeneration === transportGenerationRef.current
|
|
2501
|
+
? result.approvalId
|
|
2502
|
+
: undefined;
|
|
2503
|
+
const awaitingApproval = Boolean(approvalId);
|
|
2504
|
+
if (!awaitingApproval) {
|
|
2505
|
+
workCall.status =
|
|
2506
|
+
toolAbortController.signal.aborted ||
|
|
2507
|
+
result.status === "approval_required"
|
|
2508
|
+
? "cancelled"
|
|
2509
|
+
: result.status;
|
|
2510
|
+
workCall.output = result.output;
|
|
2511
|
+
workCall.completedAt = new Date().toISOString();
|
|
2512
|
+
}
|
|
2513
|
+
if (workSegment) {
|
|
2514
|
+
if (!workSegment.calls.includes(workCall)) {
|
|
2515
|
+
workSegment.calls.push(workCall);
|
|
2516
|
+
}
|
|
2517
|
+
if (workSegment.closing) {
|
|
2518
|
+
closeRealtimeVoiceWorkSegment(workSegmentRef, workSegment);
|
|
2519
|
+
} else {
|
|
2520
|
+
publishRealtimeVoiceWorkSegment(workSegment);
|
|
2521
|
+
}
|
|
2090
2522
|
}
|
|
2523
|
+
finishTurnToolCall(
|
|
2524
|
+
performance.now(),
|
|
2525
|
+
toolAbortController.signal.aborted ? { status: "cancelled" } : result,
|
|
2526
|
+
);
|
|
2091
2527
|
if (
|
|
2092
2528
|
transportGeneration !== transportGenerationRef.current ||
|
|
2093
2529
|
channelRef.current !== transportChannel
|
|
@@ -2095,54 +2531,115 @@ function useRealtimeVoiceModeController(
|
|
|
2095
2531
|
return;
|
|
2096
2532
|
}
|
|
2097
2533
|
if (result.capability) capabilityRef.current = result.capability;
|
|
2534
|
+
if (approvalId && workCall.input) {
|
|
2535
|
+
pendingApprovalsRef.current.set(call.callId, {
|
|
2536
|
+
callId: call.callId,
|
|
2537
|
+
name: call.name,
|
|
2538
|
+
args: workCall.input,
|
|
2539
|
+
approvalId,
|
|
2540
|
+
workCall,
|
|
2541
|
+
workSegment,
|
|
2542
|
+
});
|
|
2543
|
+
syncApprovals();
|
|
2544
|
+
}
|
|
2098
2545
|
toolManifestCoordinator.enqueue(result);
|
|
2099
2546
|
},
|
|
2100
|
-
[
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
const threadId = transcriptThreadIdRef.current;
|
|
2109
|
-
if (!threadId) return;
|
|
2110
|
-
// The session id arrives after the first events; the start time keeps
|
|
2111
|
-
// every update of one turn under the same message id.
|
|
2112
|
-
const sessionIdentity = startedAtRef.current ?? "pending";
|
|
2113
|
-
const providerIdentity =
|
|
2114
|
-
transcript.providerId ?? `sequence-${++transcriptSequenceRef.current}`;
|
|
2115
|
-
realtimeVoiceTranscriptRegistry.publish({
|
|
2116
|
-
id: `realtime-voice:${sessionIdentity}:${transcript.role}:${providerIdentity}`,
|
|
2117
|
-
threadId,
|
|
2118
|
-
role: transcript.role,
|
|
2119
|
-
text: transcript.text,
|
|
2120
|
-
createdAt: new Date().toISOString(),
|
|
2121
|
-
status,
|
|
2122
|
-
});
|
|
2123
|
-
},
|
|
2124
|
-
[],
|
|
2547
|
+
[
|
|
2548
|
+
browserTabId,
|
|
2549
|
+
syncApprovals,
|
|
2550
|
+
toolManifestCoordinator,
|
|
2551
|
+
trackRunningTool,
|
|
2552
|
+
transition,
|
|
2553
|
+
turnMetrics,
|
|
2554
|
+
],
|
|
2125
2555
|
);
|
|
2126
2556
|
|
|
2127
|
-
const
|
|
2128
|
-
() =>
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
)
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2557
|
+
const resolveApproval = useCallback(
|
|
2558
|
+
async (callId: string, approved: boolean) => {
|
|
2559
|
+
const pending = pendingApprovalsRef.current.get(callId);
|
|
2560
|
+
if (!pending) return;
|
|
2561
|
+
pendingApprovalsRef.current.delete(callId);
|
|
2562
|
+
syncApprovals();
|
|
2563
|
+
const transportGeneration = transportGenerationRef.current;
|
|
2564
|
+
const transportChannel = channelRef.current;
|
|
2565
|
+
let result: RealtimeVoiceToolResult | null = null;
|
|
2566
|
+
let aborted = false;
|
|
2567
|
+
if (approved) {
|
|
2568
|
+
transition("working");
|
|
2569
|
+
trackRunningTool(callId, pending.name);
|
|
2570
|
+
const toolAbortController = new AbortController();
|
|
2571
|
+
toolAbortControllersRef.current.add(toolAbortController);
|
|
2572
|
+
try {
|
|
2573
|
+
result = await executeRealtimeVoiceTool({
|
|
2574
|
+
name: pending.name,
|
|
2575
|
+
args: pending.args,
|
|
2576
|
+
callId: `${callId}_approved`,
|
|
2577
|
+
sessionId: sessionIdRef.current,
|
|
2578
|
+
browserTabId,
|
|
2579
|
+
capability: capabilityRef.current,
|
|
2580
|
+
signal: toolAbortController.signal,
|
|
2581
|
+
approvalId: pending.approvalId,
|
|
2582
|
+
});
|
|
2583
|
+
} catch (toolError) {
|
|
2584
|
+
result = {
|
|
2585
|
+
callId,
|
|
2586
|
+
status: "failed",
|
|
2587
|
+
output: errorMessage(toolError),
|
|
2588
|
+
};
|
|
2589
|
+
} finally {
|
|
2590
|
+
toolAbortControllersRef.current.delete(toolAbortController);
|
|
2591
|
+
trackRunningTool(callId, null);
|
|
2143
2592
|
}
|
|
2144
|
-
|
|
2145
|
-
|
|
2593
|
+
aborted = toolAbortController.signal.aborted;
|
|
2594
|
+
} else {
|
|
2595
|
+
// A declined record left pending would make a later identical
|
|
2596
|
+
// request's approval ambiguous on the server.
|
|
2597
|
+
await executeRealtimeVoiceTool({
|
|
2598
|
+
name: pending.name,
|
|
2599
|
+
args: pending.args,
|
|
2600
|
+
callId: `${callId}_declined`,
|
|
2601
|
+
sessionId: sessionIdRef.current,
|
|
2602
|
+
browserTabId,
|
|
2603
|
+
capability: capabilityRef.current,
|
|
2604
|
+
declinedApprovalId: pending.approvalId,
|
|
2605
|
+
}).catch((declineError: unknown) => {
|
|
2606
|
+
console.warn(
|
|
2607
|
+
"Could not record the declined voice approval.",
|
|
2608
|
+
declineError,
|
|
2609
|
+
);
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
const transportChanged =
|
|
2613
|
+
transportGeneration !== transportGenerationRef.current ||
|
|
2614
|
+
channelRef.current !== transportChannel;
|
|
2615
|
+
// An approval that expired or no longer applies asks again; the call
|
|
2616
|
+
// stays running and the user sees the new request.
|
|
2617
|
+
const renewedApprovalId =
|
|
2618
|
+
!aborted && !transportChanged && result?.status === "approval_required"
|
|
2619
|
+
? result.approvalId
|
|
2620
|
+
: undefined;
|
|
2621
|
+
if (renewedApprovalId) {
|
|
2622
|
+
pendingApprovalsRef.current.set(callId, {
|
|
2623
|
+
...pending,
|
|
2624
|
+
approvalId: renewedApprovalId,
|
|
2625
|
+
});
|
|
2626
|
+
syncApprovals();
|
|
2627
|
+
} else {
|
|
2628
|
+
finishPendingApproval(pending, aborted || !result ? null : result);
|
|
2629
|
+
}
|
|
2630
|
+
if (aborted || transportChanged) return;
|
|
2631
|
+
if (result?.capability) capabilityRef.current = result.capability;
|
|
2632
|
+
sendDataChannelEvent(
|
|
2633
|
+
channelRef.current,
|
|
2634
|
+
createRealtimeVoiceApprovalOutcomeEvent(
|
|
2635
|
+
pending.name,
|
|
2636
|
+
result,
|
|
2637
|
+
protocolRef.current,
|
|
2638
|
+
),
|
|
2639
|
+
);
|
|
2640
|
+
sendDataChannelEvent(channelRef.current, { type: "response.create" });
|
|
2641
|
+
},
|
|
2642
|
+
[browserTabId, syncApprovals, trackRunningTool, transition],
|
|
2146
2643
|
);
|
|
2147
2644
|
|
|
2148
2645
|
const handleServerEvent = useCallback(
|
|
@@ -2184,15 +2681,21 @@ function useRealtimeVoiceModeController(
|
|
|
2184
2681
|
greetingStarter.start();
|
|
2185
2682
|
transition("working");
|
|
2186
2683
|
} else if (event.type === "input_audio_buffer.speech_started") {
|
|
2684
|
+
closeRealtimeVoiceWorkSegment(workSegmentRef);
|
|
2685
|
+
turnMetrics.userSpeechStarted(performance.now());
|
|
2187
2686
|
transition("listening");
|
|
2188
2687
|
} else if (event.type === "input_audio_buffer.speech_stopped") {
|
|
2688
|
+
turnMetrics.userSpeechEnded(performance.now());
|
|
2189
2689
|
transition("working");
|
|
2190
2690
|
if (protocolRef.current === "realtime") responseCoordinator.request();
|
|
2191
2691
|
} else if (event.type === "response.created") {
|
|
2192
2692
|
hasOutputAudioRef.current = true;
|
|
2193
2693
|
lastAssistantTextRef.current = "";
|
|
2194
2694
|
transition("working");
|
|
2695
|
+
} else if (event.type === "output_audio_buffer.started") {
|
|
2696
|
+
turnMetrics.assistantAudioStarted(performance.now());
|
|
2195
2697
|
} else if (event.type === "response.output_audio_transcript.delta") {
|
|
2698
|
+
turnMetrics.assistantResponded();
|
|
2196
2699
|
hasOutputAudioRef.current = true;
|
|
2197
2700
|
if (typeof event.delta === "string") {
|
|
2198
2701
|
lastAssistantTextRef.current += event.delta;
|
|
@@ -2216,6 +2719,10 @@ function useRealtimeVoiceModeController(
|
|
|
2216
2719
|
}
|
|
2217
2720
|
transition("listening");
|
|
2218
2721
|
} else if (event.type === "session.output_transcript.delta") {
|
|
2722
|
+
turnMetrics.assistantAudioStarted(
|
|
2723
|
+
performance.now(),
|
|
2724
|
+
typeof event.start_ms === "number" ? event.start_ms : undefined,
|
|
2725
|
+
);
|
|
2219
2726
|
hasOutputAudioRef.current = true;
|
|
2220
2727
|
if (typeof event.delta === "string") {
|
|
2221
2728
|
lastAssistantTextRef.current += event.delta;
|
|
@@ -2234,6 +2741,7 @@ function useRealtimeVoiceModeController(
|
|
|
2234
2741
|
? (response as { status?: unknown }).status
|
|
2235
2742
|
: undefined;
|
|
2236
2743
|
if (responseEvent.type === "response.failed" || status === "failed") {
|
|
2744
|
+
closeRealtimeVoiceWorkSegment(workSegmentRef);
|
|
2237
2745
|
transportGenerationRef.current += 1;
|
|
2238
2746
|
handledCallsRef.current.clear();
|
|
2239
2747
|
abortActiveToolCalls();
|
|
@@ -2285,11 +2793,33 @@ function useRealtimeVoiceModeController(
|
|
|
2285
2793
|
return;
|
|
2286
2794
|
}
|
|
2287
2795
|
|
|
2796
|
+
const delegationId =
|
|
2797
|
+
typeof event.delegation_id === "string" ? event.delegation_id : "";
|
|
2288
2798
|
const calls = extractRealtimeVoiceFunctionCalls(event);
|
|
2289
|
-
for (const call of calls)
|
|
2290
|
-
|
|
2799
|
+
for (const call of calls) {
|
|
2800
|
+
void handleFunctionCall(call, delegationId || undefined);
|
|
2801
|
+
}
|
|
2802
|
+
// GPT-Live's response snapshots carry no output, so whether a response
|
|
2803
|
+
// called a tool is counted from its streamed items, per delegation.
|
|
2804
|
+
const responseCalls = responseCallCountsRef.current;
|
|
2805
|
+
if (responseEvent.type === "response.created") {
|
|
2806
|
+
responseCalls.set(delegationId, 0);
|
|
2807
|
+
}
|
|
2808
|
+
if (calls.length > 0) {
|
|
2809
|
+
responseCalls.set(
|
|
2810
|
+
delegationId,
|
|
2811
|
+
(responseCalls.get(delegationId) ?? 0) + calls.length,
|
|
2812
|
+
);
|
|
2813
|
+
}
|
|
2814
|
+
if (responseBoundary && !responseCalls.get(delegationId)) {
|
|
2815
|
+
// Only the segment's own delegation ends it; an uncorrelated
|
|
2816
|
+
// boundary would split one request's work into several rows.
|
|
2817
|
+
if ((workSegmentRef.current?.delegationId ?? "") === delegationId) {
|
|
2818
|
+
closeRealtimeVoiceWorkSegment(workSegmentRef);
|
|
2819
|
+
}
|
|
2291
2820
|
transition("listening");
|
|
2292
2821
|
}
|
|
2822
|
+
if (responseBoundary) responseCalls.delete(delegationId);
|
|
2293
2823
|
if (responseBoundary) {
|
|
2294
2824
|
lastUserTextRef.current = "";
|
|
2295
2825
|
lastAssistantTextRef.current = "";
|
|
@@ -2307,6 +2837,7 @@ function useRealtimeVoiceModeController(
|
|
|
2307
2837
|
transition,
|
|
2308
2838
|
responseCoordinator,
|
|
2309
2839
|
toolManifestCoordinator,
|
|
2840
|
+
turnMetrics,
|
|
2310
2841
|
updateLivePreferences,
|
|
2311
2842
|
],
|
|
2312
2843
|
);
|
|
@@ -2337,8 +2868,10 @@ function useRealtimeVoiceModeController(
|
|
|
2337
2868
|
greetingStarter.reset();
|
|
2338
2869
|
setVoiceChangePending(false);
|
|
2339
2870
|
transcriptThreadIdRef.current =
|
|
2871
|
+
requestedTranscriptThreadIdRef.current ??
|
|
2340
2872
|
realtimeVoiceTranscriptRegistry.activeThreadId();
|
|
2341
2873
|
transcriptSequenceRef.current = 0;
|
|
2874
|
+
transcriptCreatedAtRef.current.clear();
|
|
2342
2875
|
transcriptSequencer.reset();
|
|
2343
2876
|
inputMutedRef.current = false;
|
|
2344
2877
|
outputMutedRef.current = false;
|
|
@@ -2422,8 +2955,13 @@ function useRealtimeVoiceModeController(
|
|
|
2422
2955
|
JSON.parse(String(messageEvent.data)),
|
|
2423
2956
|
stateRef.current === "ending",
|
|
2424
2957
|
);
|
|
2425
|
-
} catch {
|
|
2426
|
-
//
|
|
2958
|
+
} catch (eventError) {
|
|
2959
|
+
// A malformed provider event must not end a healthy call, but the
|
|
2960
|
+
// text it carried is lost, so say so.
|
|
2961
|
+
console.warn(
|
|
2962
|
+
"Ignored a realtime voice event that could not be handled.",
|
|
2963
|
+
eventError,
|
|
2964
|
+
);
|
|
2427
2965
|
}
|
|
2428
2966
|
};
|
|
2429
2967
|
channel.onerror = () => {
|
|
@@ -2475,7 +3013,29 @@ function useRealtimeVoiceModeController(
|
|
|
2475
3013
|
answer.model ??
|
|
2476
3014
|
(protocolRef.current === "live" ? "gpt-live-1" : "gpt-realtime-2.1");
|
|
2477
3015
|
toolManifestCoordinator.setProtocol(protocolRef.current);
|
|
3016
|
+
toolManifestCoordinator.setPriorityToolNames(
|
|
3017
|
+
answer.priorityToolNames ?? [],
|
|
3018
|
+
);
|
|
2478
3019
|
greetingStarter.setProtocol(protocolRef.current);
|
|
3020
|
+
const chosen = preferencesRef.current.intelligenceChosen === true;
|
|
3021
|
+
if (
|
|
3022
|
+
!chosen &&
|
|
3023
|
+
answer.intelligence &&
|
|
3024
|
+
answer.intelligence !== preferencesRef.current.intelligence
|
|
3025
|
+
) {
|
|
3026
|
+
// Saved as not chosen, so Settings shows the app's default after a
|
|
3027
|
+
// reload and the app can still change it.
|
|
3028
|
+
savePreferences({
|
|
3029
|
+
...preferencesRef.current,
|
|
3030
|
+
intelligence: answer.intelligence,
|
|
3031
|
+
intelligenceChosen: false,
|
|
3032
|
+
});
|
|
3033
|
+
}
|
|
3034
|
+
turnMetrics.setContext({
|
|
3035
|
+
protocol: protocolRef.current,
|
|
3036
|
+
intelligence: preferencesRef.current.intelligence,
|
|
3037
|
+
intelligenceSource: chosen ? "user" : "default",
|
|
3038
|
+
});
|
|
2479
3039
|
await peer.setRemoteDescription({ type: "answer", sdp: answer.sdp });
|
|
2480
3040
|
} catch (startError) {
|
|
2481
3041
|
if (
|
|
@@ -2514,11 +3074,21 @@ function useRealtimeVoiceModeController(
|
|
|
2514
3074
|
handleServerEvent,
|
|
2515
3075
|
hydratePreferences,
|
|
2516
3076
|
refreshMicrophones,
|
|
3077
|
+
savePreferences,
|
|
2517
3078
|
t,
|
|
2518
3079
|
transcriptSequencer,
|
|
2519
3080
|
transition,
|
|
3081
|
+
turnMetrics,
|
|
2520
3082
|
]);
|
|
2521
3083
|
|
|
3084
|
+
const startForThread = useCallback(
|
|
3085
|
+
(options?: RealtimeVoiceStartOptions) => {
|
|
3086
|
+
requestedTranscriptThreadIdRef.current = options?.threadId;
|
|
3087
|
+
return start();
|
|
3088
|
+
},
|
|
3089
|
+
[start],
|
|
3090
|
+
);
|
|
3091
|
+
|
|
2522
3092
|
const end = useCallback(() => {
|
|
2523
3093
|
if (stateRef.current === "idle" || stateRef.current === "ending") return;
|
|
2524
3094
|
const transcriptThreadId = transcriptThreadIdRef.current;
|
|
@@ -2644,9 +3214,12 @@ function useRealtimeVoiceModeController(
|
|
|
2644
3214
|
setMicrophone,
|
|
2645
3215
|
setInputMuted,
|
|
2646
3216
|
setOutputMuted,
|
|
2647
|
-
start,
|
|
3217
|
+
start: startForThread,
|
|
2648
3218
|
end,
|
|
2649
3219
|
dismissError,
|
|
3220
|
+
currentToolName,
|
|
3221
|
+
approvals,
|
|
3222
|
+
resolveApproval,
|
|
2650
3223
|
};
|
|
2651
3224
|
}
|
|
2652
3225
|
|
|
@@ -2744,7 +3317,11 @@ export function buildRealtimeVoiceSettings({
|
|
|
2744
3317
|
})),
|
|
2745
3318
|
onValueChange: (value: string) => {
|
|
2746
3319
|
if (isOneOf(REALTIME_VOICE_INTELLIGENCE_LEVELS, value)) {
|
|
2747
|
-
onPreferencesChange({
|
|
3320
|
+
onPreferencesChange({
|
|
3321
|
+
...preferences,
|
|
3322
|
+
intelligence: value,
|
|
3323
|
+
intelligenceChosen: true,
|
|
3324
|
+
});
|
|
2748
3325
|
}
|
|
2749
3326
|
},
|
|
2750
3327
|
},
|