@agent-native/core 0.98.0 → 0.98.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +32 -0
- package/corpus/core/docs/content/locales/ar-SA/pr-visual-recap.mdx +25 -1
- package/corpus/core/docs/content/locales/ar-SA/template-plan.mdx +1 -1
- package/corpus/core/docs/content/locales/de-DE/pr-visual-recap.mdx +25 -1
- package/corpus/core/docs/content/locales/de-DE/template-plan.mdx +1 -1
- package/corpus/core/docs/content/locales/es-ES/pr-visual-recap.mdx +25 -1
- package/corpus/core/docs/content/locales/es-ES/template-plan.mdx +1 -1
- package/corpus/core/docs/content/locales/fr-FR/pr-visual-recap.mdx +25 -1
- package/corpus/core/docs/content/locales/fr-FR/template-plan.mdx +1 -1
- package/corpus/core/docs/content/locales/hi-IN/pr-visual-recap.mdx +25 -1
- package/corpus/core/docs/content/locales/hi-IN/template-plan.mdx +1 -1
- package/corpus/core/docs/content/locales/ja-JP/pr-visual-recap.mdx +25 -1
- package/corpus/core/docs/content/locales/ja-JP/template-plan.mdx +1 -1
- package/corpus/core/docs/content/locales/ko-KR/pr-visual-recap.mdx +25 -1
- package/corpus/core/docs/content/locales/ko-KR/template-plan.mdx +1 -1
- package/corpus/core/docs/content/locales/pt-BR/pr-visual-recap.mdx +25 -1
- package/corpus/core/docs/content/locales/pt-BR/template-plan.mdx +1 -1
- package/corpus/core/docs/content/locales/zh-CN/pr-visual-recap.mdx +25 -1
- package/corpus/core/docs/content/locales/zh-CN/template-plan.mdx +1 -1
- package/corpus/core/docs/content/locales/zh-TW/pr-visual-recap.mdx +25 -1
- package/corpus/core/docs/content/locales/zh-TW/template-plan.mdx +1 -1
- package/corpus/core/docs/content/pr-visual-recap.mdx +50 -1
- package/corpus/core/docs/content/template-plan.mdx +1 -1
- package/corpus/core/package.json +2 -2
- package/corpus/core/src/action-change-marker.ts +8 -1
- package/corpus/core/src/changelog/parse.ts +8 -3
- package/corpus/core/src/cli/plan-local.ts +43 -11
- package/corpus/core/src/cli/pr-visual-recap-workflow.ts +2 -9
- package/corpus/core/src/cli/recap.ts +356 -4
- package/corpus/core/src/cli/skills-content/local-files.ts +17 -12
- package/corpus/core/src/client/AgentPanel.tsx +2 -3
- package/corpus/core/src/client/AssistantChat.tsx +83 -39
- package/corpus/core/src/client/analytics.ts +125 -14
- package/corpus/core/src/client/blocks/library/wireframe-kit.tsx +16 -2
- package/corpus/core/src/client/chat-model-groups.ts +28 -0
- package/corpus/core/src/client/composer/RealtimeVoiceMode.tsx +442 -85
- package/corpus/core/src/client/composer/TiptapComposer.tsx +46 -29
- package/corpus/core/src/client/composer/VoiceButton.tsx +2 -1
- package/corpus/core/src/client/composer/index.ts +5 -0
- package/corpus/core/src/client/composer/useRealtimeVoiceMode.tsx +1206 -67
- package/corpus/core/src/client/index.ts +10 -0
- package/corpus/core/src/client/progress/RunsTray.tsx +13 -1
- package/corpus/core/src/client/session-replay.ts +10 -0
- package/corpus/core/src/client/settings/DemoModeSection.tsx +4 -28
- package/corpus/core/src/client/settings/SecretsSection.tsx +387 -266
- package/corpus/core/src/client/settings/SettingsPanel.tsx +117 -38
- package/corpus/core/src/client/url-scrub.ts +13 -0
- package/corpus/core/src/client/use-action.ts +12 -0
- package/corpus/core/src/client/use-agent-chat-home-handoff.ts +8 -1
- package/corpus/core/src/client/use-agent-engine-configured.ts +17 -3
- package/corpus/core/src/client/use-db-sync.ts +84 -34
- package/corpus/core/src/client/use-demo-mode-status.ts +44 -0
- package/corpus/core/src/client/use-pausing-interval.ts +14 -4
- package/corpus/core/src/integrations/scope-store.ts +1 -1
- package/corpus/core/src/integrations/usage-budget-store.ts +1 -1
- package/corpus/core/src/localization/default-messages.ts +44 -1
- package/corpus/core/src/mcp-client/workspace-servers.ts +4 -4
- package/corpus/core/src/secrets/crypto.ts +28 -8
- package/corpus/core/src/server/action-change.ts +5 -1
- package/corpus/core/src/server/action-routes.ts +8 -0
- package/corpus/core/src/server/poll.ts +1 -0
- package/corpus/core/src/server/realtime-voice.ts +299 -13
- package/corpus/core/src/styles/agent-native.css +2 -2
- package/corpus/core/src/templates/default/.agents/skills/real-time-sync/SKILL.md +2 -2
- package/corpus/core/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +2 -2
- package/corpus/core/src/templates/workspace-core/.agents/skills/secrets/SKILL.md +7 -3
- package/corpus/core/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +39 -9
- package/corpus/core/src/vite/client.ts +9 -1
- package/corpus/templates/analytics/.agents/skills/real-time-sync/SKILL.md +2 -2
- package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +10 -5
- package/corpus/templates/analytics/AGENTS.md +17 -13
- package/corpus/templates/analytics/actions/navigate.ts +59 -5
- package/corpus/templates/analytics/actions/view-screen.ts +3 -6
- package/corpus/templates/analytics/app/components/ProviderCorpusJobNotifier.tsx +20 -3
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +20 -5
- package/corpus/templates/analytics/app/components/layout/Header.tsx +1 -1
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +21 -6
- package/corpus/templates/analytics/app/global.css +19 -20
- package/corpus/templates/analytics/app/lib/demo-chart-trend.ts +156 -0
- package/corpus/templates/analytics/app/lib/provider-corpus-job-sync.ts +26 -0
- package/corpus/templates/analytics/app/pages/Settings.tsx +35 -341
- package/corpus/templates/analytics/app/pages/monitoring/ErrorsPanel.tsx +38 -7
- package/corpus/templates/analytics/app/pages/monitoring/MonitoringPage.tsx +1 -1
- package/corpus/templates/analytics/app/pages/monitoring/errors/i18n.ts +3 -0
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +333 -272
- package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +72 -17
- package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +297 -108
- package/corpus/templates/analytics/app/root.tsx +28 -8
- package/corpus/templates/analytics/changelog/2026-06-24-first-party-signup-and-pageview-panels-now-honor-dashboard-filters.md +2 -1
- package/corpus/templates/analytics/changelog/2026-06-25-referrer-dashboard-panels-show-external-domains.md +2 -1
- package/corpus/templates/analytics/changelog/2026-07-06-mobile-chat-header-is-more-compact-and-the-menu-button-opens-navigation.md +1 -0
- package/corpus/templates/analytics/changelog/2026-07-08-dashboard-history-and-chat-context.md +1 -0
- package/corpus/templates/analytics/changelog/2026-07-08-settings-are-cleaner-searchable-and-alerts-have-their-own-tab.md +1 -0
- package/corpus/templates/analytics/changelog/2026-07-08-sidebar-sections-stay-collapsed-until-opened.md +1 -0
- package/corpus/templates/analytics/changelog/2026-07-11-analytics-no-longer-repeatedly-restarts-background-requests-.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-11-analytics-only-asks-for-replay-storage-when-you-enable-sessi.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-11-charts-now-show-their-loading-skeleton-while-refreshing.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-11-realtime-voice-now-shows-a-clear-connection-indicator-comple.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-11-replay-event-counts-stay-hidden-until-recordings-finish-load.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-11-replay-storage-key-mismatches-show-actionable-setup-guidance.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-11-replay-timelines-group-continuous-scrolling-and-dev-tools-co.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-11-session-filters-now-stay-compact-in-a-settings-menu-at-mediu.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-11-session-replay-console-errors-now-link-to-matching-issues-or.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-11-session-replays-load-large-recordings-faster-and-surface-fai.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-11-session-replays-preserve-recorded-styles-and-viewport-layout.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-11-session-replays-stay-centered-instead-of-clipping-wide-recor.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-12-daily-dashboard-emails-reliably-include-the-complete-dashboa.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-12-demo-mode-charts-trend-upward.md +6 -0
- package/corpus/templates/analytics/package.json +1 -1
- package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +1 -303
- package/corpus/templates/analytics/server/handlers/session-replay.ts +57 -0
- package/corpus/templates/analytics/server/jobs/dashboard-report.ts +2 -0
- package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +1 -1
- package/corpus/templates/analytics/server/lib/dashboard-report.ts +17 -33
- package/corpus/templates/analytics/server/lib/demo-dashboards.ts +16 -3
- package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +12 -0
- package/corpus/templates/analytics/server/lib/session-replay.ts +208 -2
- package/corpus/templates/analytics/server/plugins/onboarding.ts +7 -79
- package/corpus/templates/analytics/server/routes/api/session-replay/recordings/[recordingId]/chunks.get.ts +1 -0
- package/corpus/templates/assets/.agents/skills/real-time-sync/SKILL.md +2 -2
- package/corpus/templates/assets/app/global.css +4 -4
- package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +0 -1
- package/corpus/templates/assets/app/routes/library.tsx +0 -1
- package/corpus/templates/brain/.agents/skills/real-time-sync/SKILL.md +2 -2
- package/corpus/templates/brain/app/global.css +5 -5
- package/corpus/templates/brain/app/root.tsx +0 -3
- package/corpus/templates/calendar/.agents/skills/real-time-sync/SKILL.md +2 -2
- package/corpus/templates/calendar/app/root.tsx +1 -0
- package/corpus/templates/chat/.agents/skills/real-time-sync/SKILL.md +2 -2
- package/corpus/templates/clips/app/components/player/video-player.tsx +5 -1
- package/corpus/templates/clips/app/hooks/use-auto-title.ts +74 -8
- package/corpus/templates/clips/app/hooks/use-library.ts +12 -25
- package/corpus/templates/clips/app/routes/_app.settings._index.tsx +20 -22
- package/corpus/templates/clips/changelog/2026-07-11-clips-no-longer-stay-stuck-on-preparing-clip-when-they-are-r.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-11-generated-titles-no-longer-cause-continuous-background-refreshes.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-11-s3-storage-setup-stays-collapsed-until-you-choose-it-as-the-.md +6 -0
- package/corpus/templates/content/.agents/skills/real-time-sync/SKILL.md +2 -2
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +2 -2
- package/corpus/templates/content/app/hooks/use-comments.ts +0 -1
- package/corpus/templates/content/changelog/2026-07-11-comments-now-update-from-live-collaboration-events-without-c.md +6 -0
- package/corpus/templates/content/changelog/2026-07-12-the-left-sidebar-stays-fixed-horizontally-while-scrolling-on.md +6 -0
- package/corpus/templates/design/app/components/layout/Header.tsx +1 -1
- package/corpus/templates/forms/.agents/skills/real-time-sync/SKILL.md +2 -2
- package/corpus/templates/macros/app/components/layout/AppLayout.tsx +1 -2
- package/corpus/templates/macros/app/root.tsx +0 -3
- package/corpus/templates/mail/.agents/skills/real-time-sync/SKILL.md +2 -2
- package/corpus/templates/mail/app/hooks/use-compose-state.ts +1 -0
- package/corpus/templates/mail/app/root.tsx +1 -12
- package/corpus/templates/plan/.agents/skills/real-time-sync/SKILL.md +2 -2
- package/corpus/templates/plan/.agents/skills/visual-plan/references/local-files.md +17 -12
- package/corpus/templates/plan/.agents/skills/visual-recap/references/local-files.md +17 -12
- package/corpus/templates/plan/actions/validate-local-plan-source.ts +4 -4
- package/corpus/templates/plan/app/components/layout/Sidebar.tsx +2 -1
- package/corpus/templates/plan/app/hooks/use-navigation-state.ts +5 -2
- package/corpus/templates/plan/app/hooks/use-plans.ts +6 -13
- package/corpus/templates/plan/app/lib/plan-local-bridge.ts +41 -0
- package/corpus/templates/plan/app/lib/plan-tracking.ts +3 -0
- package/corpus/templates/plan/app/pages/PlansPage.tsx +14 -36
- package/corpus/templates/plan/app/root.tsx +13 -3
- package/corpus/templates/plan/changelog/2026-07-11-plan-refreshes-only-when-content-changes.md +6 -0
- package/corpus/templates/plan/changelog/2026-07-12-local-plan-source-now-stays-on-device.md +6 -0
- package/corpus/templates/slides/app/components/comments/SlideCommentsPanel.tsx +4 -1
- package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +1 -1
- package/corpus/templates/slides/app/components/layout/Header.tsx +1 -1
- package/corpus/templates/slides/app/context/DeckContext.tsx +1 -0
- package/corpus/templates/slides/app/hooks/use-slide-comments.ts +7 -31
- package/corpus/templates/slides/changelog/2026-07-11-comments-now-update-from-live-collaboration-events-without-c.md +6 -0
- package/dist/action-change-marker.d.ts +1 -0
- package/dist/action-change-marker.d.ts.map +1 -1
- package/dist/action-change-marker.js +7 -1
- package/dist/action-change-marker.js.map +1 -1
- package/dist/changelog/parse.d.ts +4 -2
- package/dist/changelog/parse.d.ts.map +1 -1
- package/dist/changelog/parse.js +5 -3
- package/dist/changelog/parse.js.map +1 -1
- package/dist/cli/plan-local.d.ts +6 -6
- package/dist/cli/plan-local.d.ts.map +1 -1
- package/dist/cli/plan-local.js +36 -7
- package/dist/cli/plan-local.js.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.d.ts +2 -8
- package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.js +2 -8
- package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
- package/dist/cli/recap.d.ts +22 -1
- package/dist/cli/recap.d.ts.map +1 -1
- package/dist/cli/recap.js +286 -2
- package/dist/cli/recap.js.map +1 -1
- package/dist/cli/skills-content/local-files.d.ts +1 -1
- package/dist/cli/skills-content/local-files.d.ts.map +1 -1
- package/dist/cli/skills-content/local-files.js +17 -12
- package/dist/cli/skills-content/local-files.js.map +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +2 -3
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/AssistantChat.d.ts +7 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +62 -40
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/analytics.d.ts +8 -0
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js +103 -13
- package/dist/client/analytics.js.map +1 -1
- package/dist/client/blocks/library/wireframe-kit.d.ts +2 -0
- package/dist/client/blocks/library/wireframe-kit.d.ts.map +1 -1
- package/dist/client/blocks/library/wireframe-kit.js +9 -2
- package/dist/client/blocks/library/wireframe-kit.js.map +1 -1
- package/dist/client/chat-model-groups.d.ts.map +1 -1
- package/dist/client/chat-model-groups.js +24 -0
- package/dist/client/chat-model-groups.js.map +1 -1
- package/dist/client/composer/RealtimeVoiceMode.d.ts +40 -4
- package/dist/client/composer/RealtimeVoiceMode.d.ts.map +1 -1
- package/dist/client/composer/RealtimeVoiceMode.js +172 -42
- package/dist/client/composer/RealtimeVoiceMode.js.map +1 -1
- package/dist/client/composer/TiptapComposer.d.ts +4 -0
- package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/client/composer/TiptapComposer.js +47 -29
- package/dist/client/composer/TiptapComposer.js.map +1 -1
- package/dist/client/composer/VoiceButton.d.ts.map +1 -1
- package/dist/client/composer/VoiceButton.js +2 -2
- package/dist/client/composer/VoiceButton.js.map +1 -1
- package/dist/client/composer/index.d.ts +2 -2
- package/dist/client/composer/index.d.ts.map +1 -1
- package/dist/client/composer/index.js +1 -1
- package/dist/client/composer/index.js.map +1 -1
- package/dist/client/composer/useRealtimeVoiceMode.d.ts +100 -3
- package/dist/client/composer/useRealtimeVoiceMode.d.ts.map +1 -1
- package/dist/client/composer/useRealtimeVoiceMode.js +952 -51
- package/dist/client/composer/useRealtimeVoiceMode.js.map +1 -1
- package/dist/client/index.d.ts +3 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +3 -2
- package/dist/client/index.js.map +1 -1
- package/dist/client/progress/RunsTray.d.ts.map +1 -1
- package/dist/client/progress/RunsTray.js +10 -1
- package/dist/client/progress/RunsTray.js.map +1 -1
- package/dist/client/session-replay.d.ts +2 -0
- package/dist/client/session-replay.d.ts.map +1 -1
- package/dist/client/session-replay.js +7 -0
- package/dist/client/session-replay.js.map +1 -1
- package/dist/client/settings/DemoModeSection.d.ts.map +1 -1
- package/dist/client/settings/DemoModeSection.js +4 -23
- package/dist/client/settings/DemoModeSection.js.map +1 -1
- package/dist/client/settings/SecretsSection.d.ts +2 -3
- package/dist/client/settings/SecretsSection.d.ts.map +1 -1
- package/dist/client/settings/SecretsSection.js +46 -22
- package/dist/client/settings/SecretsSection.js.map +1 -1
- package/dist/client/settings/SettingsPanel.d.ts +9 -0
- package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
- package/dist/client/settings/SettingsPanel.js +46 -12
- package/dist/client/settings/SettingsPanel.js.map +1 -1
- package/dist/client/url-scrub.d.ts.map +1 -1
- package/dist/client/url-scrub.js +14 -0
- package/dist/client/url-scrub.js.map +1 -1
- package/dist/client/use-action.d.ts +2 -0
- package/dist/client/use-action.d.ts.map +1 -1
- package/dist/client/use-action.js +10 -0
- package/dist/client/use-action.js.map +1 -1
- package/dist/client/use-agent-chat-home-handoff.d.ts +2 -0
- package/dist/client/use-agent-chat-home-handoff.d.ts.map +1 -1
- package/dist/client/use-agent-chat-home-handoff.js +7 -1
- package/dist/client/use-agent-chat-home-handoff.js.map +1 -1
- package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
- package/dist/client/use-agent-engine-configured.js +17 -3
- package/dist/client/use-agent-engine-configured.js.map +1 -1
- package/dist/client/use-db-sync.d.ts +8 -2
- package/dist/client/use-db-sync.d.ts.map +1 -1
- package/dist/client/use-db-sync.js +57 -32
- package/dist/client/use-db-sync.js.map +1 -1
- package/dist/client/use-demo-mode-status.d.ts +12 -0
- package/dist/client/use-demo-mode-status.d.ts.map +1 -0
- package/dist/client/use-demo-mode-status.js +30 -0
- package/dist/client/use-demo-mode-status.js.map +1 -0
- package/dist/client/use-pausing-interval.d.ts.map +1 -1
- package/dist/client/use-pausing-interval.js +16 -4
- package/dist/client/use-pausing-interval.js.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/integrations/scope-store.js +1 -1
- package/dist/integrations/scope-store.js.map +1 -1
- package/dist/integrations/usage-budget-store.js +1 -1
- package/dist/integrations/usage-budget-store.js.map +1 -1
- package/dist/localization/default-messages.d.ts +41 -1
- package/dist/localization/default-messages.d.ts.map +1 -1
- package/dist/localization/default-messages.js +41 -1
- package/dist/localization/default-messages.js.map +1 -1
- package/dist/mcp-client/workspace-servers.js +4 -4
- package/dist/mcp-client/workspace-servers.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/secrets/crypto.d.ts +7 -6
- package/dist/secrets/crypto.d.ts.map +1 -1
- package/dist/secrets/crypto.js +30 -11
- package/dist/secrets/crypto.js.map +1 -1
- package/dist/server/action-change.d.ts +1 -0
- package/dist/server/action-change.d.ts.map +1 -1
- package/dist/server/action-change.js +4 -1
- package/dist/server/action-change.js.map +1 -1
- package/dist/server/action-routes.d.ts.map +1 -1
- package/dist/server/action-routes.js +5 -0
- package/dist/server/action-routes.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/poll.d.ts.map +1 -1
- package/dist/server/poll.js +1 -0
- package/dist/server/poll.js.map +1 -1
- package/dist/server/realtime-voice.d.ts +7 -0
- package/dist/server/realtime-voice.d.ts.map +1 -1
- package/dist/server/realtime-voice.js +222 -12
- package/dist/server/realtime-voice.js.map +1 -1
- package/dist/styles/agent-native.css +2 -2
- package/dist/templates/default/.agents/skills/real-time-sync/SKILL.md +2 -2
- package/dist/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +2 -2
- package/dist/templates/workspace-core/.agents/skills/secrets/SKILL.md +7 -3
- package/dist/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +39 -9
- package/dist/vite/client.d.ts.map +1 -1
- package/dist/vite/client.js +6 -1
- package/dist/vite/client.js.map +1 -1
- package/docs/content/locales/ar-SA/pr-visual-recap.mdx +25 -1
- package/docs/content/locales/ar-SA/template-plan.mdx +1 -1
- package/docs/content/locales/de-DE/pr-visual-recap.mdx +25 -1
- package/docs/content/locales/de-DE/template-plan.mdx +1 -1
- package/docs/content/locales/es-ES/pr-visual-recap.mdx +25 -1
- package/docs/content/locales/es-ES/template-plan.mdx +1 -1
- package/docs/content/locales/fr-FR/pr-visual-recap.mdx +25 -1
- package/docs/content/locales/fr-FR/template-plan.mdx +1 -1
- package/docs/content/locales/hi-IN/pr-visual-recap.mdx +25 -1
- package/docs/content/locales/hi-IN/template-plan.mdx +1 -1
- package/docs/content/locales/ja-JP/pr-visual-recap.mdx +25 -1
- package/docs/content/locales/ja-JP/template-plan.mdx +1 -1
- package/docs/content/locales/ko-KR/pr-visual-recap.mdx +25 -1
- package/docs/content/locales/ko-KR/template-plan.mdx +1 -1
- package/docs/content/locales/pt-BR/pr-visual-recap.mdx +25 -1
- package/docs/content/locales/pt-BR/template-plan.mdx +1 -1
- package/docs/content/locales/zh-CN/pr-visual-recap.mdx +25 -1
- package/docs/content/locales/zh-CN/template-plan.mdx +1 -1
- package/docs/content/locales/zh-TW/pr-visual-recap.mdx +25 -1
- package/docs/content/locales/zh-TW/template-plan.mdx +1 -1
- package/docs/content/pr-visual-recap.mdx +50 -1
- package/docs/content/template-plan.mdx +1 -1
- package/package.json +2 -2
- package/src/templates/default/.agents/skills/real-time-sync/SKILL.md +2 -2
- package/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +2 -2
- package/src/templates/workspace-core/.agents/skills/secrets/SKILL.md +7 -3
- package/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +39 -9
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
SIDEBAR_STATE_CHANGE_EVENT,
|
|
16
16
|
type AgentSidebarStateChangeDetail,
|
|
17
17
|
} from "../agent-sidebar-state.js";
|
|
18
|
-
import { agentNativePath
|
|
18
|
+
import { agentNativePath } from "../api-path.js";
|
|
19
19
|
import { readClientAppState, setClientAppState } from "../application-state.js";
|
|
20
20
|
import { getBrowserTabId } from "../browser-tab-id.js";
|
|
21
21
|
import { useT } from "../i18n.js";
|
|
@@ -33,10 +33,73 @@ import {
|
|
|
33
33
|
} from "./RealtimeVoiceMode.js";
|
|
34
34
|
|
|
35
35
|
const REALTIME_VOICE_STATE_KEY = "realtime-voice-session";
|
|
36
|
+
const REALTIME_VOICE_PREFERENCES_KEY = "realtime-voice-prefs";
|
|
36
37
|
const REALTIME_VOICE_REQUEST_SOURCE = "realtime-voice";
|
|
37
38
|
const REALTIME_VOICE_SESSION_PATH = "/_agent-native/realtime-voice/session";
|
|
38
39
|
const REALTIME_VOICE_TOOL_PATH = "/_agent-native/realtime-voice/tool";
|
|
40
|
+
const REALTIME_VOICE_CAPABILITY_HEADER = "X-Agent-Native-Realtime-Capability";
|
|
39
41
|
const REALTIME_VOICE_CONNECTION_TIMEOUT_MS = 15_000;
|
|
42
|
+
const REALTIME_VOICE_MAX_TOOLS = 32;
|
|
43
|
+
const REALTIME_VOICE_MAX_TOOL_SCHEMA_BYTES = 32_000;
|
|
44
|
+
const REALTIME_VOICE_MAX_SESSION_BYTES = 64_000;
|
|
45
|
+
const REALTIME_VOICE_TOOL_UPDATE_TIMEOUT_MS = 5_000;
|
|
46
|
+
const REALTIME_VOICE_PRIORITY_TOOL_NAMES = [
|
|
47
|
+
"navigate",
|
|
48
|
+
"set-url-path",
|
|
49
|
+
"set-search-params",
|
|
50
|
+
"view-screen",
|
|
51
|
+
"tool-search",
|
|
52
|
+
] as const;
|
|
53
|
+
const REALTIME_VOICE_TOOL_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
54
|
+
const REALTIME_VOICE_MICROPHONE_STORAGE_KEY =
|
|
55
|
+
"agent-native:realtime-voice-microphone";
|
|
56
|
+
|
|
57
|
+
export const REALTIME_VOICE_LANGUAGES = [
|
|
58
|
+
"auto",
|
|
59
|
+
"en",
|
|
60
|
+
"es",
|
|
61
|
+
"fr",
|
|
62
|
+
"de",
|
|
63
|
+
"it",
|
|
64
|
+
"pt",
|
|
65
|
+
"ja",
|
|
66
|
+
"ko",
|
|
67
|
+
"zh",
|
|
68
|
+
] as const;
|
|
69
|
+
export const REALTIME_VOICE_INTELLIGENCE_LEVELS = [
|
|
70
|
+
"instant",
|
|
71
|
+
"balanced",
|
|
72
|
+
"deep",
|
|
73
|
+
] as const;
|
|
74
|
+
export const REALTIME_VOICES = [
|
|
75
|
+
"marin",
|
|
76
|
+
"cedar",
|
|
77
|
+
"coral",
|
|
78
|
+
"sage",
|
|
79
|
+
"verse",
|
|
80
|
+
"alloy",
|
|
81
|
+
"ash",
|
|
82
|
+
"ballad",
|
|
83
|
+
"echo",
|
|
84
|
+
"shimmer",
|
|
85
|
+
] as const;
|
|
86
|
+
|
|
87
|
+
export type RealtimeVoiceLanguage = (typeof REALTIME_VOICE_LANGUAGES)[number];
|
|
88
|
+
export type RealtimeVoiceIntelligence =
|
|
89
|
+
(typeof REALTIME_VOICE_INTELLIGENCE_LEVELS)[number];
|
|
90
|
+
export type RealtimeVoice = (typeof REALTIME_VOICES)[number];
|
|
91
|
+
|
|
92
|
+
export interface RealtimeVoicePreferences {
|
|
93
|
+
language: RealtimeVoiceLanguage;
|
|
94
|
+
intelligence: RealtimeVoiceIntelligence;
|
|
95
|
+
voice: RealtimeVoice;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const DEFAULT_REALTIME_VOICE_PREFERENCES: RealtimeVoicePreferences = {
|
|
99
|
+
language: "auto",
|
|
100
|
+
intelligence: "instant",
|
|
101
|
+
voice: "marin",
|
|
102
|
+
};
|
|
40
103
|
|
|
41
104
|
export const REALTIME_VOICE_AUDIO_CONSTRAINTS: MediaTrackConstraints = {
|
|
42
105
|
// Prefer the browser/OS-selected input instead of whichever physical device
|
|
@@ -55,6 +118,19 @@ export interface RealtimeVoiceToolResult {
|
|
|
55
118
|
status: "completed" | "failed" | "approval_required";
|
|
56
119
|
output: string;
|
|
57
120
|
approvalKey?: string;
|
|
121
|
+
expandedTools?: RealtimeVoiceFunctionTool[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface RealtimeVoiceSessionAnswer {
|
|
125
|
+
sdp: string;
|
|
126
|
+
capability?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface RealtimeVoiceFunctionTool {
|
|
130
|
+
type: "function";
|
|
131
|
+
name: string;
|
|
132
|
+
description?: string;
|
|
133
|
+
parameters?: Record<string, unknown>;
|
|
58
134
|
}
|
|
59
135
|
|
|
60
136
|
export interface RealtimeVoiceModeApi {
|
|
@@ -63,17 +139,359 @@ export interface RealtimeVoiceModeApi {
|
|
|
63
139
|
errorMessage: string | null;
|
|
64
140
|
chatVisible: boolean;
|
|
65
141
|
audioLevels: RealtimeVoiceAudioLevelStore;
|
|
142
|
+
preferences: RealtimeVoicePreferences;
|
|
143
|
+
microphones: readonly RealtimeVoiceMicrophone[];
|
|
144
|
+
microphoneDeviceId: string;
|
|
145
|
+
microphoneSwitching: boolean;
|
|
146
|
+
microphoneError: string | null;
|
|
147
|
+
voiceChangePending: boolean;
|
|
148
|
+
setLanguage: (language: RealtimeVoiceLanguage) => void;
|
|
149
|
+
setIntelligence: (intelligence: RealtimeVoiceIntelligence) => void;
|
|
150
|
+
setVoice: (voice: RealtimeVoice) => void;
|
|
151
|
+
setMicrophone: (deviceId: string) => Promise<void>;
|
|
66
152
|
start: () => Promise<void>;
|
|
67
153
|
end: () => void;
|
|
68
154
|
toggleChat: () => void;
|
|
69
155
|
}
|
|
70
156
|
|
|
157
|
+
export interface RealtimeVoiceMicrophone {
|
|
158
|
+
deviceId: string;
|
|
159
|
+
label: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function createRealtimeVoiceAudioConstraints(
|
|
163
|
+
deviceId = "default",
|
|
164
|
+
exact = false,
|
|
165
|
+
): MediaTrackConstraints {
|
|
166
|
+
return {
|
|
167
|
+
...REALTIME_VOICE_AUDIO_CONSTRAINTS,
|
|
168
|
+
deviceId:
|
|
169
|
+
deviceId === "default"
|
|
170
|
+
? { ideal: "default" }
|
|
171
|
+
: exact
|
|
172
|
+
? { exact: deviceId }
|
|
173
|
+
: { ideal: deviceId },
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function replaceRealtimeVoiceMicrophone(options: {
|
|
178
|
+
mediaDevices: Pick<MediaDevices, "getUserMedia">;
|
|
179
|
+
peer: Pick<RTCPeerConnection, "getSenders">;
|
|
180
|
+
currentStream: MediaStream | null;
|
|
181
|
+
deviceId: string;
|
|
182
|
+
}): Promise<MediaStream> {
|
|
183
|
+
const replacementStream = await options.mediaDevices.getUserMedia({
|
|
184
|
+
audio: createRealtimeVoiceAudioConstraints(options.deviceId, true),
|
|
185
|
+
});
|
|
186
|
+
try {
|
|
187
|
+
const replacementTrack = replacementStream.getAudioTracks()[0];
|
|
188
|
+
const sender = options.peer
|
|
189
|
+
.getSenders()
|
|
190
|
+
.find((candidate) => candidate.track?.kind === "audio");
|
|
191
|
+
if (!replacementTrack || !sender) {
|
|
192
|
+
throw new Error("No active microphone track is available.");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await sender.replaceTrack(replacementTrack);
|
|
196
|
+
for (const track of options.currentStream?.getTracks() ?? []) {
|
|
197
|
+
track.onended = null;
|
|
198
|
+
track.stop();
|
|
199
|
+
}
|
|
200
|
+
return replacementStream;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
for (const track of replacementStream.getTracks()) track.stop();
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function readRealtimeVoiceMicrophoneId(): string {
|
|
208
|
+
if (typeof window === "undefined") return "default";
|
|
209
|
+
try {
|
|
210
|
+
return (
|
|
211
|
+
window.localStorage.getItem(REALTIME_VOICE_MICROPHONE_STORAGE_KEY) ||
|
|
212
|
+
"default"
|
|
213
|
+
);
|
|
214
|
+
} catch {
|
|
215
|
+
return "default";
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function writeRealtimeVoiceMicrophoneId(deviceId: string): void {
|
|
220
|
+
try {
|
|
221
|
+
window.localStorage.setItem(
|
|
222
|
+
REALTIME_VOICE_MICROPHONE_STORAGE_KEY,
|
|
223
|
+
deviceId,
|
|
224
|
+
);
|
|
225
|
+
} catch {
|
|
226
|
+
// A remembered local device is optional and must never block voice mode.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isOneOf<T extends readonly string[]>(
|
|
231
|
+
values: T,
|
|
232
|
+
value: unknown,
|
|
233
|
+
): value is T[number] {
|
|
234
|
+
return typeof value === "string" && values.includes(value);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function normalizeRealtimeVoicePreferences(
|
|
238
|
+
value: unknown,
|
|
239
|
+
): RealtimeVoicePreferences {
|
|
240
|
+
const record =
|
|
241
|
+
value && typeof value === "object" && !Array.isArray(value)
|
|
242
|
+
? (value as Record<string, unknown>)
|
|
243
|
+
: {};
|
|
244
|
+
return {
|
|
245
|
+
language: isOneOf(REALTIME_VOICE_LANGUAGES, record.language)
|
|
246
|
+
? record.language
|
|
247
|
+
: DEFAULT_REALTIME_VOICE_PREFERENCES.language,
|
|
248
|
+
intelligence: isOneOf(
|
|
249
|
+
REALTIME_VOICE_INTELLIGENCE_LEVELS,
|
|
250
|
+
record.intelligence,
|
|
251
|
+
)
|
|
252
|
+
? record.intelligence
|
|
253
|
+
: DEFAULT_REALTIME_VOICE_PREFERENCES.intelligence,
|
|
254
|
+
voice: isOneOf(REALTIME_VOICES, record.voice)
|
|
255
|
+
? record.voice
|
|
256
|
+
: DEFAULT_REALTIME_VOICE_PREFERENCES.voice,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function resolveRealtimeVoiceLanguage(
|
|
261
|
+
language: RealtimeVoiceLanguage,
|
|
262
|
+
browserLanguages: readonly string[] = [],
|
|
263
|
+
): Exclude<RealtimeVoiceLanguage, "auto"> {
|
|
264
|
+
if (language !== "auto") return language;
|
|
265
|
+
for (const locale of browserLanguages) {
|
|
266
|
+
const primary = locale.trim().split("-")[0]?.toLowerCase();
|
|
267
|
+
if (isOneOf(REALTIME_VOICE_LANGUAGES, primary) && primary !== "auto") {
|
|
268
|
+
return primary;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return "en";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function realtimeVoiceReasoningEffort(
|
|
275
|
+
intelligence: RealtimeVoiceIntelligence,
|
|
276
|
+
): "minimal" | "low" | "medium" {
|
|
277
|
+
if (intelligence === "balanced") return "low";
|
|
278
|
+
if (intelligence === "deep") return "medium";
|
|
279
|
+
return "minimal";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function createRealtimeVoicePreferenceUpdate(
|
|
283
|
+
preferences: RealtimeVoicePreferences,
|
|
284
|
+
options: {
|
|
285
|
+
browserLanguages?: readonly string[];
|
|
286
|
+
includeVoice?: boolean;
|
|
287
|
+
} = {},
|
|
288
|
+
): Record<string, unknown> {
|
|
289
|
+
return {
|
|
290
|
+
type: "session.update",
|
|
291
|
+
session: {
|
|
292
|
+
type: "realtime",
|
|
293
|
+
reasoning: {
|
|
294
|
+
effort: realtimeVoiceReasoningEffort(preferences.intelligence),
|
|
295
|
+
},
|
|
296
|
+
audio: {
|
|
297
|
+
input: {
|
|
298
|
+
transcription: {
|
|
299
|
+
model: "gpt-4o-mini-transcribe",
|
|
300
|
+
language: resolveRealtimeVoiceLanguage(
|
|
301
|
+
preferences.language,
|
|
302
|
+
options.browserLanguages,
|
|
303
|
+
),
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
...(options.includeVoice
|
|
307
|
+
? { output: { voice: preferences.voice } }
|
|
308
|
+
: {}),
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
71
314
|
export interface CompletedRealtimeVoiceTranscript {
|
|
72
315
|
role: "user" | "assistant";
|
|
73
316
|
text: string;
|
|
74
317
|
providerId?: string;
|
|
75
318
|
}
|
|
76
319
|
|
|
320
|
+
export interface RealtimeVoiceTranscriptSequencer {
|
|
321
|
+
handle: (event: RealtimeServerEvent) => void;
|
|
322
|
+
reset: () => void;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
interface SequencedRealtimeVoiceTranscript {
|
|
326
|
+
status: "pending" | "completed" | "skipped";
|
|
327
|
+
role?: CompletedRealtimeVoiceTranscript["role"];
|
|
328
|
+
transcript?: CompletedRealtimeVoiceTranscript;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Input transcription is produced by a separate ASR model and can finish after
|
|
333
|
+
* the assistant response. Reserve each message's position when OpenAI adds it
|
|
334
|
+
* to the conversation, then publish only the contiguous completed prefix.
|
|
335
|
+
*/
|
|
336
|
+
export function createRealtimeVoiceTranscriptSequencer(
|
|
337
|
+
publish: (transcript: CompletedRealtimeVoiceTranscript) => void,
|
|
338
|
+
): RealtimeVoiceTranscriptSequencer {
|
|
339
|
+
const order: string[] = [];
|
|
340
|
+
const items = new Map<string, SequencedRealtimeVoiceTranscript>();
|
|
341
|
+
const settledItemIds = new Set<string>();
|
|
342
|
+
const receivedTranscriptIds = new Set<string>();
|
|
343
|
+
|
|
344
|
+
const reserve = (
|
|
345
|
+
id: string,
|
|
346
|
+
role?: CompletedRealtimeVoiceTranscript["role"],
|
|
347
|
+
) => {
|
|
348
|
+
if (settledItemIds.has(id)) return;
|
|
349
|
+
const existing = items.get(id);
|
|
350
|
+
if (existing) {
|
|
351
|
+
existing.role ??= role;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
order.push(id);
|
|
355
|
+
items.set(id, { status: "pending", role });
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const drain = () => {
|
|
359
|
+
while (order.length > 0) {
|
|
360
|
+
const id = order[0]!;
|
|
361
|
+
const entry = items.get(id);
|
|
362
|
+
if (!entry || entry.status === "pending") return;
|
|
363
|
+
order.shift();
|
|
364
|
+
items.delete(id);
|
|
365
|
+
settledItemIds.add(id);
|
|
366
|
+
if (entry.status === "completed" && entry.transcript) {
|
|
367
|
+
publish(entry.transcript);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const skip = (id: unknown) => {
|
|
373
|
+
if (typeof id !== "string" || !id) return;
|
|
374
|
+
reserve(id);
|
|
375
|
+
const entry = items.get(id)!;
|
|
376
|
+
if (entry.status === "pending") entry.status = "skipped";
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
handle(event) {
|
|
381
|
+
if (
|
|
382
|
+
event.type === "conversation.item.added" ||
|
|
383
|
+
event.type === "conversation.item.created"
|
|
384
|
+
) {
|
|
385
|
+
const item = event.item;
|
|
386
|
+
if (item && typeof item === "object") {
|
|
387
|
+
const record = item as Record<string, unknown>;
|
|
388
|
+
if (
|
|
389
|
+
record.type === "message" &&
|
|
390
|
+
(record.role === "user" || record.role === "assistant") &&
|
|
391
|
+
typeof record.id === "string"
|
|
392
|
+
) {
|
|
393
|
+
reserve(record.id, record.role);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const transcript = extractCompletedRealtimeVoiceTranscript(event);
|
|
399
|
+
if (transcript) {
|
|
400
|
+
const transcriptId = transcript.providerId
|
|
401
|
+
? `${transcript.role}:${transcript.providerId}`
|
|
402
|
+
: undefined;
|
|
403
|
+
if (transcriptId && receivedTranscriptIds.has(transcriptId)) return;
|
|
404
|
+
if (transcriptId) receivedTranscriptIds.add(transcriptId);
|
|
405
|
+
const itemId =
|
|
406
|
+
typeof event.item_id === "string" ? event.item_id : undefined;
|
|
407
|
+
if (!itemId) {
|
|
408
|
+
// Older providers may omit item_id. Match the first reserved slot for
|
|
409
|
+
// that role so one incomplete legacy event cannot strand the queue.
|
|
410
|
+
const reservedId = order.find((id) => {
|
|
411
|
+
const entry = items.get(id);
|
|
412
|
+
return (
|
|
413
|
+
entry?.status === "pending" && entry.role === transcript.role
|
|
414
|
+
);
|
|
415
|
+
});
|
|
416
|
+
if (reservedId) {
|
|
417
|
+
items.set(reservedId, {
|
|
418
|
+
status: "completed",
|
|
419
|
+
role: transcript.role,
|
|
420
|
+
transcript,
|
|
421
|
+
});
|
|
422
|
+
} else {
|
|
423
|
+
publish(transcript);
|
|
424
|
+
}
|
|
425
|
+
} else {
|
|
426
|
+
if (settledItemIds.has(itemId)) return;
|
|
427
|
+
reserve(itemId, transcript.role);
|
|
428
|
+
items.set(itemId, {
|
|
429
|
+
status: "completed",
|
|
430
|
+
role: transcript.role,
|
|
431
|
+
transcript,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
} else if (
|
|
435
|
+
event.type === "conversation.item.input_audio_transcription.failed" ||
|
|
436
|
+
event.type === "conversation.item.deleted"
|
|
437
|
+
) {
|
|
438
|
+
skip(event.item_id);
|
|
439
|
+
} else if (event.type === "response.done") {
|
|
440
|
+
const response = event.response;
|
|
441
|
+
const output =
|
|
442
|
+
response && typeof response === "object"
|
|
443
|
+
? (response as { output?: unknown }).output
|
|
444
|
+
: undefined;
|
|
445
|
+
if (Array.isArray(output)) {
|
|
446
|
+
for (const item of output) {
|
|
447
|
+
if (!item || typeof item !== "object") continue;
|
|
448
|
+
const record = item as Record<string, unknown>;
|
|
449
|
+
if (record.type === "message") skip(record.id);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
drain();
|
|
454
|
+
},
|
|
455
|
+
reset() {
|
|
456
|
+
order.length = 0;
|
|
457
|
+
items.clear();
|
|
458
|
+
settledItemIds.clear();
|
|
459
|
+
receivedTranscriptIds.clear();
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function createRealtimeVoiceGreetingEvent(): Record<string, unknown> {
|
|
465
|
+
return {
|
|
466
|
+
type: "response.create",
|
|
467
|
+
response: {
|
|
468
|
+
output_modalities: ["audio"],
|
|
469
|
+
instructions:
|
|
470
|
+
'Say exactly: "How can I help you?" Do not add anything else.',
|
|
471
|
+
// Do not set max_output_tokens here. Realtime counts assistant audio at
|
|
472
|
+
// roughly one token per 50 ms, so a text-sized cap can cut speech off.
|
|
473
|
+
// The exact-response instruction already keeps this greeting bounded.
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function createRealtimeVoiceGreetingStarter(
|
|
479
|
+
send: (event: Record<string, unknown>) => void,
|
|
480
|
+
): { start: () => boolean; reset: () => void } {
|
|
481
|
+
let started = false;
|
|
482
|
+
return {
|
|
483
|
+
start() {
|
|
484
|
+
if (started) return false;
|
|
485
|
+
started = true;
|
|
486
|
+
send(createRealtimeVoiceGreetingEvent());
|
|
487
|
+
return true;
|
|
488
|
+
},
|
|
489
|
+
reset() {
|
|
490
|
+
started = false;
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
77
495
|
/**
|
|
78
496
|
* Voice mode owns the chat only temporarily. Restore the captured transcript
|
|
79
497
|
* when it is still the user's active thread (or the chat has no active thread)
|
|
@@ -127,6 +545,14 @@ export function isRealtimeVoiceAbortError(error: unknown): boolean {
|
|
|
127
545
|
);
|
|
128
546
|
}
|
|
129
547
|
|
|
548
|
+
export function isRealtimeVoiceSetupRequiredError(error: unknown): boolean {
|
|
549
|
+
return Boolean(
|
|
550
|
+
error &&
|
|
551
|
+
typeof error === "object" &&
|
|
552
|
+
(error as { status?: unknown }).status === 409,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
130
556
|
export function createRealtimeVoiceConnectionTimeout(
|
|
131
557
|
onTimeout: () => void,
|
|
132
558
|
timeoutMs = REALTIME_VOICE_CONNECTION_TIMEOUT_MS,
|
|
@@ -135,6 +561,24 @@ export function createRealtimeVoiceConnectionTimeout(
|
|
|
135
561
|
return () => window.clearTimeout(timeout);
|
|
136
562
|
}
|
|
137
563
|
|
|
564
|
+
export function createRealtimeVoiceConnectionGate(
|
|
565
|
+
onTimeout: () => void,
|
|
566
|
+
timeoutMs = REALTIME_VOICE_CONNECTION_TIMEOUT_MS,
|
|
567
|
+
): {
|
|
568
|
+
markTransportReady: () => void;
|
|
569
|
+
markSessionCreated: () => void;
|
|
570
|
+
cancel: () => void;
|
|
571
|
+
} {
|
|
572
|
+
const cancel = createRealtimeVoiceConnectionTimeout(onTimeout, timeoutMs);
|
|
573
|
+
return {
|
|
574
|
+
// RTC connectivity alone is not enough: the session may never finish its
|
|
575
|
+
// Realtime handshake. Keep the deadline armed until session.created.
|
|
576
|
+
markTransportReady() {},
|
|
577
|
+
markSessionCreated: cancel,
|
|
578
|
+
cancel,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
138
582
|
async function readErrorResponse(response: Response): Promise<string> {
|
|
139
583
|
const raw = await response.text().catch(() => "");
|
|
140
584
|
if (!raw) return response.statusText || `HTTP ${response.status}`;
|
|
@@ -146,10 +590,18 @@ async function readErrorResponse(response: Response): Promise<string> {
|
|
|
146
590
|
}
|
|
147
591
|
}
|
|
148
592
|
|
|
149
|
-
|
|
593
|
+
interface RealtimeVoiceSessionOptions {
|
|
594
|
+
browserTabId?: string;
|
|
595
|
+
signal?: AbortSignal;
|
|
596
|
+
preferences?: RealtimeVoicePreferences;
|
|
597
|
+
browserLanguages?: readonly string[];
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export async function createRealtimeVoiceSessionWithCapability(
|
|
150
601
|
offerSdp: string,
|
|
151
|
-
options:
|
|
152
|
-
): Promise<
|
|
602
|
+
options: RealtimeVoiceSessionOptions = {},
|
|
603
|
+
): Promise<RealtimeVoiceSessionAnswer> {
|
|
604
|
+
const preferences = options.preferences;
|
|
153
605
|
const response = await fetch(agentNativePath(REALTIME_VOICE_SESSION_PATH), {
|
|
154
606
|
method: "POST",
|
|
155
607
|
credentials: "same-origin",
|
|
@@ -158,6 +610,16 @@ export async function createRealtimeVoiceSession(
|
|
|
158
610
|
...(options.browserTabId
|
|
159
611
|
? { "X-Agent-Native-Browser-Tab": options.browserTabId }
|
|
160
612
|
: {}),
|
|
613
|
+
...(preferences
|
|
614
|
+
? {
|
|
615
|
+
"X-Agent-Native-Realtime-Language": resolveRealtimeVoiceLanguage(
|
|
616
|
+
preferences.language,
|
|
617
|
+
options.browserLanguages,
|
|
618
|
+
),
|
|
619
|
+
"X-Agent-Native-Realtime-Intelligence": preferences.intelligence,
|
|
620
|
+
"X-Agent-Native-Realtime-Voice": preferences.voice,
|
|
621
|
+
}
|
|
622
|
+
: {}),
|
|
161
623
|
},
|
|
162
624
|
body: offerSdp,
|
|
163
625
|
signal: options.signal,
|
|
@@ -168,7 +630,21 @@ export async function createRealtimeVoiceSession(
|
|
|
168
630
|
(error as { status?: number }).status = response.status;
|
|
169
631
|
throw error;
|
|
170
632
|
}
|
|
171
|
-
|
|
633
|
+
const sdp = await response.text();
|
|
634
|
+
const capability = response.headers.get(REALTIME_VOICE_CAPABILITY_HEADER);
|
|
635
|
+
return {
|
|
636
|
+
sdp,
|
|
637
|
+
...(capability ? { capability } : {}),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Backwards-compatible SDP-only session helper for existing client callers. */
|
|
642
|
+
export async function createRealtimeVoiceSession(
|
|
643
|
+
offerSdp: string,
|
|
644
|
+
options: RealtimeVoiceSessionOptions = {},
|
|
645
|
+
): Promise<string> {
|
|
646
|
+
return (await createRealtimeVoiceSessionWithCapability(offerSdp, options))
|
|
647
|
+
.sdp;
|
|
172
648
|
}
|
|
173
649
|
|
|
174
650
|
export async function executeRealtimeVoiceTool(input: {
|
|
@@ -177,6 +653,7 @@ export async function executeRealtimeVoiceTool(input: {
|
|
|
177
653
|
callId: string;
|
|
178
654
|
sessionId?: string;
|
|
179
655
|
browserTabId?: string;
|
|
656
|
+
capability?: string;
|
|
180
657
|
signal?: AbortSignal;
|
|
181
658
|
}): Promise<RealtimeVoiceToolResult> {
|
|
182
659
|
const response = await fetch(agentNativePath(REALTIME_VOICE_TOOL_PATH), {
|
|
@@ -187,6 +664,9 @@ export async function executeRealtimeVoiceTool(input: {
|
|
|
187
664
|
...(input.browserTabId
|
|
188
665
|
? { "X-Agent-Native-Browser-Tab": input.browserTabId }
|
|
189
666
|
: {}),
|
|
667
|
+
...(input.capability
|
|
668
|
+
? { [REALTIME_VOICE_CAPABILITY_HEADER]: input.capability }
|
|
669
|
+
: {}),
|
|
190
670
|
},
|
|
191
671
|
body: JSON.stringify({
|
|
192
672
|
name: input.name,
|
|
@@ -203,6 +683,264 @@ export async function executeRealtimeVoiceTool(input: {
|
|
|
203
683
|
return (await response.json()) as RealtimeVoiceToolResult;
|
|
204
684
|
}
|
|
205
685
|
|
|
686
|
+
function jsonByteLength(value: unknown): number {
|
|
687
|
+
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function normalizeRealtimeVoiceFunctionTool(
|
|
691
|
+
value: unknown,
|
|
692
|
+
): RealtimeVoiceFunctionTool | null {
|
|
693
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
694
|
+
const record = value as Record<string, unknown>;
|
|
695
|
+
if (
|
|
696
|
+
record.type !== "function" ||
|
|
697
|
+
typeof record.name !== "string" ||
|
|
698
|
+
!REALTIME_VOICE_TOOL_NAME.test(record.name)
|
|
699
|
+
) {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
if (
|
|
703
|
+
record.description !== undefined &&
|
|
704
|
+
typeof record.description !== "string"
|
|
705
|
+
) {
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
if (
|
|
709
|
+
record.parameters !== undefined &&
|
|
710
|
+
(!record.parameters ||
|
|
711
|
+
typeof record.parameters !== "object" ||
|
|
712
|
+
Array.isArray(record.parameters))
|
|
713
|
+
) {
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
try {
|
|
717
|
+
if (jsonByteLength(record) > REALTIME_VOICE_MAX_TOOL_SCHEMA_BYTES) {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
return JSON.parse(JSON.stringify(record)) as RealtimeVoiceFunctionTool;
|
|
721
|
+
} catch {
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export function extractRealtimeVoiceSessionTools(
|
|
727
|
+
event: RealtimeServerEvent,
|
|
728
|
+
): RealtimeVoiceFunctionTool[] | null {
|
|
729
|
+
if (event.type !== "session.created" && event.type !== "session.updated") {
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
const session = event.session;
|
|
733
|
+
if (!session || typeof session !== "object") return null;
|
|
734
|
+
const tools = (session as { tools?: unknown }).tools;
|
|
735
|
+
if (!Array.isArray(tools)) return null;
|
|
736
|
+
return tools
|
|
737
|
+
.map(normalizeRealtimeVoiceFunctionTool)
|
|
738
|
+
.filter((tool): tool is RealtimeVoiceFunctionTool => Boolean(tool));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function createRealtimeVoiceToolManifestUpdate(
|
|
742
|
+
tools: RealtimeVoiceFunctionTool[],
|
|
743
|
+
eventId?: string,
|
|
744
|
+
): Record<string, unknown> {
|
|
745
|
+
return {
|
|
746
|
+
type: "session.update",
|
|
747
|
+
...(eventId ? { event_id: eventId } : {}),
|
|
748
|
+
session: {
|
|
749
|
+
type: "realtime",
|
|
750
|
+
tools,
|
|
751
|
+
tool_choice: "auto",
|
|
752
|
+
},
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Merge search-discovered schemas into the live manifest. Pinned navigation
|
|
758
|
+
* and discovery tools stay available, then the newest discoveries take the
|
|
759
|
+
* remaining slots ahead of lower-priority tools from the original manifest.
|
|
760
|
+
*/
|
|
761
|
+
export function mergeRealtimeVoiceToolManifest(
|
|
762
|
+
currentTools: readonly RealtimeVoiceFunctionTool[],
|
|
763
|
+
expandedTools: readonly RealtimeVoiceFunctionTool[],
|
|
764
|
+
): RealtimeVoiceFunctionTool[] {
|
|
765
|
+
const current = new Map<string, RealtimeVoiceFunctionTool>();
|
|
766
|
+
for (const value of currentTools) {
|
|
767
|
+
const tool = normalizeRealtimeVoiceFunctionTool(value);
|
|
768
|
+
if (tool) current.set(tool.name, tool);
|
|
769
|
+
}
|
|
770
|
+
const expanded = new Map<string, RealtimeVoiceFunctionTool>();
|
|
771
|
+
for (const value of expandedTools) {
|
|
772
|
+
const tool = normalizeRealtimeVoiceFunctionTool(value);
|
|
773
|
+
if (tool) expanded.set(tool.name, tool);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const candidates: RealtimeVoiceFunctionTool[] = [];
|
|
777
|
+
const seen = new Set<string>();
|
|
778
|
+
const add = (tool: RealtimeVoiceFunctionTool | undefined) => {
|
|
779
|
+
if (!tool || seen.has(tool.name)) return;
|
|
780
|
+
seen.add(tool.name);
|
|
781
|
+
candidates.push(tool);
|
|
782
|
+
};
|
|
783
|
+
for (const name of REALTIME_VOICE_PRIORITY_TOOL_NAMES) {
|
|
784
|
+
add(expanded.get(name) ?? current.get(name));
|
|
785
|
+
}
|
|
786
|
+
for (const tool of expanded.values()) add(tool);
|
|
787
|
+
for (const tool of current.values()) add(tool);
|
|
788
|
+
|
|
789
|
+
const packed: RealtimeVoiceFunctionTool[] = [];
|
|
790
|
+
for (const tool of candidates) {
|
|
791
|
+
if (packed.length >= REALTIME_VOICE_MAX_TOOLS) break;
|
|
792
|
+
const candidate = [...packed, tool];
|
|
793
|
+
if (
|
|
794
|
+
jsonByteLength(
|
|
795
|
+
createRealtimeVoiceToolManifestUpdate(
|
|
796
|
+
candidate,
|
|
797
|
+
"realtime_tool_manifest_999999999",
|
|
798
|
+
),
|
|
799
|
+
) <= REALTIME_VOICE_MAX_SESSION_BYTES
|
|
800
|
+
) {
|
|
801
|
+
packed.push(tool);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return packed;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function createRealtimeVoiceToolResultEvent(
|
|
808
|
+
result: RealtimeVoiceToolResult,
|
|
809
|
+
): Record<string, unknown> {
|
|
810
|
+
const modelResult = {
|
|
811
|
+
callId: result.callId,
|
|
812
|
+
status: result.status,
|
|
813
|
+
output: result.output,
|
|
814
|
+
...(result.approvalKey ? { approvalKey: result.approvalKey } : {}),
|
|
815
|
+
};
|
|
816
|
+
return {
|
|
817
|
+
type: "conversation.item.create",
|
|
818
|
+
item: {
|
|
819
|
+
type: "function_call_output",
|
|
820
|
+
call_id: result.callId,
|
|
821
|
+
output: JSON.stringify(modelResult),
|
|
822
|
+
},
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export interface RealtimeVoiceToolManifestCoordinator {
|
|
827
|
+
enqueue: (result: RealtimeVoiceToolResult) => void;
|
|
828
|
+
setSessionTools: (tools: readonly RealtimeVoiceFunctionTool[]) => void;
|
|
829
|
+
handleError: (eventId: string | undefined, message?: string) => boolean;
|
|
830
|
+
reset: () => void;
|
|
831
|
+
getTools: () => readonly RealtimeVoiceFunctionTool[];
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Realtime treats `session.update.tools` as a full replacement. Serialize
|
|
836
|
+
* discovery updates and wait for a confirming `session.updated` manifest
|
|
837
|
+
* before returning the search result and allowing the next model response.
|
|
838
|
+
*/
|
|
839
|
+
export function createRealtimeVoiceToolManifestCoordinator(
|
|
840
|
+
send: (event: Record<string, unknown>) => void,
|
|
841
|
+
timeoutMs = REALTIME_VOICE_TOOL_UPDATE_TIMEOUT_MS,
|
|
842
|
+
): RealtimeVoiceToolManifestCoordinator {
|
|
843
|
+
let currentTools: RealtimeVoiceFunctionTool[] = [];
|
|
844
|
+
const queue: RealtimeVoiceToolResult[] = [];
|
|
845
|
+
let updateSequence = 0;
|
|
846
|
+
let active:
|
|
847
|
+
| {
|
|
848
|
+
result: RealtimeVoiceToolResult;
|
|
849
|
+
expectedNames: string[];
|
|
850
|
+
eventId: string;
|
|
851
|
+
timer: ReturnType<typeof setTimeout>;
|
|
852
|
+
}
|
|
853
|
+
| undefined;
|
|
854
|
+
|
|
855
|
+
const finish = (failureMessage?: string) => {
|
|
856
|
+
if (!active) return;
|
|
857
|
+
const { result, timer } = active;
|
|
858
|
+
clearTimeout(timer);
|
|
859
|
+
active = undefined;
|
|
860
|
+
send(
|
|
861
|
+
createRealtimeVoiceToolResultEvent(
|
|
862
|
+
failureMessage
|
|
863
|
+
? {
|
|
864
|
+
callId: result.callId,
|
|
865
|
+
status: "failed",
|
|
866
|
+
output: `${failureMessage} The discovered tools were not added to this voice session. Original tool result: ${result.output}`,
|
|
867
|
+
}
|
|
868
|
+
: result,
|
|
869
|
+
),
|
|
870
|
+
);
|
|
871
|
+
send({ type: "response.create" });
|
|
872
|
+
drain();
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
const drain = () => {
|
|
876
|
+
if (active) return;
|
|
877
|
+
const result = queue.shift();
|
|
878
|
+
if (!result) return;
|
|
879
|
+
const expanded = Array.isArray(result.expandedTools)
|
|
880
|
+
? result.expandedTools
|
|
881
|
+
.map(normalizeRealtimeVoiceFunctionTool)
|
|
882
|
+
.filter((tool): tool is RealtimeVoiceFunctionTool => Boolean(tool))
|
|
883
|
+
: [];
|
|
884
|
+
if (result.status !== "completed" || expanded.length === 0) {
|
|
885
|
+
send(createRealtimeVoiceToolResultEvent(result));
|
|
886
|
+
send({ type: "response.create" });
|
|
887
|
+
drain();
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const merged = mergeRealtimeVoiceToolManifest(currentTools, expanded);
|
|
892
|
+
const mergedNames = new Set(merged.map((tool) => tool.name));
|
|
893
|
+
const expectedNames = expanded
|
|
894
|
+
.map((tool) => tool.name)
|
|
895
|
+
.filter((name) => mergedNames.has(name));
|
|
896
|
+
if (expectedNames.length === 0) {
|
|
897
|
+
send(createRealtimeVoiceToolResultEvent(result));
|
|
898
|
+
send({ type: "response.create" });
|
|
899
|
+
drain();
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const eventId = `realtime_tool_manifest_${++updateSequence}`;
|
|
904
|
+
active = {
|
|
905
|
+
result,
|
|
906
|
+
expectedNames,
|
|
907
|
+
eventId,
|
|
908
|
+
timer: setTimeout(
|
|
909
|
+
() => finish("Timed out while installing the discovered tools."),
|
|
910
|
+
timeoutMs,
|
|
911
|
+
),
|
|
912
|
+
};
|
|
913
|
+
send(createRealtimeVoiceToolManifestUpdate(merged, eventId));
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
return {
|
|
917
|
+
enqueue(result) {
|
|
918
|
+
queue.push(result);
|
|
919
|
+
drain();
|
|
920
|
+
},
|
|
921
|
+
setSessionTools(tools) {
|
|
922
|
+
currentTools = mergeRealtimeVoiceToolManifest([], tools);
|
|
923
|
+
if (!active) return;
|
|
924
|
+
const names = new Set(currentTools.map((tool) => tool.name));
|
|
925
|
+
if (active.expectedNames.every((name) => names.has(name))) finish();
|
|
926
|
+
},
|
|
927
|
+
handleError(eventId, message) {
|
|
928
|
+
if (!active || !eventId || active.eventId !== eventId) return false;
|
|
929
|
+
finish(message || "The provider rejected the discovered tool update.");
|
|
930
|
+
return true;
|
|
931
|
+
},
|
|
932
|
+
reset() {
|
|
933
|
+
if (active) clearTimeout(active.timer);
|
|
934
|
+
active = undefined;
|
|
935
|
+
queue.length = 0;
|
|
936
|
+
currentTools = [];
|
|
937
|
+
},
|
|
938
|
+
getTools() {
|
|
939
|
+
return currentTools;
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
206
944
|
export function extractRealtimeVoiceFunctionCalls(
|
|
207
945
|
event: RealtimeServerEvent,
|
|
208
946
|
): Array<{ name: string; callId: string; argumentsText: string }> {
|
|
@@ -269,19 +1007,6 @@ function openOpenAiKeySettings(): void {
|
|
|
269
1007
|
);
|
|
270
1008
|
}
|
|
271
1009
|
|
|
272
|
-
function openMicrophoneSettings(): void {
|
|
273
|
-
if (typeof window === "undefined") return;
|
|
274
|
-
window.location.assign(`${appPath("/settings")}#voice`);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
export function endRealtimeVoiceBeforeNavigation(
|
|
278
|
-
end: () => void,
|
|
279
|
-
navigate: () => void,
|
|
280
|
-
): void {
|
|
281
|
-
end();
|
|
282
|
-
window.setTimeout(navigate, 0);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
1010
|
export function listenForRealtimeVoicePageHide(
|
|
286
1011
|
cleanup: () => void,
|
|
287
1012
|
): () => void {
|
|
@@ -304,7 +1029,49 @@ function voiceCopy(t: ReturnType<typeof useT>): RealtimeVoiceModeCopy {
|
|
|
304
1029
|
showChat: t("agentPanel.voiceMode.showChat"),
|
|
305
1030
|
hideChat: t("agentPanel.voiceMode.hideChat"),
|
|
306
1031
|
endVoiceMode: t("agentPanel.voiceMode.end"),
|
|
307
|
-
|
|
1032
|
+
voiceSettings: t("agentPanel.voiceMode.voiceSettings"),
|
|
1033
|
+
settings: {
|
|
1034
|
+
language: t("agentPanel.voiceMode.settings.language"),
|
|
1035
|
+
autoLanguage: t("agentPanel.voiceMode.settings.autoLanguage"),
|
|
1036
|
+
languages: {
|
|
1037
|
+
en: t("agentPanel.voiceMode.settings.languages.en"),
|
|
1038
|
+
es: t("agentPanel.voiceMode.settings.languages.es"),
|
|
1039
|
+
fr: t("agentPanel.voiceMode.settings.languages.fr"),
|
|
1040
|
+
de: t("agentPanel.voiceMode.settings.languages.de"),
|
|
1041
|
+
it: t("agentPanel.voiceMode.settings.languages.it"),
|
|
1042
|
+
pt: t("agentPanel.voiceMode.settings.languages.pt"),
|
|
1043
|
+
ja: t("agentPanel.voiceMode.settings.languages.ja"),
|
|
1044
|
+
ko: t("agentPanel.voiceMode.settings.languages.ko"),
|
|
1045
|
+
zh: t("agentPanel.voiceMode.settings.languages.zh"),
|
|
1046
|
+
},
|
|
1047
|
+
intelligence: t("agentPanel.voiceMode.settings.intelligence"),
|
|
1048
|
+
intelligenceLevels: {
|
|
1049
|
+
instant: t("agentPanel.voiceMode.settings.intelligenceLevels.instant"),
|
|
1050
|
+
balanced: t(
|
|
1051
|
+
"agentPanel.voiceMode.settings.intelligenceLevels.balanced",
|
|
1052
|
+
),
|
|
1053
|
+
deep: t("agentPanel.voiceMode.settings.intelligenceLevels.deep"),
|
|
1054
|
+
},
|
|
1055
|
+
voiceStyle: t("agentPanel.voiceMode.settings.voiceStyle"),
|
|
1056
|
+
microphone: t("agentPanel.voiceMode.settings.microphone"),
|
|
1057
|
+
defaultMicrophone: t("agentPanel.voiceMode.settings.defaultMicrophone"),
|
|
1058
|
+
microphoneSwitchFailed: t(
|
|
1059
|
+
"agentPanel.voiceMode.settings.microphoneSwitchFailed",
|
|
1060
|
+
),
|
|
1061
|
+
voiceChangePending: t("agentPanel.voiceMode.settings.voiceChangePending"),
|
|
1062
|
+
voiceDescriptions: {
|
|
1063
|
+
marin: t("agentPanel.voiceMode.settings.voiceDescriptions.marin"),
|
|
1064
|
+
cedar: t("agentPanel.voiceMode.settings.voiceDescriptions.cedar"),
|
|
1065
|
+
coral: t("agentPanel.voiceMode.settings.voiceDescriptions.coral"),
|
|
1066
|
+
sage: t("agentPanel.voiceMode.settings.voiceDescriptions.sage"),
|
|
1067
|
+
verse: t("agentPanel.voiceMode.settings.voiceDescriptions.verse"),
|
|
1068
|
+
alloy: t("agentPanel.voiceMode.settings.voiceDescriptions.alloy"),
|
|
1069
|
+
ash: t("agentPanel.voiceMode.settings.voiceDescriptions.ash"),
|
|
1070
|
+
ballad: t("agentPanel.voiceMode.settings.voiceDescriptions.ballad"),
|
|
1071
|
+
echo: t("agentPanel.voiceMode.settings.voiceDescriptions.echo"),
|
|
1072
|
+
shimmer: t("agentPanel.voiceMode.settings.voiceDescriptions.shimmer"),
|
|
1073
|
+
},
|
|
1074
|
+
},
|
|
308
1075
|
status: {
|
|
309
1076
|
connecting: t("agentPanel.voiceMode.status.connecting"),
|
|
310
1077
|
listening: t("agentPanel.voiceMode.status.listening"),
|
|
@@ -318,6 +1085,7 @@ function voiceCopy(t: ReturnType<typeof useT>): RealtimeVoiceModeCopy {
|
|
|
318
1085
|
responseFailed: t("agentPanel.voiceMode.errors.responseFailed"),
|
|
319
1086
|
sessionFailed: t("agentPanel.voiceMode.errors.sessionFailed"),
|
|
320
1087
|
channelDisconnected: t("agentPanel.voiceMode.errors.channelDisconnected"),
|
|
1088
|
+
connectionTimedOut: t("agentPanel.voiceMode.errors.connectionTimedOut"),
|
|
321
1089
|
connectionFailed: t("agentPanel.voiceMode.errors.connectionFailed"),
|
|
322
1090
|
offerFailed: t("agentPanel.voiceMode.errors.offerFailed"),
|
|
323
1091
|
},
|
|
@@ -336,8 +1104,20 @@ function useRealtimeVoiceModeController(
|
|
|
336
1104
|
const [state, setState] = useState<"idle" | RealtimeVoiceModeState>("idle");
|
|
337
1105
|
const [error, setError] = useState<string | null>(null);
|
|
338
1106
|
const [chatVisible, setChatVisible] = useState(false);
|
|
1107
|
+
const [preferences, setPreferences] = useState(
|
|
1108
|
+
DEFAULT_REALTIME_VOICE_PREFERENCES,
|
|
1109
|
+
);
|
|
1110
|
+
const [voiceChangePending, setVoiceChangePending] = useState(false);
|
|
1111
|
+
const [microphones, setMicrophones] = useState<RealtimeVoiceMicrophone[]>([]);
|
|
1112
|
+
const [microphoneDeviceId, setMicrophoneDeviceId] = useState(
|
|
1113
|
+
readRealtimeVoiceMicrophoneId,
|
|
1114
|
+
);
|
|
1115
|
+
const [microphoneSwitching, setMicrophoneSwitching] = useState(false);
|
|
1116
|
+
const [microphoneError, setMicrophoneError] = useState<string | null>(null);
|
|
339
1117
|
const [audioLevels] = useState(createRealtimeVoiceAudioLevelStore);
|
|
340
1118
|
const stateRef = useRef(state);
|
|
1119
|
+
const preferencesRef = useRef(preferences);
|
|
1120
|
+
const hasOutputAudioRef = useRef(false);
|
|
341
1121
|
const peerRef = useRef<RTCPeerConnection | null>(null);
|
|
342
1122
|
const channelRef = useRef<RTCDataChannel | null>(null);
|
|
343
1123
|
const streamRef = useRef<MediaStream | null>(null);
|
|
@@ -352,19 +1132,56 @@ function useRealtimeVoiceModeController(
|
|
|
352
1132
|
const meterFrameRef = useRef<number | null>(null);
|
|
353
1133
|
const lastMeterSampleRef = useRef(0);
|
|
354
1134
|
const abortRef = useRef<AbortController | null>(null);
|
|
355
|
-
const
|
|
1135
|
+
const connectionGateRef = useRef<ReturnType<
|
|
1136
|
+
typeof createRealtimeVoiceConnectionGate
|
|
1137
|
+
> | null>(null);
|
|
356
1138
|
const handledCallsRef = useRef(new Set<string>());
|
|
357
1139
|
const sessionIdRef = useRef<string | undefined>(undefined);
|
|
1140
|
+
const capabilityRef = useRef<string | undefined>(undefined);
|
|
1141
|
+
const transportGenerationRef = useRef(0);
|
|
358
1142
|
const startedAtRef = useRef<string | undefined>(undefined);
|
|
359
1143
|
const lastUserTextRef = useRef("");
|
|
360
1144
|
const lastAssistantTextRef = useRef("");
|
|
361
1145
|
const transcriptThreadIdRef = useRef<string | undefined>(undefined);
|
|
362
1146
|
const transcriptSequenceRef = useRef(0);
|
|
1147
|
+
const preferencesHydratedRef = useRef(false);
|
|
1148
|
+
const preferencesEditedRef = useRef(false);
|
|
1149
|
+
const preferenceWriteChainRef = useRef<Promise<void>>(Promise.resolve());
|
|
1150
|
+
const toolManifestCoordinator = useMemo(
|
|
1151
|
+
() =>
|
|
1152
|
+
createRealtimeVoiceToolManifestCoordinator((event) => {
|
|
1153
|
+
sendDataChannelEvent(channelRef.current, event);
|
|
1154
|
+
}),
|
|
1155
|
+
[],
|
|
1156
|
+
);
|
|
1157
|
+
const switchMicrophoneRef = useRef<
|
|
1158
|
+
(deviceId: string, force?: boolean) => Promise<void>
|
|
1159
|
+
>(async () => undefined);
|
|
363
1160
|
|
|
364
1161
|
useEffect(() => {
|
|
365
1162
|
stateRef.current = state;
|
|
366
1163
|
}, [state]);
|
|
367
1164
|
|
|
1165
|
+
useEffect(() => {
|
|
1166
|
+
preferencesRef.current = preferences;
|
|
1167
|
+
}, [preferences]);
|
|
1168
|
+
|
|
1169
|
+
const hydratePreferences = useCallback(async () => {
|
|
1170
|
+
if (preferencesHydratedRef.current) return;
|
|
1171
|
+
try {
|
|
1172
|
+
const stored = await readClientAppState(REALTIME_VOICE_PREFERENCES_KEY);
|
|
1173
|
+
if (!preferencesEditedRef.current && stored != null) {
|
|
1174
|
+
const next = normalizeRealtimeVoicePreferences(stored);
|
|
1175
|
+
preferencesRef.current = next;
|
|
1176
|
+
setPreferences(next);
|
|
1177
|
+
}
|
|
1178
|
+
} catch {
|
|
1179
|
+
// Preferences are optional; keep safe defaults when storage is unavailable.
|
|
1180
|
+
} finally {
|
|
1181
|
+
preferencesHydratedRef.current = true;
|
|
1182
|
+
}
|
|
1183
|
+
}, []);
|
|
1184
|
+
|
|
368
1185
|
const syncAppState = useCallback(
|
|
369
1186
|
(nextState: "idle" | RealtimeVoiceModeState) => {
|
|
370
1187
|
const value =
|
|
@@ -377,6 +1194,7 @@ function useRealtimeVoiceModeController(
|
|
|
377
1194
|
startedAt: startedAtRef.current,
|
|
378
1195
|
sessionId: sessionIdRef.current,
|
|
379
1196
|
browserTabId,
|
|
1197
|
+
preferences: preferencesRef.current,
|
|
380
1198
|
lastUserText: lastUserTextRef.current || undefined,
|
|
381
1199
|
lastAssistantText: lastAssistantTextRef.current || undefined,
|
|
382
1200
|
};
|
|
@@ -396,6 +1214,95 @@ function useRealtimeVoiceModeController(
|
|
|
396
1214
|
[syncAppState],
|
|
397
1215
|
);
|
|
398
1216
|
|
|
1217
|
+
const savePreferences = useCallback((next: RealtimeVoicePreferences) => {
|
|
1218
|
+
preferencesEditedRef.current = true;
|
|
1219
|
+
preferencesHydratedRef.current = true;
|
|
1220
|
+
preferencesRef.current = next;
|
|
1221
|
+
setPreferences(next);
|
|
1222
|
+
preferenceWriteChainRef.current = preferenceWriteChainRef.current
|
|
1223
|
+
.catch(() => undefined)
|
|
1224
|
+
.then(async () => {
|
|
1225
|
+
await setClientAppState(REALTIME_VOICE_PREFERENCES_KEY, next, {
|
|
1226
|
+
requestSource: REALTIME_VOICE_REQUEST_SOURCE,
|
|
1227
|
+
});
|
|
1228
|
+
})
|
|
1229
|
+
.catch(() => undefined);
|
|
1230
|
+
}, []);
|
|
1231
|
+
|
|
1232
|
+
const updateLivePreferences = useCallback(
|
|
1233
|
+
(next: RealtimeVoicePreferences, includeVoice = false) => {
|
|
1234
|
+
sendDataChannelEvent(
|
|
1235
|
+
channelRef.current,
|
|
1236
|
+
createRealtimeVoicePreferenceUpdate(next, {
|
|
1237
|
+
browserLanguages:
|
|
1238
|
+
typeof navigator === "undefined" ? [] : navigator.languages,
|
|
1239
|
+
includeVoice,
|
|
1240
|
+
}),
|
|
1241
|
+
);
|
|
1242
|
+
},
|
|
1243
|
+
[],
|
|
1244
|
+
);
|
|
1245
|
+
|
|
1246
|
+
const setLanguage = useCallback(
|
|
1247
|
+
(language: RealtimeVoiceLanguage) => {
|
|
1248
|
+
const next = { ...preferencesRef.current, language };
|
|
1249
|
+
savePreferences(next);
|
|
1250
|
+
updateLivePreferences(next);
|
|
1251
|
+
},
|
|
1252
|
+
[savePreferences, updateLivePreferences],
|
|
1253
|
+
);
|
|
1254
|
+
|
|
1255
|
+
const setIntelligence = useCallback(
|
|
1256
|
+
(intelligence: RealtimeVoiceIntelligence) => {
|
|
1257
|
+
const next = { ...preferencesRef.current, intelligence };
|
|
1258
|
+
savePreferences(next);
|
|
1259
|
+
updateLivePreferences(next);
|
|
1260
|
+
},
|
|
1261
|
+
[savePreferences, updateLivePreferences],
|
|
1262
|
+
);
|
|
1263
|
+
|
|
1264
|
+
const setVoice = useCallback(
|
|
1265
|
+
(voice: RealtimeVoice) => {
|
|
1266
|
+
const next = { ...preferencesRef.current, voice };
|
|
1267
|
+
savePreferences(next);
|
|
1268
|
+
if (hasOutputAudioRef.current) {
|
|
1269
|
+
setVoiceChangePending(true);
|
|
1270
|
+
updateLivePreferences(next);
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
setVoiceChangePending(false);
|
|
1274
|
+
updateLivePreferences(next, true);
|
|
1275
|
+
},
|
|
1276
|
+
[savePreferences, updateLivePreferences],
|
|
1277
|
+
);
|
|
1278
|
+
|
|
1279
|
+
const refreshMicrophones = useCallback(async (): Promise<
|
|
1280
|
+
RealtimeVoiceMicrophone[] | null
|
|
1281
|
+
> => {
|
|
1282
|
+
const mediaDevices = navigator.mediaDevices;
|
|
1283
|
+
if (!mediaDevices?.enumerateDevices) return null;
|
|
1284
|
+
try {
|
|
1285
|
+
const devices = await mediaDevices.enumerateDevices();
|
|
1286
|
+
const inputs = devices
|
|
1287
|
+
.filter(
|
|
1288
|
+
(device) =>
|
|
1289
|
+
device.kind === "audioinput" &&
|
|
1290
|
+
device.deviceId &&
|
|
1291
|
+
device.deviceId !== "default" &&
|
|
1292
|
+
device.deviceId !== "communications",
|
|
1293
|
+
)
|
|
1294
|
+
.map((device, index) => ({
|
|
1295
|
+
deviceId: device.deviceId,
|
|
1296
|
+
label: device.label || `Microphone ${index + 1}`,
|
|
1297
|
+
}));
|
|
1298
|
+
setMicrophones(inputs);
|
|
1299
|
+
return inputs;
|
|
1300
|
+
} catch {
|
|
1301
|
+
// Enumeration is progressive enhancement; system default remains usable.
|
|
1302
|
+
return null;
|
|
1303
|
+
}
|
|
1304
|
+
}, []);
|
|
1305
|
+
|
|
399
1306
|
const startMeterLoop = useCallback(() => {
|
|
400
1307
|
if (meterFrameRef.current !== null) return;
|
|
401
1308
|
const sample = (timestamp: number) => {
|
|
@@ -467,16 +1374,69 @@ function useRealtimeVoiceModeController(
|
|
|
467
1374
|
[startMeterLoop],
|
|
468
1375
|
);
|
|
469
1376
|
|
|
1377
|
+
const setMicrophone = useCallback(
|
|
1378
|
+
async (deviceId: string, force = false) => {
|
|
1379
|
+
if ((!force && deviceId === microphoneDeviceId) || microphoneSwitching) {
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
const mediaDevices = navigator.mediaDevices;
|
|
1383
|
+
const peer = peerRef.current;
|
|
1384
|
+
if (!mediaDevices?.getUserMedia || !peer) return;
|
|
1385
|
+
|
|
1386
|
+
setMicrophoneSwitching(true);
|
|
1387
|
+
setMicrophoneError(null);
|
|
1388
|
+
try {
|
|
1389
|
+
const replacementStream = await replaceRealtimeVoiceMicrophone({
|
|
1390
|
+
mediaDevices,
|
|
1391
|
+
peer,
|
|
1392
|
+
currentStream: streamRef.current,
|
|
1393
|
+
deviceId,
|
|
1394
|
+
});
|
|
1395
|
+
const replacementTrack = replacementStream.getAudioTracks()[0];
|
|
1396
|
+
streamRef.current = replacementStream;
|
|
1397
|
+
if (replacementTrack) {
|
|
1398
|
+
replacementTrack.onended = () => {
|
|
1399
|
+
void switchMicrophoneRef.current("default", true);
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
attachAudioMeter(streamRef.current, "input");
|
|
1403
|
+
writeRealtimeVoiceMicrophoneId(deviceId);
|
|
1404
|
+
setMicrophoneDeviceId(deviceId);
|
|
1405
|
+
await refreshMicrophones();
|
|
1406
|
+
} catch {
|
|
1407
|
+
setMicrophoneError(
|
|
1408
|
+
copy?.settings.microphoneSwitchFailed ??
|
|
1409
|
+
"Could not switch microphones. Your current microphone is still active.",
|
|
1410
|
+
);
|
|
1411
|
+
} finally {
|
|
1412
|
+
setMicrophoneSwitching(false);
|
|
1413
|
+
}
|
|
1414
|
+
},
|
|
1415
|
+
[
|
|
1416
|
+
attachAudioMeter,
|
|
1417
|
+
copy,
|
|
1418
|
+
microphoneDeviceId,
|
|
1419
|
+
microphoneSwitching,
|
|
1420
|
+
refreshMicrophones,
|
|
1421
|
+
],
|
|
1422
|
+
);
|
|
1423
|
+
switchMicrophoneRef.current = setMicrophone;
|
|
1424
|
+
|
|
470
1425
|
const cleanupTransport = useCallback(() => {
|
|
471
|
-
|
|
472
|
-
|
|
1426
|
+
connectionGateRef.current?.cancel();
|
|
1427
|
+
connectionGateRef.current = null;
|
|
1428
|
+
transportGenerationRef.current += 1;
|
|
1429
|
+
capabilityRef.current = undefined;
|
|
473
1430
|
abortRef.current?.abort();
|
|
474
1431
|
abortRef.current = null;
|
|
475
1432
|
channelRef.current?.close();
|
|
476
1433
|
channelRef.current = null;
|
|
477
1434
|
peerRef.current?.close();
|
|
478
1435
|
peerRef.current = null;
|
|
479
|
-
for (const track of streamRef.current?.getTracks() ?? [])
|
|
1436
|
+
for (const track of streamRef.current?.getTracks() ?? []) {
|
|
1437
|
+
track.onended = null;
|
|
1438
|
+
track.stop();
|
|
1439
|
+
}
|
|
480
1440
|
streamRef.current = null;
|
|
481
1441
|
if (audioRef.current) {
|
|
482
1442
|
audioRef.current.pause();
|
|
@@ -501,7 +1461,8 @@ function useRealtimeVoiceModeController(
|
|
|
501
1461
|
if (audioContext) void audioContext.close().catch(() => undefined);
|
|
502
1462
|
audioLevels.reset();
|
|
503
1463
|
handledCallsRef.current.clear();
|
|
504
|
-
|
|
1464
|
+
toolManifestCoordinator.reset();
|
|
1465
|
+
}, [audioLevels, toolManifestCoordinator]);
|
|
505
1466
|
|
|
506
1467
|
const fail = useCallback(
|
|
507
1468
|
(message: string, options?: { openKeySettings?: boolean }) => {
|
|
@@ -517,6 +1478,8 @@ function useRealtimeVoiceModeController(
|
|
|
517
1478
|
async (call: { name: string; callId: string; argumentsText: string }) => {
|
|
518
1479
|
if (handledCallsRef.current.has(call.callId)) return;
|
|
519
1480
|
handledCallsRef.current.add(call.callId);
|
|
1481
|
+
const transportGeneration = transportGenerationRef.current;
|
|
1482
|
+
const transportChannel = channelRef.current;
|
|
520
1483
|
transition("working");
|
|
521
1484
|
let result: RealtimeVoiceToolResult;
|
|
522
1485
|
try {
|
|
@@ -527,6 +1490,7 @@ function useRealtimeVoiceModeController(
|
|
|
527
1490
|
callId: call.callId,
|
|
528
1491
|
sessionId: sessionIdRef.current,
|
|
529
1492
|
browserTabId,
|
|
1493
|
+
capability: capabilityRef.current,
|
|
530
1494
|
signal: abortRef.current?.signal,
|
|
531
1495
|
});
|
|
532
1496
|
} catch (toolError) {
|
|
@@ -536,24 +1500,21 @@ function useRealtimeVoiceModeController(
|
|
|
536
1500
|
output: errorMessage(toolError),
|
|
537
1501
|
};
|
|
538
1502
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
});
|
|
547
|
-
sendDataChannelEvent(channelRef.current, { type: "response.create" });
|
|
1503
|
+
if (
|
|
1504
|
+
transportGeneration !== transportGenerationRef.current ||
|
|
1505
|
+
channelRef.current !== transportChannel
|
|
1506
|
+
) {
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
toolManifestCoordinator.enqueue(result);
|
|
548
1510
|
},
|
|
549
|
-
[browserTabId, transition],
|
|
1511
|
+
[browserTabId, toolManifestCoordinator, transition],
|
|
550
1512
|
);
|
|
551
1513
|
|
|
552
|
-
const
|
|
553
|
-
(
|
|
554
|
-
const transcript = extractCompletedRealtimeVoiceTranscript(event);
|
|
1514
|
+
const publishCompletedTranscript = useCallback(
|
|
1515
|
+
(transcript: CompletedRealtimeVoiceTranscript) => {
|
|
555
1516
|
const threadId = transcriptThreadIdRef.current;
|
|
556
|
-
if (!
|
|
1517
|
+
if (!threadId) return;
|
|
557
1518
|
const sessionIdentity =
|
|
558
1519
|
sessionIdRef.current ?? startedAtRef.current ?? "pending";
|
|
559
1520
|
const providerIdentity =
|
|
@@ -569,23 +1530,46 @@ function useRealtimeVoiceModeController(
|
|
|
569
1530
|
[],
|
|
570
1531
|
);
|
|
571
1532
|
|
|
1533
|
+
const transcriptSequencer = useMemo(
|
|
1534
|
+
() => createRealtimeVoiceTranscriptSequencer(publishCompletedTranscript),
|
|
1535
|
+
[publishCompletedTranscript],
|
|
1536
|
+
);
|
|
1537
|
+
const greetingStarter = useMemo(
|
|
1538
|
+
() =>
|
|
1539
|
+
createRealtimeVoiceGreetingStarter((event) => {
|
|
1540
|
+
sendDataChannelEvent(channelRef.current, event);
|
|
1541
|
+
}),
|
|
1542
|
+
[],
|
|
1543
|
+
);
|
|
1544
|
+
|
|
572
1545
|
const handleServerEvent = useCallback(
|
|
573
1546
|
(event: RealtimeServerEvent) => {
|
|
1547
|
+
transcriptSequencer.handle(event);
|
|
1548
|
+
const sessionTools = extractRealtimeVoiceSessionTools(event);
|
|
1549
|
+
if (sessionTools) toolManifestCoordinator.setSessionTools(sessionTools);
|
|
574
1550
|
if (event.type === "session.created") {
|
|
1551
|
+
connectionGateRef.current?.markSessionCreated();
|
|
1552
|
+
connectionGateRef.current = null;
|
|
575
1553
|
const session = event.session;
|
|
576
1554
|
if (session && typeof session === "object") {
|
|
577
1555
|
const id = (session as { id?: unknown }).id;
|
|
578
1556
|
if (typeof id === "string") sessionIdRef.current = id;
|
|
579
1557
|
}
|
|
580
|
-
|
|
1558
|
+
updateLivePreferences(preferencesRef.current, true);
|
|
1559
|
+
greetingStarter.start();
|
|
1560
|
+
transition("working");
|
|
581
1561
|
} else if (event.type === "input_audio_buffer.speech_started") {
|
|
582
1562
|
transition("listening");
|
|
583
1563
|
} else if (event.type === "input_audio_buffer.speech_stopped") {
|
|
584
1564
|
transition("working");
|
|
585
1565
|
} else if (event.type === "response.created") {
|
|
1566
|
+
// From this point onward the response is committed to audio output, so
|
|
1567
|
+
// changing voices risks violating Realtime's per-session voice lock.
|
|
1568
|
+
hasOutputAudioRef.current = true;
|
|
586
1569
|
lastAssistantTextRef.current = "";
|
|
587
1570
|
transition("working");
|
|
588
1571
|
} else if (event.type === "response.output_audio_transcript.delta") {
|
|
1572
|
+
hasOutputAudioRef.current = true;
|
|
589
1573
|
if (typeof event.delta === "string") {
|
|
590
1574
|
lastAssistantTextRef.current += event.delta;
|
|
591
1575
|
}
|
|
@@ -594,7 +1578,6 @@ function useRealtimeVoiceModeController(
|
|
|
594
1578
|
if (typeof event.transcript === "string") {
|
|
595
1579
|
lastAssistantTextRef.current = event.transcript;
|
|
596
1580
|
}
|
|
597
|
-
persistCompletedTranscript(event);
|
|
598
1581
|
syncAppState("speaking");
|
|
599
1582
|
} else if (
|
|
600
1583
|
event.type === "conversation.item.input_audio_transcription.completed"
|
|
@@ -602,7 +1585,6 @@ function useRealtimeVoiceModeController(
|
|
|
602
1585
|
if (typeof event.transcript === "string") {
|
|
603
1586
|
lastUserTextRef.current = event.transcript;
|
|
604
1587
|
}
|
|
605
|
-
persistCompletedTranscript(event);
|
|
606
1588
|
syncAppState("working");
|
|
607
1589
|
} else if (event.type === "response.done") {
|
|
608
1590
|
const response = event.response;
|
|
@@ -625,6 +1607,18 @@ function useRealtimeVoiceModeController(
|
|
|
625
1607
|
: typeof detail === "string"
|
|
626
1608
|
? detail
|
|
627
1609
|
: "";
|
|
1610
|
+
const expansionEventId =
|
|
1611
|
+
detail && typeof detail === "object"
|
|
1612
|
+
? ((detail as { event_id?: unknown }).event_id ?? event.event_id)
|
|
1613
|
+
: event.event_id;
|
|
1614
|
+
if (
|
|
1615
|
+
toolManifestCoordinator.handleError(
|
|
1616
|
+
typeof expansionEventId === "string" ? expansionEventId : undefined,
|
|
1617
|
+
message,
|
|
1618
|
+
)
|
|
1619
|
+
) {
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
628
1622
|
fail(
|
|
629
1623
|
message ||
|
|
630
1624
|
copy?.errors.sessionFailed ||
|
|
@@ -642,10 +1636,13 @@ function useRealtimeVoiceModeController(
|
|
|
642
1636
|
[
|
|
643
1637
|
copy,
|
|
644
1638
|
fail,
|
|
1639
|
+
greetingStarter,
|
|
645
1640
|
handleFunctionCall,
|
|
646
|
-
persistCompletedTranscript,
|
|
647
1641
|
syncAppState,
|
|
1642
|
+
transcriptSequencer,
|
|
648
1643
|
transition,
|
|
1644
|
+
toolManifestCoordinator,
|
|
1645
|
+
updateLivePreferences,
|
|
649
1646
|
],
|
|
650
1647
|
);
|
|
651
1648
|
|
|
@@ -666,42 +1663,62 @@ function useRealtimeVoiceModeController(
|
|
|
666
1663
|
lastUserTextRef.current = "";
|
|
667
1664
|
lastAssistantTextRef.current = "";
|
|
668
1665
|
sessionIdRef.current = undefined;
|
|
1666
|
+
hasOutputAudioRef.current = false;
|
|
1667
|
+
greetingStarter.reset();
|
|
1668
|
+
setVoiceChangePending(false);
|
|
669
1669
|
transcriptThreadIdRef.current =
|
|
670
1670
|
realtimeVoiceTranscriptRegistry.activeThreadId();
|
|
671
1671
|
transcriptSequenceRef.current = 0;
|
|
1672
|
+
transcriptSequencer.reset();
|
|
672
1673
|
transition("connecting");
|
|
673
1674
|
setChatVisible(false);
|
|
674
1675
|
window.dispatchEvent(new Event("agent-panel:close"));
|
|
675
1676
|
|
|
1677
|
+
await hydratePreferences();
|
|
1678
|
+
if ((stateRef.current as string) !== "connecting") return;
|
|
1679
|
+
|
|
676
1680
|
const abortController = new AbortController();
|
|
677
1681
|
abortRef.current = abortController;
|
|
678
1682
|
const isCurrentAttempt = () =>
|
|
679
1683
|
abortRef.current === abortController && !abortController.signal.aborted;
|
|
680
|
-
const
|
|
1684
|
+
const connectionGate = createRealtimeVoiceConnectionGate(() => {
|
|
681
1685
|
if (!isCurrentAttempt()) return;
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
if (!isCurrentAttempt()) return;
|
|
689
|
-
fail(
|
|
690
|
-
copy?.errors.connectionFailed ??
|
|
691
|
-
"The realtime voice connection timed out.",
|
|
692
|
-
);
|
|
693
|
-
},
|
|
694
|
-
);
|
|
1686
|
+
fail(
|
|
1687
|
+
copy?.errors.connectionTimedOut ??
|
|
1688
|
+
"The realtime voice connection timed out.",
|
|
1689
|
+
);
|
|
1690
|
+
});
|
|
1691
|
+
connectionGateRef.current = connectionGate;
|
|
695
1692
|
try {
|
|
696
1693
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
697
|
-
audio:
|
|
1694
|
+
audio: createRealtimeVoiceAudioConstraints(
|
|
1695
|
+
readRealtimeVoiceMicrophoneId(),
|
|
1696
|
+
),
|
|
698
1697
|
});
|
|
699
1698
|
if (!isCurrentAttempt()) {
|
|
700
1699
|
for (const track of stream.getTracks()) track.stop();
|
|
701
1700
|
return;
|
|
702
1701
|
}
|
|
703
1702
|
streamRef.current = stream;
|
|
1703
|
+
for (const track of stream.getAudioTracks()) {
|
|
1704
|
+
track.onended = () => {
|
|
1705
|
+
void switchMicrophoneRef.current("default", true);
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
704
1708
|
attachAudioMeter(stream, "input");
|
|
1709
|
+
void refreshMicrophones().then((inputs) => {
|
|
1710
|
+
const selected = readRealtimeVoiceMicrophoneId();
|
|
1711
|
+
if (
|
|
1712
|
+
inputs &&
|
|
1713
|
+
selected !== "default" &&
|
|
1714
|
+
!inputs.some((device) => device.deviceId === selected)
|
|
1715
|
+
) {
|
|
1716
|
+
// The persisted device was only an ideal preference, so getUserMedia
|
|
1717
|
+
// already selected a usable fallback for this new session.
|
|
1718
|
+
writeRealtimeVoiceMicrophoneId("default");
|
|
1719
|
+
setMicrophoneDeviceId("default");
|
|
1720
|
+
}
|
|
1721
|
+
});
|
|
705
1722
|
|
|
706
1723
|
const peer = new RTCPeerConnection();
|
|
707
1724
|
peerRef.current = peer;
|
|
@@ -720,7 +1737,7 @@ function useRealtimeVoiceModeController(
|
|
|
720
1737
|
|
|
721
1738
|
const channel = peer.createDataChannel("oai-events");
|
|
722
1739
|
channelRef.current = channel;
|
|
723
|
-
channel.onopen =
|
|
1740
|
+
channel.onopen = connectionGate.markTransportReady;
|
|
724
1741
|
channel.onmessage = (messageEvent) => {
|
|
725
1742
|
if (!isCurrentAttempt()) return;
|
|
726
1743
|
try {
|
|
@@ -738,7 +1755,9 @@ function useRealtimeVoiceModeController(
|
|
|
738
1755
|
};
|
|
739
1756
|
peer.onconnectionstatechange = () => {
|
|
740
1757
|
if (!isCurrentAttempt()) return;
|
|
741
|
-
if (peer.connectionState === "connected")
|
|
1758
|
+
if (peer.connectionState === "connected") {
|
|
1759
|
+
connectionGate.markTransportReady();
|
|
1760
|
+
}
|
|
742
1761
|
if (peer.connectionState === "failed") {
|
|
743
1762
|
fail(
|
|
744
1763
|
copy?.errors.connectionFailed ??
|
|
@@ -757,12 +1776,15 @@ function useRealtimeVoiceModeController(
|
|
|
757
1776
|
"The browser did not create an audio offer.",
|
|
758
1777
|
);
|
|
759
1778
|
}
|
|
760
|
-
const
|
|
1779
|
+
const answer = await createRealtimeVoiceSessionWithCapability(offer.sdp, {
|
|
761
1780
|
browserTabId,
|
|
762
1781
|
signal: abortController.signal,
|
|
1782
|
+
preferences: preferencesRef.current,
|
|
1783
|
+
browserLanguages: navigator.languages,
|
|
763
1784
|
});
|
|
764
1785
|
if (!isCurrentAttempt()) return;
|
|
765
|
-
|
|
1786
|
+
capabilityRef.current = answer.capability;
|
|
1787
|
+
await peer.setRemoteDescription({ type: "answer", sdp: answer.sdp });
|
|
766
1788
|
} catch (startError) {
|
|
767
1789
|
// Ending a connection aborts the SDP request by design. A superseded
|
|
768
1790
|
// attempt must never resurrect the dock or surface that cancellation as
|
|
@@ -774,15 +1796,36 @@ function useRealtimeVoiceModeController(
|
|
|
774
1796
|
) {
|
|
775
1797
|
return;
|
|
776
1798
|
}
|
|
1799
|
+
if (isRealtimeVoiceSetupRequiredError(startError)) {
|
|
1800
|
+
// Credentials can disappear after the entry-point status check. Treat
|
|
1801
|
+
// the authoritative setup response as a gate, not as an active voice
|
|
1802
|
+
// session failure: clean up the mic/RTC attempt, reopen chat, and
|
|
1803
|
+
// refresh both the chat and voice provider setup surfaces.
|
|
1804
|
+
cleanupTransport();
|
|
1805
|
+
setError(null);
|
|
1806
|
+
startedAtRef.current = undefined;
|
|
1807
|
+
sessionIdRef.current = undefined;
|
|
1808
|
+
transcriptThreadIdRef.current = undefined;
|
|
1809
|
+
transition("idle");
|
|
1810
|
+
setChatVisible(true);
|
|
1811
|
+
window.dispatchEvent(new Event("agent-panel:open"));
|
|
1812
|
+
window.dispatchEvent(new Event("agent-engine:configured-changed"));
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
777
1815
|
const status = (startError as { status?: unknown })?.status;
|
|
778
1816
|
fail(errorMessage(startError), { openKeySettings: status === 400 });
|
|
779
1817
|
}
|
|
780
1818
|
}, [
|
|
781
1819
|
attachAudioMeter,
|
|
782
1820
|
browserTabId,
|
|
1821
|
+
cleanupTransport,
|
|
783
1822
|
copy,
|
|
784
1823
|
fail,
|
|
1824
|
+
greetingStarter,
|
|
785
1825
|
handleServerEvent,
|
|
1826
|
+
hydratePreferences,
|
|
1827
|
+
refreshMicrophones,
|
|
1828
|
+
transcriptSequencer,
|
|
786
1829
|
transition,
|
|
787
1830
|
]);
|
|
788
1831
|
|
|
@@ -821,6 +1864,26 @@ function useRealtimeVoiceModeController(
|
|
|
821
1864
|
window.dispatchEvent(new Event("agent-panel:toggle"));
|
|
822
1865
|
}, []);
|
|
823
1866
|
|
|
1867
|
+
useEffect(() => {
|
|
1868
|
+
const mediaDevices = navigator.mediaDevices;
|
|
1869
|
+
if (!mediaDevices?.addEventListener) return;
|
|
1870
|
+
const handleDeviceChange = () => {
|
|
1871
|
+
const selected = readRealtimeVoiceMicrophoneId();
|
|
1872
|
+
void refreshMicrophones().then((inputs) => {
|
|
1873
|
+
if (
|
|
1874
|
+
inputs &&
|
|
1875
|
+
selected !== "default" &&
|
|
1876
|
+
!inputs.some((device) => device.deviceId === selected)
|
|
1877
|
+
) {
|
|
1878
|
+
void setMicrophone("default");
|
|
1879
|
+
}
|
|
1880
|
+
});
|
|
1881
|
+
};
|
|
1882
|
+
mediaDevices.addEventListener("devicechange", handleDeviceChange);
|
|
1883
|
+
return () =>
|
|
1884
|
+
mediaDevices.removeEventListener("devicechange", handleDeviceChange);
|
|
1885
|
+
}, [refreshMicrophones, setMicrophone]);
|
|
1886
|
+
|
|
824
1887
|
useEffect(() => {
|
|
825
1888
|
const onSidebarState = (event: Event) => {
|
|
826
1889
|
const detail = (event as CustomEvent<AgentSidebarStateChangeDetail>)
|
|
@@ -848,6 +1911,16 @@ function useRealtimeVoiceModeController(
|
|
|
848
1911
|
errorMessage: error,
|
|
849
1912
|
chatVisible,
|
|
850
1913
|
audioLevels,
|
|
1914
|
+
preferences,
|
|
1915
|
+
microphones,
|
|
1916
|
+
microphoneDeviceId,
|
|
1917
|
+
microphoneSwitching,
|
|
1918
|
+
microphoneError,
|
|
1919
|
+
voiceChangePending,
|
|
1920
|
+
setLanguage,
|
|
1921
|
+
setIntelligence,
|
|
1922
|
+
setVoice,
|
|
1923
|
+
setMicrophone,
|
|
851
1924
|
start,
|
|
852
1925
|
end,
|
|
853
1926
|
toggleChat,
|
|
@@ -868,6 +1941,77 @@ export function RealtimeVoiceModeProvider({
|
|
|
868
1941
|
);
|
|
869
1942
|
const copy = useRealtimeVoiceModeCopy();
|
|
870
1943
|
const voice = useRealtimeVoiceModeController(resolvedBrowserTabId, copy);
|
|
1944
|
+
const inlineSettings = useMemo(
|
|
1945
|
+
() => ({
|
|
1946
|
+
dialogLabel: copy.voiceSettings,
|
|
1947
|
+
...(voice.voiceChangePending
|
|
1948
|
+
? { appliesNextConversationNote: copy.settings.voiceChangePending }
|
|
1949
|
+
: {}),
|
|
1950
|
+
...(voice.microphoneError
|
|
1951
|
+
? { microphoneError: voice.microphoneError }
|
|
1952
|
+
: {}),
|
|
1953
|
+
microphone: {
|
|
1954
|
+
label: copy.settings.microphone,
|
|
1955
|
+
value: voice.microphoneDeviceId,
|
|
1956
|
+
disabled: voice.microphoneSwitching,
|
|
1957
|
+
options: [
|
|
1958
|
+
{
|
|
1959
|
+
value: "default",
|
|
1960
|
+
label: copy.settings.defaultMicrophone,
|
|
1961
|
+
},
|
|
1962
|
+
...voice.microphones.map((microphone) => ({
|
|
1963
|
+
value: microphone.deviceId,
|
|
1964
|
+
label: microphone.label,
|
|
1965
|
+
})),
|
|
1966
|
+
],
|
|
1967
|
+
onValueChange: (value: string) => {
|
|
1968
|
+
void voice.setMicrophone(value);
|
|
1969
|
+
},
|
|
1970
|
+
},
|
|
1971
|
+
language: {
|
|
1972
|
+
label: copy.settings.language,
|
|
1973
|
+
value: voice.preferences.language,
|
|
1974
|
+
options: REALTIME_VOICE_LANGUAGES.map((language) => ({
|
|
1975
|
+
value: language,
|
|
1976
|
+
label:
|
|
1977
|
+
language === "auto"
|
|
1978
|
+
? copy.settings.autoLanguage
|
|
1979
|
+
: copy.settings.languages[language],
|
|
1980
|
+
})),
|
|
1981
|
+
onValueChange: (value: string) => {
|
|
1982
|
+
if (isOneOf(REALTIME_VOICE_LANGUAGES, value)) {
|
|
1983
|
+
voice.setLanguage(value);
|
|
1984
|
+
}
|
|
1985
|
+
},
|
|
1986
|
+
},
|
|
1987
|
+
intelligence: {
|
|
1988
|
+
label: copy.settings.intelligence,
|
|
1989
|
+
value: voice.preferences.intelligence,
|
|
1990
|
+
options: REALTIME_VOICE_INTELLIGENCE_LEVELS.map((intelligence) => ({
|
|
1991
|
+
value: intelligence,
|
|
1992
|
+
label: copy.settings.intelligenceLevels[intelligence],
|
|
1993
|
+
})),
|
|
1994
|
+
onValueChange: (value: string) => {
|
|
1995
|
+
if (isOneOf(REALTIME_VOICE_INTELLIGENCE_LEVELS, value)) {
|
|
1996
|
+
voice.setIntelligence(value);
|
|
1997
|
+
}
|
|
1998
|
+
},
|
|
1999
|
+
},
|
|
2000
|
+
voiceStyle: {
|
|
2001
|
+
label: copy.settings.voiceStyle,
|
|
2002
|
+
value: voice.preferences.voice,
|
|
2003
|
+
options: REALTIME_VOICES.map((voiceName) => ({
|
|
2004
|
+
value: voiceName,
|
|
2005
|
+
label: `${voiceName[0]!.toUpperCase()}${voiceName.slice(1)}`,
|
|
2006
|
+
description: copy.settings.voiceDescriptions[voiceName],
|
|
2007
|
+
})),
|
|
2008
|
+
onValueChange: (value: string) => {
|
|
2009
|
+
if (isOneOf(REALTIME_VOICES, value)) voice.setVoice(value);
|
|
2010
|
+
},
|
|
2011
|
+
},
|
|
2012
|
+
}),
|
|
2013
|
+
[copy, voice],
|
|
2014
|
+
);
|
|
871
2015
|
|
|
872
2016
|
return (
|
|
873
2017
|
<RealtimeVoiceModeContext.Provider value={voice}>
|
|
@@ -881,12 +2025,7 @@ export function RealtimeVoiceModeProvider({
|
|
|
881
2025
|
audioLevels={voice.audioLevels}
|
|
882
2026
|
onToggleChat={voice.toggleChat}
|
|
883
2027
|
onEndVoiceMode={voice.end}
|
|
884
|
-
|
|
885
|
-
endRealtimeVoiceBeforeNavigation(
|
|
886
|
-
voice.end,
|
|
887
|
-
openMicrophoneSettings,
|
|
888
|
-
)
|
|
889
|
-
}
|
|
2028
|
+
settings={inlineSettings}
|
|
890
2029
|
errorMessage={voice.errorMessage}
|
|
891
2030
|
/>,
|
|
892
2031
|
document.body,
|