@theoremjs/react 0.0.0-stage → 0.3.1
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/LICENSE +21 -0
- package/README.md +772 -2
- package/client/attachment-hover-preview.d.ts +11 -0
- package/client/attachment-hover-preview.js +36 -0
- package/client/audio-level.d.ts +5 -0
- package/client/audio-level.js +24 -0
- package/client/composer-attachments.d.ts +30 -0
- package/client/composer-attachments.js +33 -0
- package/client/composer-drawer.d.ts +19 -0
- package/client/composer-drawer.js +15 -0
- package/client/composer-hints.d.ts +25 -0
- package/client/composer-hints.js +20 -0
- package/client/composer-primary.d.ts +16 -0
- package/client/composer-primary.js +25 -0
- package/client/decision-images.d.ts +76 -0
- package/client/decision-images.js +139 -0
- package/client/decision-transport.d.ts +70 -0
- package/client/decision-transport.js +175 -0
- package/client/decode-composer-draft.d.ts +11 -0
- package/client/decode-composer-draft.js +12 -0
- package/client/embedding-input.d.ts +84 -0
- package/client/embedding-input.js +194 -0
- package/client/embedding-space.d.ts +82 -0
- package/client/embedding-space.js +389 -0
- package/client/embedding-transport.d.ts +40 -0
- package/client/embedding-transport.js +71 -0
- package/client/encode-composer-draft.d.ts +9 -0
- package/client/encode-composer-draft.js +34 -0
- package/client/encode-files.d.ts +18 -0
- package/client/encode-files.js +54 -0
- package/client/failure.d.ts +29 -0
- package/client/failure.js +34 -0
- package/client/generation-selection.d.ts +15 -0
- package/client/generation-selection.js +24 -0
- package/client/host-call.d.ts +51 -0
- package/client/host-call.js +47 -0
- package/client/host-transport.d.ts +70 -0
- package/client/host-transport.js +75 -0
- package/client/image-output.d.ts +6 -0
- package/client/image-output.js +13 -0
- package/client/index.d.ts +28 -0
- package/client/index.js +22 -0
- package/client/ink-waveform.d.ts +17 -0
- package/client/ink-waveform.js +68 -0
- package/client/live/apply-live-turn-tool-event.d.ts +14 -0
- package/client/live/apply-live-turn-tool-event.js +39 -0
- package/client/live/live-captions.d.ts +53 -0
- package/client/live/live-captions.js +174 -0
- package/client/live/live-mic-forward.d.ts +30 -0
- package/client/live/live-mic-forward.js +46 -0
- package/client/live/live-page-tool.d.ts +34 -0
- package/client/live/live-page-tool.js +11 -0
- package/client/live/live-state.d.ts +13 -0
- package/client/live/live-state.js +22 -0
- package/client/live/live-tool.d.ts +11 -0
- package/client/live/live-tool.js +1 -0
- package/client/live/live-video.d.ts +10 -0
- package/client/live/live-video.js +71 -0
- package/client/live/run-live-tool-call.d.ts +22 -0
- package/client/live/run-live-tool-call.js +70 -0
- package/client/live/use-live-caption-log.d.ts +17 -0
- package/client/live/use-live-caption-log.js +51 -0
- package/client/live-client.d.ts +189 -0
- package/client/live-client.js +829 -0
- package/client/live-errors.d.ts +2 -0
- package/client/live-errors.js +9 -0
- package/client/live-messages.d.ts +148 -0
- package/client/live-messages.js +100 -0
- package/client/mic-capture.d.ts +4 -0
- package/client/mic-capture.js +6 -0
- package/client/oauth-popup.d.ts +26 -0
- package/client/oauth-popup.js +30 -0
- package/client/page-dispatch.d.ts +15 -0
- package/client/page-dispatch.js +63 -0
- package/client/pcm-downsample.d.ts +4 -0
- package/client/pcm-downsample.js +45 -0
- package/client/run-commit.d.ts +34 -0
- package/client/run-commit.js +49 -0
- package/client/run-session.d.ts +79 -0
- package/client/run-session.js +207 -0
- package/client/schema-fields.d.ts +63 -0
- package/client/schema-fields.js +199 -0
- package/client/shaped-data.d.ts +118 -0
- package/client/shaped-data.js +343 -0
- package/client/source-citations.d.ts +13 -0
- package/client/source-citations.js +56 -0
- package/client/space-geometry.d.ts +39 -0
- package/client/space-geometry.js +183 -0
- package/client/state-fields.d.ts +16 -0
- package/client/state-fields.js +44 -0
- package/client/tool-result.d.ts +78 -0
- package/client/tool-result.js +293 -0
- package/client/tool-resume.d.ts +50 -0
- package/client/tool-resume.js +14 -0
- package/client/trace-feed.d.ts +13 -0
- package/client/trace-feed.js +18 -0
- package/client/trace-story.d.ts +163 -0
- package/client/trace-story.js +440 -0
- package/client/trace-view.d.ts +130 -0
- package/client/trace-view.js +333 -0
- package/client/transcript-groups.d.ts +140 -0
- package/client/transcript-groups.js +217 -0
- package/client/transcript-schema.d.ts +10 -0
- package/client/transcript-schema.js +120 -0
- package/client/transcript-scroll.d.ts +7 -0
- package/client/transcript-scroll.js +6 -0
- package/client/transport.d.ts +264 -0
- package/client/transport.js +330 -0
- package/client/turn-client.d.ts +44 -0
- package/client/turn-client.js +88 -0
- package/client/voice-label.d.ts +2 -0
- package/client/voice-label.js +15 -0
- package/client/voice-recorder.d.ts +27 -0
- package/client/voice-recorder.js +162 -0
- package/client/wire-line.d.ts +56 -0
- package/client/wire-line.js +90 -0
- package/client/worklets/mic-capture.js +16 -0
- package/components/InkWaveform.d.ts +21 -0
- package/components/InkWaveform.js +131 -0
- package/components/live/use-live-runner-controls.d.ts +47 -0
- package/components/live/use-live-runner-controls.js +96 -0
- package/components/live/use-live-runner-model.d.ts +49 -0
- package/components/live/use-live-runner-model.js +190 -0
- package/components/live/use-live-runner-ui.d.ts +67 -0
- package/components/live/use-live-runner-ui.js +167 -0
- package/components/live/use-live-session-client.d.ts +41 -0
- package/components/live/use-live-session-client.js +126 -0
- package/components/use-composer-voice.d.ts +28 -0
- package/components/use-composer-voice.js +90 -0
- package/components/use-editor-selection.d.ts +3 -0
- package/components/use-editor-selection.js +19 -0
- package/components/use-voice-playback.d.ts +17 -0
- package/components/use-voice-playback.js +68 -0
- package/hooks/use-described.d.ts +12 -0
- package/hooks/use-described.js +25 -0
- package/hooks/use-embedding-index.d.ts +27 -0
- package/hooks/use-embedding-index.js +43 -0
- package/hooks/use-one-call.d.ts +31 -0
- package/hooks/use-one-call.js +76 -0
- package/hooks/use-page-tool-warnings.d.ts +6 -0
- package/hooks/use-page-tool-warnings.js +25 -0
- package/hooks/use-second-ticker.d.ts +2 -0
- package/hooks/use-second-ticker.js +13 -0
- package/hooks/use-theorem-chat-actions.d.ts +79 -0
- package/hooks/use-theorem-chat-actions.js +287 -0
- package/hooks/use-theorem-chat-state.d.ts +66 -0
- package/hooks/use-theorem-chat-state.js +122 -0
- package/hooks/use-theorem-chat.d.ts +97 -0
- package/hooks/use-theorem-chat.js +528 -0
- package/hooks/use-theorem-decision.d.ts +18 -0
- package/hooks/use-theorem-decision.js +12 -0
- package/hooks/use-theorem-embedding.d.ts +18 -0
- package/hooks/use-theorem-embedding.js +12 -0
- package/hooks/use-theorem-host.d.ts +45 -0
- package/hooks/use-theorem-host.js +135 -0
- package/hooks/use-theorem-interface.d.ts +22 -0
- package/hooks/use-theorem-interface.js +15 -0
- package/hooks/use-trace-records.d.ts +4 -0
- package/hooks/use-trace-records.js +7 -0
- package/index.d.ts +17 -0
- package/index.js +17 -0
- package/live.d.ts +9 -0
- package/live.js +10 -0
- package/package.json +74 -4
- package/server/checkpoint-ownership.d.ts +6 -0
- package/server/checkpoint-ownership.js +42 -0
- package/server/conversation-store.d.ts +92 -0
- package/server/conversation-store.js +131 -0
- package/server/conversation.d.ts +193 -0
- package/server/conversation.js +552 -0
- package/server/credential-store.d.ts +24 -0
- package/server/credential-store.js +14 -0
- package/server/decision-handler.d.ts +42 -0
- package/server/decision-handler.js +46 -0
- package/server/embedding-handler.d.ts +33 -0
- package/server/embedding-handler.js +45 -0
- package/server/handler.d.ts +152 -0
- package/server/handler.js +771 -0
- package/server/json-response.d.ts +2 -0
- package/server/json-response.js +7 -0
- package/server/mod.d.ts +25 -0
- package/server/mod.js +23 -0
- package/server/page-dispatch.d.ts +22 -0
- package/server/page-dispatch.js +127 -0
- package/server/request-body.d.ts +2 -0
- package/server/request-body.js +42 -0
- package/server/request-check.d.ts +21 -0
- package/server/request-check.js +49 -0
- package/server/session-authority.d.ts +8 -0
- package/server/session-authority.js +78 -0
- package/server/session-boundary.d.ts +26 -0
- package/server/session-boundary.js +64 -0
- package/server/session-identity.d.ts +16 -0
- package/server/session-identity.js +41 -0
- package/server/session-limits.d.ts +12 -0
- package/server/session-limits.js +59 -0
- package/server/session-store.d.ts +123 -0
- package/server/session-store.js +264 -0
- package/server/single-action.d.ts +21 -0
- package/server/single-action.js +30 -0
- package/server/steer-inbox.d.ts +35 -0
- package/server/steer-inbox.js +52 -0
- package/server/tool-operations.d.ts +6 -0
- package/server/tool-operations.js +132 -0
- package/server/turn-input.d.ts +10 -0
- package/server/turn-input.js +29 -0
- package/styles/ink-controls.css +134 -0
- package/ui/CardWall.d.ts +10 -0
- package/ui/CardWall.js +61 -0
- package/ui/ChatComposerBar.d.ts +62 -0
- package/ui/ChatComposerBar.js +306 -0
- package/ui/ChatTranscript.d.ts +43 -0
- package/ui/ChatTranscript.js +527 -0
- package/ui/ConsoleFrame.d.ts +104 -0
- package/ui/ConsoleFrame.js +146 -0
- package/ui/DecisionAnswers.d.ts +53 -0
- package/ui/DecisionAnswers.js +233 -0
- package/ui/DecisionImages.d.ts +16 -0
- package/ui/DecisionImages.js +46 -0
- package/ui/EmbeddingDocuments.d.ts +78 -0
- package/ui/EmbeddingDocuments.js +278 -0
- package/ui/EmbeddingQuery.d.ts +27 -0
- package/ui/EmbeddingQuery.js +95 -0
- package/ui/EmbeddingResults.d.ts +41 -0
- package/ui/EmbeddingResults.js +165 -0
- package/ui/EmbeddingSpace.d.ts +26 -0
- package/ui/EmbeddingSpace.js +106 -0
- package/ui/LiveCaptionsPanel.d.ts +22 -0
- package/ui/LiveCaptionsPanel.js +27 -0
- package/ui/LiveRunner.d.ts +30 -0
- package/ui/LiveRunner.js +180 -0
- package/ui/PaneState.d.ts +33 -0
- package/ui/PaneState.js +50 -0
- package/ui/SchemaFields.d.ts +10 -0
- package/ui/SchemaFields.js +150 -0
- package/ui/ShapedData.d.ts +11 -0
- package/ui/ShapedData.js +196 -0
- package/ui/SidePanel.d.ts +126 -0
- package/ui/SidePanel.js +123 -0
- package/ui/StateFields.d.ts +44 -0
- package/ui/StateFields.js +139 -0
- package/ui/TheoremChat.d.ts +122 -0
- package/ui/TheoremChat.js +134 -0
- package/ui/TheoremDecision.d.ts +30 -0
- package/ui/TheoremDecision.js +130 -0
- package/ui/TheoremEmbedding.d.ts +27 -0
- package/ui/TheoremEmbedding.js +122 -0
- package/ui/TheoremHost.d.ts +55 -0
- package/ui/TheoremHost.js +240 -0
- package/ui/ToolGateCard.d.ts +32 -0
- package/ui/ToolGateCard.js +107 -0
- package/ui/ToolResult.d.ts +10 -0
- package/ui/ToolResult.js +62 -0
- package/ui/ToolResultCharts.d.ts +5 -0
- package/ui/ToolResultCharts.js +103 -0
- package/ui/TraceCharts.d.ts +13 -0
- package/ui/TraceCharts.js +266 -0
- package/ui/TraceGuardrails.d.ts +11 -0
- package/ui/TraceGuardrails.js +74 -0
- package/ui/TraceInspector.d.ts +16 -0
- package/ui/TraceInspector.js +206 -0
- package/ui/TraceInspectorPanel.d.ts +46 -0
- package/ui/TraceInspectorPanel.js +95 -0
- package/ui/TraceOverview.d.ts +38 -0
- package/ui/TraceOverview.js +126 -0
- package/ui/TraceSpanDetail.d.ts +10 -0
- package/ui/TraceSpanDetail.js +190 -0
- package/ui/TraceStory.d.ts +20 -0
- package/ui/TraceStory.js +108 -0
- package/ui/TraceValues.d.ts +36 -0
- package/ui/TraceValues.js +123 -0
- package/ui/TraceWaterfall.d.ts +13 -0
- package/ui/TraceWaterfall.js +135 -0
- package/ui/VoiceNote.d.ts +12 -0
- package/ui/VoiceNote.js +28 -0
- package/ui/approval-heading.d.ts +4 -0
- package/ui/approval-heading.js +9 -0
- package/ui/arrive.d.ts +18 -0
- package/ui/arrive.js +46 -0
- package/ui/built/theme.css +792 -0
- package/ui/built/theme.css.d.ts +9 -0
- package/ui/built/theorem.d.ts +13 -0
- package/ui/built/theorem.js +660 -0
- package/ui/built/theorem.variants.d.ts +22 -0
- package/ui/code-language.d.ts +12 -0
- package/ui/code-language.js +50 -0
- package/ui/disclosure-motion.d.ts +6 -0
- package/ui/disclosure-motion.js +207 -0
- package/ui/embedding-space-gl.d.ts +37 -0
- package/ui/embedding-space-gl.js +663 -0
- package/ui/embedding-space-view.d.ts +8 -0
- package/ui/embedding-space-view.js +8 -0
- package/ui/glide.d.ts +7 -0
- package/ui/glide.js +44 -0
- package/ui/icons.d.ts +4 -0
- package/ui/icons.js +38 -0
- package/ui/index.d.ts +36 -0
- package/ui/index.js +39 -0
- package/ui/labels-provider.d.ts +16 -0
- package/ui/labels-provider.js +64 -0
- package/ui/labels.d.ts +1442 -0
- package/ui/labels.js +1443 -0
- package/ui/markdown-plugins.d.ts +9 -0
- package/ui/markdown-plugins.js +13 -0
- package/ui/mcp-icon.d.ts +6 -0
- package/ui/mcp-icon.js +8 -0
- package/ui/row-keys.d.ts +19 -0
- package/ui/row-keys.js +33 -0
- package/ui/theme.d.ts +10 -0
- package/ui/theme.js +13 -0
- package/ui/theorem-theme.d.ts +10 -0
- package/ui/theorem-theme.js +105 -0
- package/ui/trace-palette.d.ts +10 -0
- package/ui/trace-palette.js +10 -0
- package/ui/trace-patterns.d.ts +40 -0
- package/ui/trace-patterns.js +57 -0
- package/ui/transcript-copy-text.d.ts +4 -0
- package/ui/transcript-copy-text.js +40 -0
- package/ui/wall-columns.d.ts +8 -0
- package/ui/wall-columns.js +19 -0
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
// why: This is browser code; a Deno program that reaches it through `@theoremjs/react/client` checks it with the DOM.
|
|
2
|
+
/// <reference lib="dom" />
|
|
3
|
+
/**
|
|
4
|
+
* Pure client-side SDK for THEOREM Gemini 3.1 Flash Live sessions over WebSocket relay.
|
|
5
|
+
*
|
|
6
|
+
* Handles:
|
|
7
|
+
* - Bidirectional WebSocket connection to `/api/live/relay`.
|
|
8
|
+
* - 16-bit 16kHz PCM microphone audio recording & streaming.
|
|
9
|
+
* - Gapless 24kHz PCM / WAV model voice playback scheduling.
|
|
10
|
+
* - Barge-in interruption cancellation (instant audio queue flush).
|
|
11
|
+
* - Quiet-mic gate while model audio plays (reduces false barge-in from speaker bleed).
|
|
12
|
+
* - UI tool execution and response routing.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
import { describeError, labelText, TheoremError, } from '@theoremjs/agents';
|
|
17
|
+
import { base64ToBytes, bytesToBase64 } from '@theoremjs/agents/kernel';
|
|
18
|
+
import { float32Rms, float32RmsToLevel, timeDomainBytesToLevel } from "./audio-level.js";
|
|
19
|
+
import { applyLiveToolTurnEvent, liveTranscriptFromEvidence, shouldForwardMicFrame, } from "./live/live-mic-forward.js";
|
|
20
|
+
import { isPermissionDeniedError } from "./live-errors.js";
|
|
21
|
+
import { liveClientMessageSchema, parseLiveServerEnvelope, } from "./live-messages.js";
|
|
22
|
+
import { MIC_CAPTURE_PROCESSOR, micCaptureWorkletUrl } from "./mic-capture.js";
|
|
23
|
+
import { downsampleAndConvertToInt16, pcm16BytesToFloat32 } from "./pcm-downsample.js";
|
|
24
|
+
import { hostError } from "./transport.js";
|
|
25
|
+
import { issuedOperationId } from "./turn-client.js";
|
|
26
|
+
/** One message to the relay, in the shape a relay checks with `parseLiveClientMessage`. */
|
|
27
|
+
function clientMessage(message) {
|
|
28
|
+
return JSON.stringify(message);
|
|
29
|
+
}
|
|
30
|
+
function emptyInboundTurnAccum() {
|
|
31
|
+
return {
|
|
32
|
+
toolCalls: [],
|
|
33
|
+
mediaChunks: [],
|
|
34
|
+
cancelledToolIds: new Set(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* While the model is playing audio, mic frames below this RMS are not forwarded.
|
|
39
|
+
* Speaker bleed / keyboard / room noise sit under this; intentional barge-in speech
|
|
40
|
+
* sits above it. Gemini VAD only offers LOW|HIGH start sensitivity — this is the
|
|
41
|
+
* fine-grained "less choppy barge-in" knob.
|
|
42
|
+
*/
|
|
43
|
+
const BARGE_IN_RMS_WHILE_SPEAKING = 0.05;
|
|
44
|
+
/** How long the client waits before each try at taking a dropped call up again. After the last, the call fails. */
|
|
45
|
+
const LIVE_RECONNECT_DELAYS_MS = [500, 1000, 2000, 4000, 8000];
|
|
46
|
+
/** A caught value as an `Error`; one that is not keeps its text as `internal` detail. */
|
|
47
|
+
function asError(err) {
|
|
48
|
+
return err instanceof Error
|
|
49
|
+
? err
|
|
50
|
+
: new TheoremError('internal', describeError(err), { cause: err });
|
|
51
|
+
}
|
|
52
|
+
function safeDisconnect(node) {
|
|
53
|
+
if (!node || typeof node.disconnect !== 'function')
|
|
54
|
+
return;
|
|
55
|
+
try {
|
|
56
|
+
node.disconnect();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// why: disconnect() throws for a node that is not connected; it is disconnected either way.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function stopMediaTracks(stream) {
|
|
63
|
+
if (!stream || typeof stream.getTracks !== 'function')
|
|
64
|
+
return;
|
|
65
|
+
for (const track of stream.getTracks()) {
|
|
66
|
+
track.stop();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function checkMicFrameForward(args) {
|
|
70
|
+
const rms = float32Rms(args.inputFloat32);
|
|
71
|
+
const modelPlaying = args.hasPlaybackNodes || args.status === 'speaking';
|
|
72
|
+
return shouldForwardMicFrame({
|
|
73
|
+
isMuted: args.isMuted,
|
|
74
|
+
socketOpen: args.wsOpen,
|
|
75
|
+
modelPlaying,
|
|
76
|
+
rms,
|
|
77
|
+
bargeInRmsWhileSpeaking: BARGE_IN_RMS_WHILE_SPEAKING,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function resolveWorkingStatusTransition(currentStatus, serverWorking) {
|
|
81
|
+
if (currentStatus === 'listening' && serverWorking)
|
|
82
|
+
return 'working';
|
|
83
|
+
if (currentStatus === 'working' && !serverWorking)
|
|
84
|
+
return 'listening';
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
export class LiveSessionClient {
|
|
88
|
+
ws = null;
|
|
89
|
+
audioContext = null;
|
|
90
|
+
micStream = null;
|
|
91
|
+
micSource = null;
|
|
92
|
+
micWorklet = null;
|
|
93
|
+
micWorkletModuleLoaded = false;
|
|
94
|
+
playbackNodes = [];
|
|
95
|
+
playbackBus = null;
|
|
96
|
+
playbackAnalyser = null;
|
|
97
|
+
playbackMeterFrame = 0;
|
|
98
|
+
playbackMeterBuffer = null;
|
|
99
|
+
nextPlaybackTime = 0;
|
|
100
|
+
status = 'disconnected';
|
|
101
|
+
/** Server reported background work (reasoning / async tools) still in flight. */
|
|
102
|
+
serverWorking = false;
|
|
103
|
+
micActivating = false;
|
|
104
|
+
connectTimeout = null;
|
|
105
|
+
isMuted = false;
|
|
106
|
+
options;
|
|
107
|
+
/** Serialize control-plane handling (tools / status) without waiting on audio decode. */
|
|
108
|
+
inboundChain = Promise.resolve();
|
|
109
|
+
/** Ordered model-audio playback queue (separate so tools are not stuck behind decode). */
|
|
110
|
+
audioChain = Promise.resolve();
|
|
111
|
+
/**
|
|
112
|
+
* Tool calls, one at a time in the order the model made them. Apart from
|
|
113
|
+
* `inboundChain`: a call waits on the relay's reply, which arrives inbound.
|
|
114
|
+
*/
|
|
115
|
+
toolChain = Promise.resolve();
|
|
116
|
+
/** Bumped on barge-in / cancel so stale audioChain work is skipped. */
|
|
117
|
+
audioEpoch = 0;
|
|
118
|
+
pendingExecuteResults = new Map();
|
|
119
|
+
/** The page's context package, and the JSON of the one the relay last got. */
|
|
120
|
+
context;
|
|
121
|
+
sentContext;
|
|
122
|
+
/** The provider's latest handle for taking this call up again after a drop. */
|
|
123
|
+
history = [];
|
|
124
|
+
providerState;
|
|
125
|
+
/** The session said it ended, or failed: a close after that is the end, not a drop. */
|
|
126
|
+
sessionOver = false;
|
|
127
|
+
/** When the call dropped, for the time away a resume reports. */
|
|
128
|
+
droppedAt;
|
|
129
|
+
/** When the call that could not reconnect dropped: the next call takes it up again. */
|
|
130
|
+
lostAt;
|
|
131
|
+
reconnectTries = 0;
|
|
132
|
+
reconnectTimer = null;
|
|
133
|
+
constructor(options) {
|
|
134
|
+
this.options = options;
|
|
135
|
+
this.context = options.context;
|
|
136
|
+
}
|
|
137
|
+
setStatus(newStatus) {
|
|
138
|
+
this.status = newStatus;
|
|
139
|
+
if (newStatus === 'listening' || newStatus === 'disconnected' || newStatus === 'error') {
|
|
140
|
+
this.clearConnectTimeout();
|
|
141
|
+
}
|
|
142
|
+
this.options.onStatusChange?.(newStatus);
|
|
143
|
+
}
|
|
144
|
+
/** Still opening: the first connect, or a try at taking a dropped call up again. */
|
|
145
|
+
get opening() {
|
|
146
|
+
return this.status === 'connecting' || this.status === 'reconnecting';
|
|
147
|
+
}
|
|
148
|
+
setConnectPhase(phase) {
|
|
149
|
+
this.options.onConnectPhase?.(phase);
|
|
150
|
+
}
|
|
151
|
+
clearConnectTimeout() {
|
|
152
|
+
if (this.connectTimeout) {
|
|
153
|
+
clearTimeout(this.connectTimeout);
|
|
154
|
+
this.connectTimeout = null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
detachWebSocket() {
|
|
158
|
+
for (const [callId, pending] of this.pendingExecuteResults) {
|
|
159
|
+
// labels-exempt: internal diagnostic; the user reads error.network
|
|
160
|
+
pending.reject(new TheoremError('network', `live session closed before call ${callId} settled`));
|
|
161
|
+
}
|
|
162
|
+
this.pendingExecuteResults.clear();
|
|
163
|
+
if (!this.ws)
|
|
164
|
+
return;
|
|
165
|
+
this.ws.onopen = null;
|
|
166
|
+
this.ws.onmessage = null;
|
|
167
|
+
this.ws.onclose = null;
|
|
168
|
+
this.ws.onerror = null;
|
|
169
|
+
try {
|
|
170
|
+
this.ws.close();
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// why: closing a socket that already closed throws; the connection is over either way.
|
|
174
|
+
}
|
|
175
|
+
this.ws = null;
|
|
176
|
+
}
|
|
177
|
+
clearReconnect() {
|
|
178
|
+
if (this.reconnectTimer)
|
|
179
|
+
clearTimeout(this.reconnectTimer);
|
|
180
|
+
this.reconnectTimer = null;
|
|
181
|
+
this.reconnectTries = 0;
|
|
182
|
+
this.droppedAt = undefined;
|
|
183
|
+
}
|
|
184
|
+
teardownConnection() {
|
|
185
|
+
this.clearConnectTimeout();
|
|
186
|
+
this.micActivating = false;
|
|
187
|
+
this.setConnectPhase(null);
|
|
188
|
+
this.detachWebSocket();
|
|
189
|
+
this.cleanupAudio();
|
|
190
|
+
}
|
|
191
|
+
/** End the session on `error`: a failed connect or a lost socket. */
|
|
192
|
+
failSession(error) {
|
|
193
|
+
if (this.status === 'error' || this.status === 'disconnected')
|
|
194
|
+
return;
|
|
195
|
+
this.clearReconnect();
|
|
196
|
+
this.teardownConnection();
|
|
197
|
+
this.options.onError?.(error);
|
|
198
|
+
this.setStatus('error');
|
|
199
|
+
}
|
|
200
|
+
createSocket(url) {
|
|
201
|
+
return this.options.createSocket?.() ?? new WebSocket(url);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Start a call. After one that dropped and could not reconnect, it takes that call up again,
|
|
205
|
+
* so the agent neither greets twice nor forgets; after a hang-up it is a new call.
|
|
206
|
+
*/
|
|
207
|
+
connect() {
|
|
208
|
+
const lostAt = this.lostAt;
|
|
209
|
+
this.clearReconnect();
|
|
210
|
+
this.lostAt = undefined;
|
|
211
|
+
if (lostAt === undefined) {
|
|
212
|
+
this.providerState = undefined;
|
|
213
|
+
this.history = [];
|
|
214
|
+
}
|
|
215
|
+
else
|
|
216
|
+
this.droppedAt = lostAt;
|
|
217
|
+
return this.open('connecting');
|
|
218
|
+
}
|
|
219
|
+
/** A lost call that cannot be taken up again, its handle refused or expired, starts as a new one. */
|
|
220
|
+
startOver() {
|
|
221
|
+
if (this.status !== 'connecting' || !this.providerState)
|
|
222
|
+
return false;
|
|
223
|
+
this.providerState = undefined;
|
|
224
|
+
this.droppedAt = undefined;
|
|
225
|
+
void this.open('connecting');
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
/** The first message: the call's values, and the handle and time away when it takes a dropped call up again. */
|
|
229
|
+
openMessage() {
|
|
230
|
+
const { slots, openMessage: host } = this.options;
|
|
231
|
+
return {
|
|
232
|
+
type: 'open',
|
|
233
|
+
...(this.history.length ? { history: structuredClone(this.history) } : {}),
|
|
234
|
+
...(slots ? { slots } : {}),
|
|
235
|
+
...(this.context === undefined ? {} : { context: this.context }),
|
|
236
|
+
...(this.providerState
|
|
237
|
+
? {
|
|
238
|
+
resume: {
|
|
239
|
+
providerState: this.providerState,
|
|
240
|
+
awayMs: this.droppedAt === undefined ? 0 : Date.now() - this.droppedAt,
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
: {}),
|
|
244
|
+
...(host ? { host } : {}),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/** The socket closed while the call was open: take it up again if the provider gave a handle, else end. */
|
|
248
|
+
handleDrop() {
|
|
249
|
+
if (!this.providerState || this.sessionOver) {
|
|
250
|
+
this.teardownConnection();
|
|
251
|
+
this.setStatus('disconnected');
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.droppedAt ??= Date.now();
|
|
255
|
+
this.teardownConnection();
|
|
256
|
+
this.setStatus('reconnecting');
|
|
257
|
+
this.scheduleReconnect();
|
|
258
|
+
}
|
|
259
|
+
scheduleReconnect() {
|
|
260
|
+
const delay = LIVE_RECONNECT_DELAYS_MS[this.reconnectTries];
|
|
261
|
+
if (delay === undefined) {
|
|
262
|
+
const droppedAt = this.droppedAt;
|
|
263
|
+
// labels-exempt: internal diagnostic; the user reads error.network
|
|
264
|
+
this.failSession(new TheoremError('network', 'live call dropped and could not reconnect'));
|
|
265
|
+
this.lostAt = droppedAt;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
this.reconnectTries += 1;
|
|
269
|
+
this.reconnectTimer = setTimeout(() => {
|
|
270
|
+
this.reconnectTimer = null;
|
|
271
|
+
void this.open('reconnecting');
|
|
272
|
+
}, delay);
|
|
273
|
+
}
|
|
274
|
+
/** A try at opening failed: a reconnect waits and tries again, a first connect fails the call. */
|
|
275
|
+
failOpen(error) {
|
|
276
|
+
if (this.status !== 'reconnecting') {
|
|
277
|
+
if (!this.startOver())
|
|
278
|
+
this.failSession(error);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
this.teardownConnection();
|
|
282
|
+
this.scheduleReconnect();
|
|
283
|
+
}
|
|
284
|
+
async open(as) {
|
|
285
|
+
this.teardownConnection();
|
|
286
|
+
this.sessionOver = false;
|
|
287
|
+
this.setStatus(as);
|
|
288
|
+
if (as === 'connecting')
|
|
289
|
+
this.setConnectPhase('socket');
|
|
290
|
+
try {
|
|
291
|
+
const AudioContextClass = globalThis.AudioContext;
|
|
292
|
+
this.audioContext = new AudioContextClass();
|
|
293
|
+
if (this.audioContext.state === 'suspended') {
|
|
294
|
+
await this.audioContext.resume();
|
|
295
|
+
}
|
|
296
|
+
const protocol = globalThis.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
297
|
+
const profileParam = this.options.profile
|
|
298
|
+
? `?profile=${encodeURIComponent(this.options.profile)}`
|
|
299
|
+
: '';
|
|
300
|
+
const defaultUrl = `${protocol}//${globalThis.location.host}/api/live/relay${profileParam}`;
|
|
301
|
+
const url = this.options.relayUrl || defaultUrl;
|
|
302
|
+
this.ws = this.createSocket(url);
|
|
303
|
+
this.ws.binaryType = 'arraybuffer';
|
|
304
|
+
this.ws.onopen = () => {
|
|
305
|
+
const open = this.openMessage();
|
|
306
|
+
this.sentContext = JSON.stringify(open.context);
|
|
307
|
+
this.ws?.send(JSON.stringify(open));
|
|
308
|
+
};
|
|
309
|
+
this.connectTimeout = setTimeout(() => {
|
|
310
|
+
if (this.opening) {
|
|
311
|
+
// labels-exempt: internal diagnostic; the user reads error.timeout
|
|
312
|
+
this.failOpen(new TheoremError('timeout', 'live connect timed out'));
|
|
313
|
+
}
|
|
314
|
+
}, 20_000);
|
|
315
|
+
this.ws.onmessage = (event) => {
|
|
316
|
+
this.enqueueServerMessage(event.data);
|
|
317
|
+
};
|
|
318
|
+
this.ws.onclose = () => {
|
|
319
|
+
if (this.opening) {
|
|
320
|
+
// labels-exempt: internal diagnostic; the user reads error.network
|
|
321
|
+
this.failOpen(new TheoremError('network', 'live socket closed before ready'));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
this.handleDrop();
|
|
325
|
+
};
|
|
326
|
+
this.ws.onerror = () => {
|
|
327
|
+
if (!this.opening && this.providerState && !this.sessionOver) {
|
|
328
|
+
this.handleDrop();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// labels-exempt: internal diagnostic; the user reads error.network
|
|
332
|
+
this.failOpen(new TheoremError('network', 'live socket error'));
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
this.failOpen(new TheoremError('internal', describeError(err), { cause: err }));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async ensureAudioContext() {
|
|
340
|
+
if (!this.audioContext || this.audioContext.state === 'closed') {
|
|
341
|
+
const AudioContextClass = globalThis.AudioContext;
|
|
342
|
+
this.audioContext = new AudioContextClass();
|
|
343
|
+
if (this.audioContext.state === 'suspended') {
|
|
344
|
+
await this.audioContext.resume();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async activateMicrophone() {
|
|
349
|
+
if (this.micActivating || !this.opening)
|
|
350
|
+
return;
|
|
351
|
+
this.micActivating = true;
|
|
352
|
+
if (this.status === 'connecting')
|
|
353
|
+
this.setConnectPhase('microphone');
|
|
354
|
+
try {
|
|
355
|
+
await this.ensureAudioContext();
|
|
356
|
+
this.micStream = await navigator.mediaDevices.getUserMedia({
|
|
357
|
+
audio: {
|
|
358
|
+
channelCount: 1,
|
|
359
|
+
echoCancellation: true,
|
|
360
|
+
noiseSuppression: true,
|
|
361
|
+
autoGainControl: true,
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
await this.setupMicrophonePipeline();
|
|
365
|
+
this.becomeReady();
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
const denied = isPermissionDeniedError(err);
|
|
369
|
+
this.failSession(new TheoremError(denied ? 'auth' : 'internal', describeError(err), {
|
|
370
|
+
cause: err,
|
|
371
|
+
copy: { key: denied ? 'voice.permission' : 'voice.unavailable' },
|
|
372
|
+
}));
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
this.micActivating = false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
sendMicFrame(inputFloat32, sampleRate) {
|
|
379
|
+
const pcm16 = downsampleAndConvertToInt16(inputFloat32, sampleRate, 16000);
|
|
380
|
+
const base64 = bytesToBase64(new Uint8Array(pcm16.buffer));
|
|
381
|
+
const socket = this.ws;
|
|
382
|
+
if (socket?.readyState === WebSocket.OPEN) {
|
|
383
|
+
socket.send(clientMessage({ type: 'audio', data: base64 }));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
reportMicVolume(inputFloat32) {
|
|
387
|
+
if (this.isMuted)
|
|
388
|
+
return;
|
|
389
|
+
this.options.onVolumeLevel?.(float32RmsToLevel(inputFloat32), true);
|
|
390
|
+
}
|
|
391
|
+
forwardMicBuffer(inputFloat32) {
|
|
392
|
+
this.reportMicVolume(inputFloat32);
|
|
393
|
+
const forward = checkMicFrameForward({
|
|
394
|
+
isMuted: this.isMuted,
|
|
395
|
+
wsOpen: this.ws?.readyState === WebSocket.OPEN,
|
|
396
|
+
hasPlaybackNodes: this.playbackNodes.length > 0,
|
|
397
|
+
status: this.status,
|
|
398
|
+
inputFloat32,
|
|
399
|
+
});
|
|
400
|
+
if (forward) {
|
|
401
|
+
const sampleRate = this.audioContext ? this.audioContext.sampleRate : 48000;
|
|
402
|
+
this.sendMicFrame(inputFloat32, sampleRate);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
async setupMicrophonePipeline() {
|
|
406
|
+
if (!this.micStream || !this.audioContext)
|
|
407
|
+
return;
|
|
408
|
+
this.micSource = this.audioContext.createMediaStreamSource(this.micStream);
|
|
409
|
+
const silent = this.audioContext.createGain();
|
|
410
|
+
silent.gain.value = 0;
|
|
411
|
+
if (!this.micWorkletModuleLoaded) {
|
|
412
|
+
await this.audioContext.audioWorklet.addModule(micCaptureWorkletUrl());
|
|
413
|
+
this.micWorkletModuleLoaded = true;
|
|
414
|
+
}
|
|
415
|
+
this.micWorklet = new AudioWorkletNode(this.audioContext, MIC_CAPTURE_PROCESSOR);
|
|
416
|
+
this.micWorklet.port.onmessage = (event) => {
|
|
417
|
+
const inputFloat32 = new Float32Array(event.data);
|
|
418
|
+
this.forwardMicBuffer(inputFloat32);
|
|
419
|
+
};
|
|
420
|
+
this.micSource.connect(this.micWorklet);
|
|
421
|
+
this.micWorklet.connect(silent);
|
|
422
|
+
silent.connect(this.audioContext.destination);
|
|
423
|
+
}
|
|
424
|
+
ensurePlaybackBus() {
|
|
425
|
+
if (!this.audioContext || this.playbackBus)
|
|
426
|
+
return;
|
|
427
|
+
this.playbackBus = this.audioContext.createGain();
|
|
428
|
+
this.playbackAnalyser = this.audioContext.createAnalyser();
|
|
429
|
+
this.playbackAnalyser.fftSize = 512;
|
|
430
|
+
this.playbackAnalyser.smoothingTimeConstant = 0.35;
|
|
431
|
+
this.playbackBus.connect(this.playbackAnalyser);
|
|
432
|
+
this.playbackAnalyser.connect(this.audioContext.destination);
|
|
433
|
+
this.playbackMeterBuffer = new Uint8Array(this.playbackAnalyser.fftSize);
|
|
434
|
+
}
|
|
435
|
+
stopPlaybackMeter() {
|
|
436
|
+
if (this.playbackMeterFrame) {
|
|
437
|
+
cancelAnimationFrame(this.playbackMeterFrame);
|
|
438
|
+
this.playbackMeterFrame = 0;
|
|
439
|
+
}
|
|
440
|
+
this.options.onVolumeLevel?.(0, false);
|
|
441
|
+
}
|
|
442
|
+
startPlaybackMeter() {
|
|
443
|
+
if (this.playbackMeterFrame)
|
|
444
|
+
return;
|
|
445
|
+
const tick = () => {
|
|
446
|
+
if (this.playbackNodes.length === 0 || !this.playbackAnalyser || !this.playbackMeterBuffer) {
|
|
447
|
+
this.stopPlaybackMeter();
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
this.playbackAnalyser.getByteTimeDomainData(this.playbackMeterBuffer);
|
|
451
|
+
this.options.onVolumeLevel?.(timeDomainBytesToLevel(this.playbackMeterBuffer), false);
|
|
452
|
+
this.playbackMeterFrame = requestAnimationFrame(tick);
|
|
453
|
+
};
|
|
454
|
+
this.playbackMeterFrame = requestAnimationFrame(tick);
|
|
455
|
+
}
|
|
456
|
+
async processServerEnvelope(payload) {
|
|
457
|
+
if (await this.tryHandleControlEnvelope(payload))
|
|
458
|
+
return;
|
|
459
|
+
if (payload.type !== 'events')
|
|
460
|
+
return;
|
|
461
|
+
const accum = this.collectInboundTurn(payload.events);
|
|
462
|
+
const runnableTools = accum.toolCalls.filter((call) => !accum.cancelledToolIds.has(call.id));
|
|
463
|
+
for (const call of runnableTools) {
|
|
464
|
+
this.toolChain = this.toolChain
|
|
465
|
+
.then(() => this.options.onToolCall(call.name, call.arguments, {
|
|
466
|
+
callId: call.id,
|
|
467
|
+
operationId: issuedOperationId(call),
|
|
468
|
+
}))
|
|
469
|
+
.catch((err) => {
|
|
470
|
+
this.options.onError?.(asError(err));
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
this.scheduleMediaChunks(accum.mediaChunks);
|
|
474
|
+
}
|
|
475
|
+
enqueueServerMessage(data) {
|
|
476
|
+
this.inboundChain = this.inboundChain
|
|
477
|
+
.then(async () => {
|
|
478
|
+
if (typeof data !== 'string')
|
|
479
|
+
return;
|
|
480
|
+
const payload = parseLiveServerEnvelope(data);
|
|
481
|
+
// why: An envelope the client can't use reaches onTurnEvent like any event; the call goes on.
|
|
482
|
+
if (payload.type === 'unsupported' || payload.type === 'malformed') {
|
|
483
|
+
this.options.onTurnEvent?.(payload);
|
|
484
|
+
}
|
|
485
|
+
else
|
|
486
|
+
await this.processServerEnvelope(payload);
|
|
487
|
+
})
|
|
488
|
+
.catch((err) => {
|
|
489
|
+
this.options.onError?.(asError(err));
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
async handleReadyEnvelope(payload) {
|
|
493
|
+
this.options.onSessionReady?.({
|
|
494
|
+
sessionId: payload.sessionId,
|
|
495
|
+
profile: payload.profile,
|
|
496
|
+
});
|
|
497
|
+
if (this.options.voiceIngress === false) {
|
|
498
|
+
this.becomeReady();
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
await this.activateMicrophone();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/** The call is open, first or again: the page's context catches up with what changed meanwhile. */
|
|
505
|
+
becomeReady() {
|
|
506
|
+
this.clearReconnect();
|
|
507
|
+
this.setConnectPhase(null);
|
|
508
|
+
this.setStatus('listening');
|
|
509
|
+
this.flushContext();
|
|
510
|
+
}
|
|
511
|
+
handleExecuteToolResultEnvelope(payload) {
|
|
512
|
+
const pending = this.pendingExecuteResults.get(payload.operationId);
|
|
513
|
+
if (!pending || pending.callId !== payload.callId)
|
|
514
|
+
return;
|
|
515
|
+
this.pendingExecuteResults.delete(payload.operationId);
|
|
516
|
+
if (payload.status === 'refused') {
|
|
517
|
+
pending.reject(hostError(payload.body, 'request'));
|
|
518
|
+
}
|
|
519
|
+
else if (payload.status === 'dispatched') {
|
|
520
|
+
pending.resolve({
|
|
521
|
+
status: 'dispatched',
|
|
522
|
+
grantId: payload.grantId,
|
|
523
|
+
arguments: payload.arguments,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
else if (payload.status === 'gated') {
|
|
527
|
+
pending.resolve({ status: 'gated', gate: payload.gate });
|
|
528
|
+
}
|
|
529
|
+
else
|
|
530
|
+
pending.resolve({ status: 'settled' });
|
|
531
|
+
}
|
|
532
|
+
async tryHandleControlEnvelope(payload) {
|
|
533
|
+
if (payload.type === 'ready') {
|
|
534
|
+
await this.handleReadyEnvelope(payload);
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
if (payload.type === 'executeToolResult') {
|
|
538
|
+
this.handleExecuteToolResultEnvelope(payload);
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
if (payload.type === 'trace') {
|
|
542
|
+
this.options.onTrace?.(payload.record);
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
if (payload.type === 'error') {
|
|
546
|
+
if (this.startOver())
|
|
547
|
+
return true;
|
|
548
|
+
this.sessionOver = true;
|
|
549
|
+
this.options.onError?.(hostError(payload, 'unavailable'));
|
|
550
|
+
this.setStatus('error');
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
collectInboundTurn(events) {
|
|
556
|
+
const accum = emptyInboundTurnAccum();
|
|
557
|
+
for (const event of events) {
|
|
558
|
+
this.processTurnEvent(event, accum);
|
|
559
|
+
}
|
|
560
|
+
return accum;
|
|
561
|
+
}
|
|
562
|
+
processTurnEvent(event, accum) {
|
|
563
|
+
if (event.type === 'provider_checkpoint' && event.history)
|
|
564
|
+
this.history = structuredClone(event.history);
|
|
565
|
+
if ((event.type === 'provider_checkpoint' || event.type === 'done') && event.providerState)
|
|
566
|
+
this.providerState = event.providerState;
|
|
567
|
+
if (event.type === 'provider_warning')
|
|
568
|
+
this.providerState = undefined;
|
|
569
|
+
this.options.onTurnEvent?.(event);
|
|
570
|
+
switch (event.type) {
|
|
571
|
+
case 'evidence':
|
|
572
|
+
this.handleEvidenceTurnEvent(event);
|
|
573
|
+
break;
|
|
574
|
+
case 'session':
|
|
575
|
+
this.handleSessionTurnEvent(event);
|
|
576
|
+
break;
|
|
577
|
+
case 'media':
|
|
578
|
+
this.collectMediaTurnEvent(event, accum);
|
|
579
|
+
break;
|
|
580
|
+
case 'tool':
|
|
581
|
+
applyLiveToolTurnEvent(event.tool, accum);
|
|
582
|
+
break;
|
|
583
|
+
case 'done':
|
|
584
|
+
this.handleDoneTurnEvent(event);
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
handleEvidenceTurnEvent(event) {
|
|
589
|
+
const transcript = liveTranscriptFromEvidence(event);
|
|
590
|
+
if (transcript) {
|
|
591
|
+
this.options.onTranscript?.(transcript.text, transcript.isUser, {
|
|
592
|
+
interim: transcript.interim,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
handleSessionTurnEvent(event) {
|
|
597
|
+
if (this.notifySessionLifecycle(event.session))
|
|
598
|
+
return;
|
|
599
|
+
this.serverWorking = event.session.kind === 'working';
|
|
600
|
+
const nextStatus = resolveWorkingStatusTransition(this.status, this.serverWorking);
|
|
601
|
+
if (nextStatus)
|
|
602
|
+
this.setStatus(nextStatus);
|
|
603
|
+
}
|
|
604
|
+
/** Hand the session's closing and end to the host; `true` when it was one of them. */
|
|
605
|
+
notifySessionLifecycle(session) {
|
|
606
|
+
if (session.kind === 'closing_soon') {
|
|
607
|
+
this.options.onSessionClosing?.(session.timeLeftMs);
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
if (session.kind !== 'ended')
|
|
611
|
+
return false;
|
|
612
|
+
// why: The provider ends a call at its time limit. With a handle the call is taken up
|
|
613
|
+
// again, so the person is not told it ended.
|
|
614
|
+
if (this.providerState) {
|
|
615
|
+
this.handleDrop();
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
this.sessionOver = true;
|
|
619
|
+
this.options.onSessionEnded?.(session);
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
/** Status to settle into once model speech stops. */
|
|
623
|
+
restingStatus() {
|
|
624
|
+
return this.serverWorking ? 'working' : 'listening';
|
|
625
|
+
}
|
|
626
|
+
collectMediaTurnEvent(event, accum) {
|
|
627
|
+
if (!event.media.data)
|
|
628
|
+
return;
|
|
629
|
+
this.setStatus('speaking');
|
|
630
|
+
accum.mediaChunks.push({
|
|
631
|
+
data: event.media.data,
|
|
632
|
+
mimeType: event.media.mimeType,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
handleDoneTurnEvent(event) {
|
|
636
|
+
if (event.interrupted) {
|
|
637
|
+
this.cancelPlayback();
|
|
638
|
+
}
|
|
639
|
+
if (event.stop.kind !== 'generation_complete') {
|
|
640
|
+
this.serverWorking = false;
|
|
641
|
+
this.setStatus('listening');
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
scheduleMediaChunks(mediaChunks) {
|
|
645
|
+
if (mediaChunks.length === 0)
|
|
646
|
+
return;
|
|
647
|
+
const epoch = this.audioEpoch;
|
|
648
|
+
for (const chunk of mediaChunks) {
|
|
649
|
+
this.audioChain = this.audioChain
|
|
650
|
+
.then(async () => {
|
|
651
|
+
if (epoch !== this.audioEpoch)
|
|
652
|
+
return;
|
|
653
|
+
await this.enqueueAudioChunk(chunk.data, chunk.mimeType);
|
|
654
|
+
})
|
|
655
|
+
.catch((err) => {
|
|
656
|
+
this.options.onError?.(asError(err));
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
/** The relay runs `LiveSession.executeTool`; see `ExecuteToolOnRelay`. */
|
|
661
|
+
executeToolOnRelay(args) {
|
|
662
|
+
const socket = this.ws;
|
|
663
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
664
|
+
throw new TheoremError('request', 'live session is not connected'); // labels-exempt: internal diagnostic
|
|
665
|
+
}
|
|
666
|
+
const checked = liveClientMessageSchema.safeParse({ ...args, type: 'executeTool' });
|
|
667
|
+
if (!checked.success || checked.data.type !== 'executeTool') {
|
|
668
|
+
throw new TheoremError('request', labelText('error.request'));
|
|
669
|
+
}
|
|
670
|
+
const request = checked.data;
|
|
671
|
+
if (this.pendingExecuteResults.has(request.operationId)) {
|
|
672
|
+
throw new TheoremError('request', labelText('session.gate_expired'));
|
|
673
|
+
}
|
|
674
|
+
return new Promise((resolve, reject) => {
|
|
675
|
+
this.pendingExecuteResults.set(request.operationId, {
|
|
676
|
+
callId: request.callId,
|
|
677
|
+
resolve,
|
|
678
|
+
reject,
|
|
679
|
+
});
|
|
680
|
+
try {
|
|
681
|
+
socket.send(clientMessage(request));
|
|
682
|
+
}
|
|
683
|
+
catch (error) {
|
|
684
|
+
this.pendingExecuteResults.delete(request.operationId);
|
|
685
|
+
reject(asError(error));
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
async enqueueAudioChunk(base64Data, mimeType) {
|
|
690
|
+
if (!this.audioContext)
|
|
691
|
+
return;
|
|
692
|
+
try {
|
|
693
|
+
this.ensurePlaybackBus();
|
|
694
|
+
if (!this.playbackBus)
|
|
695
|
+
return;
|
|
696
|
+
const bytes = base64ToBytes(base64Data);
|
|
697
|
+
let audioBuffer;
|
|
698
|
+
if (mimeType?.includes('wav')) {
|
|
699
|
+
const wavBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
700
|
+
audioBuffer = await this.audioContext.decodeAudioData(wavBuffer);
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
// why: Raw PCM 24kHz 1-channel 16-bit LE
|
|
704
|
+
const float32 = pcm16BytesToFloat32(bytes);
|
|
705
|
+
audioBuffer = this.audioContext.createBuffer(1, float32.length, 24000);
|
|
706
|
+
audioBuffer.getChannelData(0).set(float32);
|
|
707
|
+
}
|
|
708
|
+
const source = this.audioContext.createBufferSource();
|
|
709
|
+
source.buffer = audioBuffer;
|
|
710
|
+
source.connect(this.playbackBus);
|
|
711
|
+
const now = this.audioContext.currentTime;
|
|
712
|
+
const startTime = Math.max(now, this.nextPlaybackTime);
|
|
713
|
+
source.start(startTime);
|
|
714
|
+
this.nextPlaybackTime = startTime + audioBuffer.duration;
|
|
715
|
+
this.playbackNodes.push(source);
|
|
716
|
+
this.startPlaybackMeter();
|
|
717
|
+
source.onended = () => {
|
|
718
|
+
const idx = this.playbackNodes.indexOf(source);
|
|
719
|
+
if (idx !== -1)
|
|
720
|
+
this.playbackNodes.splice(idx, 1);
|
|
721
|
+
if (this.playbackNodes.length === 0) {
|
|
722
|
+
this.stopPlaybackMeter();
|
|
723
|
+
if (this.status === 'speaking') {
|
|
724
|
+
this.setStatus(this.restingStatus());
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
catch (err) {
|
|
730
|
+
this.options.onError?.(asError(err));
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
cancelPlayback() {
|
|
734
|
+
this.audioEpoch += 1;
|
|
735
|
+
this.audioChain = Promise.resolve();
|
|
736
|
+
for (const node of this.playbackNodes) {
|
|
737
|
+
try {
|
|
738
|
+
node.stop();
|
|
739
|
+
node.disconnect();
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
// why: a node that already stopped throws on stop() and disconnect(); it leaves the list either way.
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
this.playbackNodes = [];
|
|
746
|
+
this.stopPlaybackMeter();
|
|
747
|
+
if (this.audioContext) {
|
|
748
|
+
this.nextPlaybackTime = this.audioContext.currentTime;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
toggleMute() {
|
|
752
|
+
this.isMuted = !this.isMuted;
|
|
753
|
+
return this.isMuted;
|
|
754
|
+
}
|
|
755
|
+
sendText(text) {
|
|
756
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
757
|
+
this.ws.send(clientMessage({ type: 'text', text }));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* The call's slot values. An open call is taken up again with them when the provider gave a
|
|
762
|
+
* handle; with none, the next call has them.
|
|
763
|
+
*/
|
|
764
|
+
setSlots(slots) {
|
|
765
|
+
if (JSON.stringify(slots) === JSON.stringify(this.options.slots))
|
|
766
|
+
return;
|
|
767
|
+
this.options = { ...this.options, slots };
|
|
768
|
+
if (this.providerState && !this.opening && this.ws?.readyState === WebSocket.OPEN) {
|
|
769
|
+
this.handleDrop();
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Replace the page's context package. In a call it reaches the agent as
|
|
774
|
+
* background, with no reply; before one, it goes with the call's opening.
|
|
775
|
+
*/
|
|
776
|
+
setContext(context) {
|
|
777
|
+
this.context = context;
|
|
778
|
+
this.flushContext();
|
|
779
|
+
}
|
|
780
|
+
flushContext() {
|
|
781
|
+
if (this.opening || this.ws?.readyState !== WebSocket.OPEN)
|
|
782
|
+
return;
|
|
783
|
+
const next = JSON.stringify(this.context);
|
|
784
|
+
if (next === this.sentContext)
|
|
785
|
+
return;
|
|
786
|
+
this.sentContext = next;
|
|
787
|
+
this.ws.send(clientMessage({ type: 'context', context: this.context }));
|
|
788
|
+
}
|
|
789
|
+
sendVideo(data, mimeType = 'image/jpeg') {
|
|
790
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
791
|
+
this.ws.send(clientMessage({ type: 'video', data, mimeType }));
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
disconnect() {
|
|
795
|
+
this.clearReconnect();
|
|
796
|
+
this.lostAt = undefined;
|
|
797
|
+
this.providerState = undefined;
|
|
798
|
+
this.teardownConnection();
|
|
799
|
+
this.setStatus('disconnected');
|
|
800
|
+
}
|
|
801
|
+
cleanupAudio() {
|
|
802
|
+
this.cancelPlayback();
|
|
803
|
+
this.stopPlaybackMeter();
|
|
804
|
+
safeDisconnect(this.playbackBus);
|
|
805
|
+
this.playbackBus = null;
|
|
806
|
+
this.playbackAnalyser = null;
|
|
807
|
+
this.playbackMeterBuffer = null;
|
|
808
|
+
if (this.micWorklet) {
|
|
809
|
+
this.micWorklet.port.onmessage = null;
|
|
810
|
+
safeDisconnect(this.micWorklet);
|
|
811
|
+
this.micWorklet = null;
|
|
812
|
+
}
|
|
813
|
+
safeDisconnect(this.micSource);
|
|
814
|
+
this.micSource = null;
|
|
815
|
+
stopMediaTracks(this.micStream);
|
|
816
|
+
this.micStream = null;
|
|
817
|
+
if (this.audioContext && this.audioContext.state !== 'closed') {
|
|
818
|
+
try {
|
|
819
|
+
void this.audioContext.close();
|
|
820
|
+
}
|
|
821
|
+
catch {
|
|
822
|
+
// why: close() throws on a context that already closed.
|
|
823
|
+
}
|
|
824
|
+
this.audioContext = null;
|
|
825
|
+
// why: A worklet module belongs to its context; the next context loads it again.
|
|
826
|
+
this.micWorkletModuleLoaded = false;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|