@agent-native/core 0.94.1 → 0.94.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 +6 -0
- package/corpus/core/docs/content/cloneable-saas.mdx +15 -15
- package/corpus/core/docs/content/creating-templates.mdx +1 -1
- package/corpus/core/docs/content/extensions.mdx +1 -1
- package/corpus/core/docs/content/local-file-mode.mdx +6 -6
- package/corpus/core/docs/content/locales/ar-SA/voice-input.mdx +2 -2
- package/corpus/core/docs/content/locales/de-DE/voice-input.mdx +2 -2
- package/corpus/core/docs/content/locales/es-ES/voice-input.mdx +2 -2
- package/corpus/core/docs/content/locales/fr-FR/voice-input.mdx +2 -2
- package/corpus/core/docs/content/locales/hi-IN/voice-input.mdx +2 -2
- package/corpus/core/docs/content/locales/ja-JP/voice-input.mdx +2 -2
- package/corpus/core/docs/content/locales/ko-KR/voice-input.mdx +2 -2
- package/corpus/core/docs/content/locales/pt-BR/voice-input.mdx +2 -2
- package/corpus/core/docs/content/locales/zh-CN/voice-input.mdx +2 -2
- package/corpus/core/docs/content/locales/zh-TW/voice-input.mdx +2 -2
- package/corpus/core/docs/content/sharing.mdx +4 -4
- package/corpus/core/docs/content/template-analytics.mdx +2 -2
- package/corpus/core/docs/content/template-calendar.mdx +4 -4
- package/corpus/core/docs/content/template-clips.mdx +2 -2
- package/corpus/core/docs/content/template-content.mdx +12 -11
- package/corpus/core/docs/content/template-mail.mdx +1 -1
- package/corpus/core/docs/content/template-slides.mdx +2 -2
- package/corpus/core/docs/content/voice-input.mdx +2 -2
- package/corpus/core/docs/content/workspace.mdx +1 -1
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/AssistantChat.tsx +30 -0
- package/corpus/core/src/client/MultiTabAssistantChat.tsx +19 -1
- package/corpus/core/src/client/agent-chat.ts +6 -0
- package/corpus/core/src/client/composer/RealtimeVoiceMode.tsx +72 -2
- package/corpus/core/src/client/composer/realtime-voice-audio-level.ts +64 -0
- package/corpus/core/src/client/composer/realtime-voice-transcript.ts +134 -0
- package/corpus/core/src/client/composer/useRealtimeVoiceMode.tsx +220 -6
- package/corpus/core/src/client/use-chat-threads.ts +33 -9
- package/corpus/core/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +11 -5
- package/corpus/templates/clips/app/components/player/comments-panel.tsx +98 -1
- package/corpus/templates/clips/changelog/2026-07-10-comment-urls-open-as-clickable-links.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-10-desktop-recording-uploads-now-show-a-brief-uploaded-state-wi.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-10-desktop-recordings-now-retry-saving-their-locally-captured-t.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-10-desktop-update-prompts-are-more-compact-and-easier-to-dismiss.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-10-quiet-recordings-now-finish-saving-instead-of-getting-stuck.md +6 -0
- package/corpus/templates/clips/desktop/src/components/UpdateBanner.tsx +7 -4
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +284 -85
- package/corpus/templates/clips/desktop/src/overlays/finalizing.tsx +148 -40
- package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +13 -13
- package/corpus/templates/clips/desktop/src/styles.css +83 -13
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +43 -28
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +130 -65
- package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +10 -4
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +82 -1
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +82 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +20 -0
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
- package/dist/client/MultiTabAssistantChat.js +9 -0
- package/dist/client/MultiTabAssistantChat.js.map +1 -1
- package/dist/client/agent-chat.d.ts +6 -0
- package/dist/client/agent-chat.d.ts.map +1 -1
- package/dist/client/agent-chat.js.map +1 -1
- package/dist/client/composer/RealtimeVoiceMode.d.ts +3 -1
- package/dist/client/composer/RealtimeVoiceMode.d.ts.map +1 -1
- package/dist/client/composer/RealtimeVoiceMode.js +32 -3
- package/dist/client/composer/RealtimeVoiceMode.js.map +1 -1
- package/dist/client/composer/realtime-voice-audio-level.d.ts +14 -0
- package/dist/client/composer/realtime-voice-audio-level.d.ts.map +1 -0
- package/dist/client/composer/realtime-voice-audio-level.js +47 -0
- package/dist/client/composer/realtime-voice-audio-level.js.map +1 -0
- package/dist/client/composer/realtime-voice-transcript.d.ts +26 -0
- package/dist/client/composer/realtime-voice-transcript.d.ts.map +1 -0
- package/dist/client/composer/realtime-voice-transcript.js +103 -0
- package/dist/client/composer/realtime-voice-transcript.js.map +1 -0
- package/dist/client/composer/useRealtimeVoiceMode.d.ts +14 -0
- package/dist/client/composer/useRealtimeVoiceMode.d.ts.map +1 -1
- package/dist/client/composer/useRealtimeVoiceMode.js +177 -6
- package/dist/client/composer/useRealtimeVoiceMode.js.map +1 -1
- package/dist/client/use-chat-threads.d.ts.map +1 -1
- package/dist/client/use-chat-threads.js +26 -6
- package/dist/client/use-chat-threads.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +11 -5
- package/docs/content/cloneable-saas.mdx +15 -15
- package/docs/content/creating-templates.mdx +1 -1
- package/docs/content/extensions.mdx +1 -1
- package/docs/content/local-file-mode.mdx +6 -6
- package/docs/content/locales/ar-SA/voice-input.mdx +2 -2
- package/docs/content/locales/de-DE/voice-input.mdx +2 -2
- package/docs/content/locales/es-ES/voice-input.mdx +2 -2
- package/docs/content/locales/fr-FR/voice-input.mdx +2 -2
- package/docs/content/locales/hi-IN/voice-input.mdx +2 -2
- package/docs/content/locales/ja-JP/voice-input.mdx +2 -2
- package/docs/content/locales/ko-KR/voice-input.mdx +2 -2
- package/docs/content/locales/pt-BR/voice-input.mdx +2 -2
- package/docs/content/locales/zh-CN/voice-input.mdx +2 -2
- package/docs/content/locales/zh-TW/voice-input.mdx +2 -2
- package/docs/content/sharing.mdx +4 -4
- package/docs/content/template-analytics.mdx +2 -2
- package/docs/content/template-calendar.mdx +4 -4
- package/docs/content/template-clips.mdx +2 -2
- package/docs/content/template-content.mdx +12 -11
- package/docs/content/template-mail.mdx +1 -1
- package/docs/content/template-slides.mdx +2 -2
- package/docs/content/voice-input.mdx +2 -2
- package/docs/content/workspace.mdx +1 -1
- package/package.json +1 -1
- package/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +11 -5
|
@@ -138,6 +138,12 @@ export interface AgentChatContextState {
|
|
|
138
138
|
export interface AgentChatOpenThreadRequest {
|
|
139
139
|
threadId: string;
|
|
140
140
|
newThread?: boolean;
|
|
141
|
+
/**
|
|
142
|
+
* Open only while this thread is still active (or no thread is active).
|
|
143
|
+
* This lets transient surfaces restore their own chat without stealing a
|
|
144
|
+
* thread the user selected in the meantime.
|
|
145
|
+
*/
|
|
146
|
+
onlyIfActiveThreadId?: string;
|
|
141
147
|
openRequestId?: string;
|
|
142
148
|
}
|
|
143
149
|
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
IconPhoneOff,
|
|
7
7
|
IconVolume,
|
|
8
8
|
} from "@tabler/icons-react";
|
|
9
|
-
import { useId, useState } from "react";
|
|
9
|
+
import { useEffect, useId, useState, useSyncExternalStore } from "react";
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
12
|
Popover,
|
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
TooltipTrigger,
|
|
20
20
|
} from "../components/ui/tooltip.js";
|
|
21
21
|
import { cn } from "../utils.js";
|
|
22
|
+
import {
|
|
23
|
+
createRealtimeVoiceAudioLevelStore,
|
|
24
|
+
type RealtimeVoiceAudioLevelStore,
|
|
25
|
+
} from "./realtime-voice-audio-level.js";
|
|
22
26
|
|
|
23
27
|
export type RealtimeVoiceModeState =
|
|
24
28
|
| "connecting"
|
|
@@ -164,12 +168,55 @@ export interface RealtimeVoiceModeDockProps {
|
|
|
164
168
|
state: RealtimeVoiceModeState;
|
|
165
169
|
copy: RealtimeVoiceModeCopy;
|
|
166
170
|
chatVisible: boolean;
|
|
171
|
+
audioLevels?: RealtimeVoiceAudioLevelStore;
|
|
167
172
|
onToggleChat: () => void;
|
|
168
173
|
onEndVoiceMode: () => void;
|
|
169
174
|
errorMessage?: string | null;
|
|
170
175
|
className?: string;
|
|
171
176
|
}
|
|
172
177
|
|
|
178
|
+
const SILENT_AUDIO_LEVELS = createRealtimeVoiceAudioLevelStore();
|
|
179
|
+
const WAVEFORM_WEIGHTS = [0.55, 0.82, 1, 0.82, 0.55];
|
|
180
|
+
const AUDIO_ACTIVITY_THRESHOLD = 0.035;
|
|
181
|
+
|
|
182
|
+
function usePrefersReducedMotion(): boolean {
|
|
183
|
+
const [reduced, setReduced] = useState(false);
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
186
|
+
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
187
|
+
const update = () => setReduced(query.matches);
|
|
188
|
+
update();
|
|
189
|
+
query.addEventListener?.("change", update);
|
|
190
|
+
return () => query.removeEventListener?.("change", update);
|
|
191
|
+
}, []);
|
|
192
|
+
return reduced;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function VoiceWaveform({
|
|
196
|
+
level,
|
|
197
|
+
reducedMotion,
|
|
198
|
+
}: {
|
|
199
|
+
level: number;
|
|
200
|
+
reducedMotion: boolean;
|
|
201
|
+
}) {
|
|
202
|
+
const visibleLevel = reducedMotion ? 0.45 : level;
|
|
203
|
+
return (
|
|
204
|
+
<span
|
|
205
|
+
aria-hidden="true"
|
|
206
|
+
className="flex h-6 items-center justify-center gap-0.5"
|
|
207
|
+
data-realtime-voice-waveform="true"
|
|
208
|
+
>
|
|
209
|
+
{WAVEFORM_WEIGHTS.map((weight, index) => (
|
|
210
|
+
<span
|
|
211
|
+
key={index}
|
|
212
|
+
className="w-0.5 rounded-full bg-current transition-[height] duration-75 ease-out motion-reduce:transition-none"
|
|
213
|
+
style={{ height: `${4 + visibleLevel * 16 * weight}px` }}
|
|
214
|
+
/>
|
|
215
|
+
))}
|
|
216
|
+
</span>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
173
220
|
const ORB_STATE_CLASSES: Record<RealtimeVoiceModeState, string> = {
|
|
174
221
|
connecting:
|
|
175
222
|
"bg-secondary text-secondary-foreground ring-4 ring-secondary/40 hover:bg-secondary/80",
|
|
@@ -212,12 +259,26 @@ export function RealtimeVoiceModeDock({
|
|
|
212
259
|
state,
|
|
213
260
|
copy,
|
|
214
261
|
chatVisible,
|
|
262
|
+
audioLevels = SILENT_AUDIO_LEVELS,
|
|
215
263
|
onToggleChat,
|
|
216
264
|
onEndVoiceMode,
|
|
217
265
|
errorMessage,
|
|
218
266
|
className,
|
|
219
267
|
}: RealtimeVoiceModeDockProps) {
|
|
220
268
|
const statusId = useId();
|
|
269
|
+
const levels = useSyncExternalStore(
|
|
270
|
+
audioLevels.subscribe,
|
|
271
|
+
audioLevels.getSnapshot,
|
|
272
|
+
audioLevels.getSnapshot,
|
|
273
|
+
);
|
|
274
|
+
const reducedMotion = usePrefersReducedMotion();
|
|
275
|
+
const activity =
|
|
276
|
+
levels.output > AUDIO_ACTIVITY_THRESHOLD
|
|
277
|
+
? "assistant"
|
|
278
|
+
: levels.input > AUDIO_ACTIVITY_THRESHOLD
|
|
279
|
+
? "user"
|
|
280
|
+
: "idle";
|
|
281
|
+
const activityLevel = activity === "assistant" ? levels.output : levels.input;
|
|
221
282
|
const toggleLabel = chatVisible ? copy.hideChat : copy.showChat;
|
|
222
283
|
const ending = state === "ending";
|
|
223
284
|
const errorDetailVisible = state === "error" && Boolean(errorMessage);
|
|
@@ -228,7 +289,9 @@ export function RealtimeVoiceModeDock({
|
|
|
228
289
|
"pointer-events-none fixed bottom-4 end-4 flex max-w-[calc(100vw-2rem)] flex-col items-end gap-2",
|
|
229
290
|
className,
|
|
230
291
|
)}
|
|
292
|
+
style={{ zIndex: 270 }}
|
|
231
293
|
data-realtime-voice-state={state}
|
|
294
|
+
data-realtime-voice-activity={activity}
|
|
232
295
|
>
|
|
233
296
|
{errorDetailVisible ? (
|
|
234
297
|
<div
|
|
@@ -283,7 +346,14 @@ export function RealtimeVoiceModeDock({
|
|
|
283
346
|
ORB_STATE_CLASSES[state],
|
|
284
347
|
)}
|
|
285
348
|
>
|
|
286
|
-
|
|
349
|
+
{activity === "idle" ? (
|
|
350
|
+
<VoiceStateIcon state={state} />
|
|
351
|
+
) : (
|
|
352
|
+
<VoiceWaveform
|
|
353
|
+
level={activityLevel}
|
|
354
|
+
reducedMotion={reducedMotion}
|
|
355
|
+
/>
|
|
356
|
+
)}
|
|
287
357
|
</Button>
|
|
288
358
|
</TooltipTrigger>
|
|
289
359
|
<TooltipContent>{toggleLabel}</TooltipContent>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export interface RealtimeVoiceAudioLevels {
|
|
2
|
+
input: number;
|
|
3
|
+
output: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface RealtimeVoiceAudioLevelStore {
|
|
7
|
+
getSnapshot: () => RealtimeVoiceAudioLevels;
|
|
8
|
+
subscribe: (listener: () => void) => () => void;
|
|
9
|
+
set: (levels: RealtimeVoiceAudioLevels) => void;
|
|
10
|
+
reset: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SILENT_LEVELS: RealtimeVoiceAudioLevels = { input: 0, output: 0 };
|
|
14
|
+
|
|
15
|
+
function clampLevel(value: number): number {
|
|
16
|
+
if (!Number.isFinite(value)) return 0;
|
|
17
|
+
return Math.max(0, Math.min(1, value));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeRealtimeVoiceRms(samples: Uint8Array): number {
|
|
21
|
+
if (samples.length === 0) return 0;
|
|
22
|
+
let sumSquares = 0;
|
|
23
|
+
for (const sample of samples) {
|
|
24
|
+
const normalized = (sample - 128) / 128;
|
|
25
|
+
sumSquares += normalized * normalized;
|
|
26
|
+
}
|
|
27
|
+
const rms = Math.sqrt(sumSquares / samples.length);
|
|
28
|
+
return clampLevel(Math.max(0, rms - 0.012) * 3.2);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function smoothRealtimeVoiceLevel(
|
|
32
|
+
previous: number,
|
|
33
|
+
next: number,
|
|
34
|
+
): number {
|
|
35
|
+
const target = clampLevel(next);
|
|
36
|
+
const factor = target > previous ? 0.55 : 0.2;
|
|
37
|
+
return clampLevel(previous + (target - previous) * factor);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createRealtimeVoiceAudioLevelStore(): RealtimeVoiceAudioLevelStore {
|
|
41
|
+
let snapshot = SILENT_LEVELS;
|
|
42
|
+
const listeners = new Set<() => void>();
|
|
43
|
+
|
|
44
|
+
const set = (levels: RealtimeVoiceAudioLevels) => {
|
|
45
|
+
const next = {
|
|
46
|
+
input: clampLevel(levels.input),
|
|
47
|
+
output: clampLevel(levels.output),
|
|
48
|
+
};
|
|
49
|
+
if (next.input === snapshot.input && next.output === snapshot.output)
|
|
50
|
+
return;
|
|
51
|
+
snapshot = next;
|
|
52
|
+
for (const listener of listeners) listener();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
getSnapshot: () => snapshot,
|
|
57
|
+
subscribe(listener) {
|
|
58
|
+
listeners.add(listener);
|
|
59
|
+
return () => listeners.delete(listener);
|
|
60
|
+
},
|
|
61
|
+
set,
|
|
62
|
+
reset: () => set(SILENT_LEVELS),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
export type RealtimeVoiceTranscriptRole = "user" | "assistant";
|
|
2
|
+
|
|
3
|
+
export interface RealtimeVoiceTranscriptMessage {
|
|
4
|
+
id: string;
|
|
5
|
+
threadId: string;
|
|
6
|
+
role: RealtimeVoiceTranscriptRole;
|
|
7
|
+
text: string;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RealtimeVoiceTranscriptSink {
|
|
12
|
+
threadId: string;
|
|
13
|
+
active: boolean;
|
|
14
|
+
append: (message: RealtimeVoiceTranscriptMessage) => boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RealtimeVoiceTranscriptRegistry {
|
|
18
|
+
activeThreadId: () => string | undefined;
|
|
19
|
+
publish: (message: RealtimeVoiceTranscriptMessage) => void;
|
|
20
|
+
register: (sink: RealtimeVoiceTranscriptSink) => () => void;
|
|
21
|
+
pendingCount: () => number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function appendRealtimeVoiceTranscriptToRepository(
|
|
25
|
+
value: unknown,
|
|
26
|
+
transcript: RealtimeVoiceTranscriptMessage,
|
|
27
|
+
): { appended: boolean; repository: Record<string, unknown> } {
|
|
28
|
+
const repository =
|
|
29
|
+
value && typeof value === "object" && !Array.isArray(value)
|
|
30
|
+
? (value as Record<string, unknown>)
|
|
31
|
+
: {};
|
|
32
|
+
const messages = Array.isArray(repository.messages)
|
|
33
|
+
? [...repository.messages]
|
|
34
|
+
: [];
|
|
35
|
+
const duplicate = messages.some((entry) => {
|
|
36
|
+
if (!entry || typeof entry !== "object") return false;
|
|
37
|
+
const record = entry as Record<string, unknown>;
|
|
38
|
+
const message =
|
|
39
|
+
record.message && typeof record.message === "object"
|
|
40
|
+
? (record.message as Record<string, unknown>)
|
|
41
|
+
: record;
|
|
42
|
+
return message.id === transcript.id;
|
|
43
|
+
});
|
|
44
|
+
if (duplicate) return { appended: false, repository };
|
|
45
|
+
|
|
46
|
+
const parentId =
|
|
47
|
+
typeof repository.headId === "string" ? repository.headId : null;
|
|
48
|
+
const createdAt = new Date(transcript.createdAt);
|
|
49
|
+
const message = {
|
|
50
|
+
id: transcript.id,
|
|
51
|
+
role: transcript.role,
|
|
52
|
+
content: [{ type: "text", text: transcript.text }],
|
|
53
|
+
createdAt: Number.isNaN(createdAt.getTime()) ? new Date() : createdAt,
|
|
54
|
+
metadata: {
|
|
55
|
+
custom: {
|
|
56
|
+
source: "realtime-voice",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
...(transcript.role === "assistant"
|
|
60
|
+
? { status: { type: "complete", reason: "stop" } }
|
|
61
|
+
: {}),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
appended: true,
|
|
66
|
+
repository: {
|
|
67
|
+
...repository,
|
|
68
|
+
messages: [...messages, { message, parentId }],
|
|
69
|
+
headId: transcript.id,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createRealtimeVoiceTranscriptRegistry(): RealtimeVoiceTranscriptRegistry {
|
|
75
|
+
const sinks = new Map<
|
|
76
|
+
symbol,
|
|
77
|
+
RealtimeVoiceTranscriptSink & { order: number }
|
|
78
|
+
>();
|
|
79
|
+
const pending = new Map<string, RealtimeVoiceTranscriptMessage>();
|
|
80
|
+
const delivered = new Set<string>();
|
|
81
|
+
let order = 0;
|
|
82
|
+
|
|
83
|
+
const flush = () => {
|
|
84
|
+
for (const [id, message] of pending) {
|
|
85
|
+
let consumed = false;
|
|
86
|
+
for (const sink of sinks.values()) {
|
|
87
|
+
if (sink.threadId !== message.threadId) continue;
|
|
88
|
+
try {
|
|
89
|
+
consumed = sink.append(message) || consumed;
|
|
90
|
+
} catch {
|
|
91
|
+
// Keep the completed transcript queued until the chat can import it.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!consumed) continue;
|
|
95
|
+
pending.delete(id);
|
|
96
|
+
delivered.add(id);
|
|
97
|
+
if (delivered.size > 1_000) {
|
|
98
|
+
const oldest = delivered.values().next().value;
|
|
99
|
+
if (typeof oldest === "string") delivered.delete(oldest);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
activeThreadId() {
|
|
106
|
+
let active: (RealtimeVoiceTranscriptSink & { order: number }) | undefined;
|
|
107
|
+
for (const sink of sinks.values()) {
|
|
108
|
+
if (!sink.active || (active && sink.order <= active.order)) continue;
|
|
109
|
+
active = sink;
|
|
110
|
+
}
|
|
111
|
+
return active?.threadId;
|
|
112
|
+
},
|
|
113
|
+
publish(message) {
|
|
114
|
+
const id = message.id.trim();
|
|
115
|
+
const threadId = message.threadId.trim();
|
|
116
|
+
const text = message.text.trim();
|
|
117
|
+
if (!id || !threadId || !text || delivered.has(id) || pending.has(id)) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
pending.set(id, { ...message, id, threadId, text });
|
|
121
|
+
flush();
|
|
122
|
+
},
|
|
123
|
+
register(sink) {
|
|
124
|
+
const token = Symbol("realtime-voice-transcript-sink");
|
|
125
|
+
sinks.set(token, { ...sink, order: ++order });
|
|
126
|
+
flush();
|
|
127
|
+
return () => sinks.delete(token);
|
|
128
|
+
},
|
|
129
|
+
pendingCount: () => pending.size,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const realtimeVoiceTranscriptRegistry =
|
|
134
|
+
createRealtimeVoiceTranscriptRegistry();
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "react";
|
|
11
11
|
import { createPortal } from "react-dom";
|
|
12
12
|
|
|
13
|
+
import { requestAgentChatThreadOpen } from "../agent-chat.js";
|
|
13
14
|
import {
|
|
14
15
|
SIDEBAR_STATE_CHANGE_EVENT,
|
|
15
16
|
type AgentSidebarStateChangeDetail,
|
|
@@ -18,6 +19,13 @@ import { agentNativePath } from "../api-path.js";
|
|
|
18
19
|
import { readClientAppState, setClientAppState } from "../application-state.js";
|
|
19
20
|
import { getBrowserTabId } from "../browser-tab-id.js";
|
|
20
21
|
import { useT } from "../i18n.js";
|
|
22
|
+
import {
|
|
23
|
+
createRealtimeVoiceAudioLevelStore,
|
|
24
|
+
normalizeRealtimeVoiceRms,
|
|
25
|
+
smoothRealtimeVoiceLevel,
|
|
26
|
+
type RealtimeVoiceAudioLevelStore,
|
|
27
|
+
} from "./realtime-voice-audio-level.js";
|
|
28
|
+
import { realtimeVoiceTranscriptRegistry } from "./realtime-voice-transcript.js";
|
|
21
29
|
import {
|
|
22
30
|
RealtimeVoiceModeDock,
|
|
23
31
|
type RealtimeVoiceModeCopy,
|
|
@@ -43,11 +51,54 @@ export interface RealtimeVoiceModeApi {
|
|
|
43
51
|
active: boolean;
|
|
44
52
|
errorMessage: string | null;
|
|
45
53
|
chatVisible: boolean;
|
|
54
|
+
audioLevels: RealtimeVoiceAudioLevelStore;
|
|
46
55
|
start: () => Promise<void>;
|
|
47
56
|
end: () => void;
|
|
48
57
|
toggleChat: () => void;
|
|
49
58
|
}
|
|
50
59
|
|
|
60
|
+
export interface CompletedRealtimeVoiceTranscript {
|
|
61
|
+
role: "user" | "assistant";
|
|
62
|
+
text: string;
|
|
63
|
+
providerId?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Voice mode owns the chat only temporarily. Restore the captured transcript
|
|
68
|
+
* when it is still the user's active thread (or the chat has no active thread)
|
|
69
|
+
* but never pull them back after they deliberately selected another thread.
|
|
70
|
+
*/
|
|
71
|
+
export function shouldRestoreRealtimeVoiceTranscriptThread(
|
|
72
|
+
transcriptThreadId: string | undefined,
|
|
73
|
+
activeThreadId: string | undefined,
|
|
74
|
+
): transcriptThreadId is string {
|
|
75
|
+
return Boolean(
|
|
76
|
+
transcriptThreadId &&
|
|
77
|
+
(!activeThreadId || activeThreadId === transcriptThreadId),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function extractCompletedRealtimeVoiceTranscript(
|
|
82
|
+
event: RealtimeServerEvent,
|
|
83
|
+
): CompletedRealtimeVoiceTranscript | null {
|
|
84
|
+
const userCompleted =
|
|
85
|
+
event.type === "conversation.item.input_audio_transcription.completed";
|
|
86
|
+
const assistantCompleted =
|
|
87
|
+
event.type === "response.output_audio_transcript.done";
|
|
88
|
+
if (!userCompleted && !assistantCompleted) return null;
|
|
89
|
+
const text =
|
|
90
|
+
typeof event.transcript === "string" ? event.transcript.trim() : "";
|
|
91
|
+
if (!text) return null;
|
|
92
|
+
const providerId = [event.item_id, event.response_id, event.event_id].find(
|
|
93
|
+
(value): value is string => typeof value === "string" && value.length > 0,
|
|
94
|
+
);
|
|
95
|
+
return {
|
|
96
|
+
role: userCompleted ? "user" : "assistant",
|
|
97
|
+
text,
|
|
98
|
+
...(providerId ? { providerId } : {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
51
102
|
export interface RealtimeVoiceModeProviderProps {
|
|
52
103
|
children: ReactNode;
|
|
53
104
|
browserTabId?: string;
|
|
@@ -232,17 +283,29 @@ function useRealtimeVoiceModeController(
|
|
|
232
283
|
const [state, setState] = useState<"idle" | RealtimeVoiceModeState>("idle");
|
|
233
284
|
const [error, setError] = useState<string | null>(null);
|
|
234
285
|
const [chatVisible, setChatVisible] = useState(false);
|
|
286
|
+
const [audioLevels] = useState(createRealtimeVoiceAudioLevelStore);
|
|
235
287
|
const stateRef = useRef(state);
|
|
236
288
|
const peerRef = useRef<RTCPeerConnection | null>(null);
|
|
237
289
|
const channelRef = useRef<RTCDataChannel | null>(null);
|
|
238
290
|
const streamRef = useRef<MediaStream | null>(null);
|
|
239
291
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
292
|
+
const audioContextRef = useRef<AudioContext | null>(null);
|
|
293
|
+
const inputAnalyserRef = useRef<AnalyserNode | null>(null);
|
|
294
|
+
const outputAnalyserRef = useRef<AnalyserNode | null>(null);
|
|
295
|
+
const inputMeterBufferRef = useRef<Uint8Array<ArrayBuffer> | null>(null);
|
|
296
|
+
const outputMeterBufferRef = useRef<Uint8Array<ArrayBuffer> | null>(null);
|
|
297
|
+
const inputSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
|
298
|
+
const outputSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
|
299
|
+
const meterFrameRef = useRef<number | null>(null);
|
|
300
|
+
const lastMeterSampleRef = useRef(0);
|
|
240
301
|
const abortRef = useRef<AbortController | null>(null);
|
|
241
302
|
const handledCallsRef = useRef(new Set<string>());
|
|
242
303
|
const sessionIdRef = useRef<string | undefined>(undefined);
|
|
243
304
|
const startedAtRef = useRef<string | undefined>(undefined);
|
|
244
305
|
const lastUserTextRef = useRef("");
|
|
245
306
|
const lastAssistantTextRef = useRef("");
|
|
307
|
+
const transcriptThreadIdRef = useRef<string | undefined>(undefined);
|
|
308
|
+
const transcriptSequenceRef = useRef(0);
|
|
246
309
|
|
|
247
310
|
useEffect(() => {
|
|
248
311
|
stateRef.current = state;
|
|
@@ -279,6 +342,77 @@ function useRealtimeVoiceModeController(
|
|
|
279
342
|
[syncAppState],
|
|
280
343
|
);
|
|
281
344
|
|
|
345
|
+
const startMeterLoop = useCallback(() => {
|
|
346
|
+
if (meterFrameRef.current !== null) return;
|
|
347
|
+
const sample = (timestamp: number) => {
|
|
348
|
+
meterFrameRef.current = requestAnimationFrame(sample);
|
|
349
|
+
if (timestamp - lastMeterSampleRef.current < 50) return;
|
|
350
|
+
lastMeterSampleRef.current = timestamp;
|
|
351
|
+
|
|
352
|
+
const current = audioLevels.getSnapshot();
|
|
353
|
+
let input = current.input;
|
|
354
|
+
let output = current.output;
|
|
355
|
+
const inputAnalyser = inputAnalyserRef.current;
|
|
356
|
+
const inputBuffer = inputMeterBufferRef.current;
|
|
357
|
+
if (inputAnalyser && inputBuffer) {
|
|
358
|
+
inputAnalyser.getByteTimeDomainData(inputBuffer);
|
|
359
|
+
input = smoothRealtimeVoiceLevel(
|
|
360
|
+
input,
|
|
361
|
+
normalizeRealtimeVoiceRms(inputBuffer),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
const outputAnalyser = outputAnalyserRef.current;
|
|
365
|
+
const outputBuffer = outputMeterBufferRef.current;
|
|
366
|
+
if (outputAnalyser && outputBuffer) {
|
|
367
|
+
outputAnalyser.getByteTimeDomainData(outputBuffer);
|
|
368
|
+
output = smoothRealtimeVoiceLevel(
|
|
369
|
+
output,
|
|
370
|
+
normalizeRealtimeVoiceRms(outputBuffer),
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
audioLevels.set({ input, output });
|
|
374
|
+
};
|
|
375
|
+
meterFrameRef.current = requestAnimationFrame(sample);
|
|
376
|
+
}, [audioLevels]);
|
|
377
|
+
|
|
378
|
+
const attachAudioMeter = useCallback(
|
|
379
|
+
(stream: MediaStream, channel: "input" | "output") => {
|
|
380
|
+
try {
|
|
381
|
+
const AudioCtor =
|
|
382
|
+
window.AudioContext ??
|
|
383
|
+
(
|
|
384
|
+
window as typeof window & {
|
|
385
|
+
webkitAudioContext?: typeof AudioContext;
|
|
386
|
+
}
|
|
387
|
+
).webkitAudioContext;
|
|
388
|
+
if (!AudioCtor) return;
|
|
389
|
+
const context = audioContextRef.current ?? new AudioCtor();
|
|
390
|
+
audioContextRef.current = context;
|
|
391
|
+
void context.resume().catch(() => undefined);
|
|
392
|
+
const analyser = context.createAnalyser();
|
|
393
|
+
analyser.fftSize = 512;
|
|
394
|
+
const source = context.createMediaStreamSource(stream);
|
|
395
|
+
source.connect(analyser);
|
|
396
|
+
const buffer = new Uint8Array(analyser.frequencyBinCount);
|
|
397
|
+
if (channel === "input") {
|
|
398
|
+
inputSourceRef.current?.disconnect();
|
|
399
|
+
inputSourceRef.current = source;
|
|
400
|
+
inputAnalyserRef.current = analyser;
|
|
401
|
+
inputMeterBufferRef.current = buffer;
|
|
402
|
+
} else {
|
|
403
|
+
outputSourceRef.current?.disconnect();
|
|
404
|
+
outputSourceRef.current = source;
|
|
405
|
+
outputAnalyserRef.current = analyser;
|
|
406
|
+
outputMeterBufferRef.current = buffer;
|
|
407
|
+
}
|
|
408
|
+
startMeterLoop();
|
|
409
|
+
} catch {
|
|
410
|
+
// Audio metering is visual-only; keep the realtime call healthy.
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
[startMeterLoop],
|
|
414
|
+
);
|
|
415
|
+
|
|
282
416
|
const cleanupTransport = useCallback(() => {
|
|
283
417
|
abortRef.current?.abort();
|
|
284
418
|
abortRef.current = null;
|
|
@@ -293,8 +427,25 @@ function useRealtimeVoiceModeController(
|
|
|
293
427
|
audioRef.current.srcObject = null;
|
|
294
428
|
}
|
|
295
429
|
audioRef.current = null;
|
|
430
|
+
if (meterFrameRef.current !== null) {
|
|
431
|
+
cancelAnimationFrame(meterFrameRef.current);
|
|
432
|
+
meterFrameRef.current = null;
|
|
433
|
+
}
|
|
434
|
+
inputSourceRef.current?.disconnect();
|
|
435
|
+
outputSourceRef.current?.disconnect();
|
|
436
|
+
inputSourceRef.current = null;
|
|
437
|
+
outputSourceRef.current = null;
|
|
438
|
+
inputAnalyserRef.current = null;
|
|
439
|
+
outputAnalyserRef.current = null;
|
|
440
|
+
inputMeterBufferRef.current = null;
|
|
441
|
+
outputMeterBufferRef.current = null;
|
|
442
|
+
lastMeterSampleRef.current = 0;
|
|
443
|
+
const audioContext = audioContextRef.current;
|
|
444
|
+
audioContextRef.current = null;
|
|
445
|
+
if (audioContext) void audioContext.close().catch(() => undefined);
|
|
446
|
+
audioLevels.reset();
|
|
296
447
|
handledCallsRef.current.clear();
|
|
297
|
-
}, []);
|
|
448
|
+
}, [audioLevels]);
|
|
298
449
|
|
|
299
450
|
const fail = useCallback(
|
|
300
451
|
(message: string, options?: { openKeySettings?: boolean }) => {
|
|
@@ -342,6 +493,26 @@ function useRealtimeVoiceModeController(
|
|
|
342
493
|
[browserTabId, transition],
|
|
343
494
|
);
|
|
344
495
|
|
|
496
|
+
const persistCompletedTranscript = useCallback(
|
|
497
|
+
(event: RealtimeServerEvent) => {
|
|
498
|
+
const transcript = extractCompletedRealtimeVoiceTranscript(event);
|
|
499
|
+
const threadId = transcriptThreadIdRef.current;
|
|
500
|
+
if (!transcript || !threadId) return;
|
|
501
|
+
const sessionIdentity =
|
|
502
|
+
sessionIdRef.current ?? startedAtRef.current ?? "pending";
|
|
503
|
+
const providerIdentity =
|
|
504
|
+
transcript.providerId ?? `sequence-${++transcriptSequenceRef.current}`;
|
|
505
|
+
realtimeVoiceTranscriptRegistry.publish({
|
|
506
|
+
id: `realtime-voice:${sessionIdentity}:${transcript.role}:${providerIdentity}`,
|
|
507
|
+
threadId,
|
|
508
|
+
role: transcript.role,
|
|
509
|
+
text: transcript.text,
|
|
510
|
+
createdAt: new Date().toISOString(),
|
|
511
|
+
});
|
|
512
|
+
},
|
|
513
|
+
[],
|
|
514
|
+
);
|
|
515
|
+
|
|
345
516
|
const handleServerEvent = useCallback(
|
|
346
517
|
(event: RealtimeServerEvent) => {
|
|
347
518
|
if (event.type === "session.created") {
|
|
@@ -367,14 +538,16 @@ function useRealtimeVoiceModeController(
|
|
|
367
538
|
if (typeof event.transcript === "string") {
|
|
368
539
|
lastAssistantTextRef.current = event.transcript;
|
|
369
540
|
}
|
|
541
|
+
persistCompletedTranscript(event);
|
|
370
542
|
syncAppState("speaking");
|
|
371
543
|
} else if (
|
|
372
544
|
event.type === "conversation.item.input_audio_transcription.completed"
|
|
373
545
|
) {
|
|
374
546
|
if (typeof event.transcript === "string") {
|
|
375
547
|
lastUserTextRef.current = event.transcript;
|
|
376
|
-
syncAppState("working");
|
|
377
548
|
}
|
|
549
|
+
persistCompletedTranscript(event);
|
|
550
|
+
syncAppState("working");
|
|
378
551
|
} else if (event.type === "response.done") {
|
|
379
552
|
const response = event.response;
|
|
380
553
|
const status =
|
|
@@ -410,7 +583,14 @@ function useRealtimeVoiceModeController(
|
|
|
410
583
|
transition("listening");
|
|
411
584
|
}
|
|
412
585
|
},
|
|
413
|
-
[
|
|
586
|
+
[
|
|
587
|
+
copy,
|
|
588
|
+
fail,
|
|
589
|
+
handleFunctionCall,
|
|
590
|
+
persistCompletedTranscript,
|
|
591
|
+
syncAppState,
|
|
592
|
+
transition,
|
|
593
|
+
],
|
|
414
594
|
);
|
|
415
595
|
|
|
416
596
|
const start = useCallback(async () => {
|
|
@@ -430,6 +610,9 @@ function useRealtimeVoiceModeController(
|
|
|
430
610
|
lastUserTextRef.current = "";
|
|
431
611
|
lastAssistantTextRef.current = "";
|
|
432
612
|
sessionIdRef.current = undefined;
|
|
613
|
+
transcriptThreadIdRef.current =
|
|
614
|
+
realtimeVoiceTranscriptRegistry.activeThreadId();
|
|
615
|
+
transcriptSequenceRef.current = 0;
|
|
433
616
|
transition("connecting");
|
|
434
617
|
setChatVisible(false);
|
|
435
618
|
window.dispatchEvent(new Event("agent-panel:close"));
|
|
@@ -445,6 +628,7 @@ function useRealtimeVoiceModeController(
|
|
|
445
628
|
},
|
|
446
629
|
});
|
|
447
630
|
streamRef.current = stream;
|
|
631
|
+
attachAudioMeter(stream, "input");
|
|
448
632
|
|
|
449
633
|
const peer = new RTCPeerConnection();
|
|
450
634
|
peerRef.current = peer;
|
|
@@ -455,7 +639,9 @@ function useRealtimeVoiceModeController(
|
|
|
455
639
|
audio.setAttribute("playsinline", "");
|
|
456
640
|
audioRef.current = audio;
|
|
457
641
|
peer.ontrack = (trackEvent) => {
|
|
458
|
-
|
|
642
|
+
const remoteStream = trackEvent.streams[0] ?? null;
|
|
643
|
+
audio.srcObject = remoteStream;
|
|
644
|
+
if (remoteStream) attachAudioMeter(remoteStream, "output");
|
|
459
645
|
void audio.play().catch(() => undefined);
|
|
460
646
|
};
|
|
461
647
|
|
|
@@ -501,15 +687,42 @@ function useRealtimeVoiceModeController(
|
|
|
501
687
|
const status = (startError as { status?: unknown })?.status;
|
|
502
688
|
fail(errorMessage(startError), { openKeySettings: status === 400 });
|
|
503
689
|
}
|
|
504
|
-
}, [
|
|
690
|
+
}, [
|
|
691
|
+
attachAudioMeter,
|
|
692
|
+
browserTabId,
|
|
693
|
+
copy,
|
|
694
|
+
fail,
|
|
695
|
+
handleServerEvent,
|
|
696
|
+
transition,
|
|
697
|
+
]);
|
|
505
698
|
|
|
506
699
|
const end = useCallback(() => {
|
|
507
700
|
if (stateRef.current === "idle" || stateRef.current === "ending") return;
|
|
701
|
+
const transcriptThreadId = transcriptThreadIdRef.current;
|
|
702
|
+
const activeThreadId = realtimeVoiceTranscriptRegistry.activeThreadId();
|
|
508
703
|
transition("ending");
|
|
509
704
|
cleanupTransport();
|
|
510
705
|
setError(null);
|
|
511
706
|
sessionIdRef.current = undefined;
|
|
512
707
|
startedAtRef.current = undefined;
|
|
708
|
+
transcriptThreadIdRef.current = undefined;
|
|
709
|
+
setChatVisible(true);
|
|
710
|
+
if (
|
|
711
|
+
shouldRestoreRealtimeVoiceTranscriptThread(
|
|
712
|
+
transcriptThreadId,
|
|
713
|
+
activeThreadId,
|
|
714
|
+
)
|
|
715
|
+
) {
|
|
716
|
+
requestAgentChatThreadOpen({
|
|
717
|
+
threadId: transcriptThreadId,
|
|
718
|
+
// The request is delivered asynchronously. Re-checking this at the
|
|
719
|
+
// receiver prevents a navigation that happened during that gap from
|
|
720
|
+
// being overwritten.
|
|
721
|
+
onlyIfActiveThreadId: transcriptThreadId,
|
|
722
|
+
});
|
|
723
|
+
} else {
|
|
724
|
+
window.dispatchEvent(new Event("agent-panel:open"));
|
|
725
|
+
}
|
|
513
726
|
transition("idle");
|
|
514
727
|
}, [cleanupTransport, transition]);
|
|
515
728
|
|
|
@@ -538,6 +751,7 @@ function useRealtimeVoiceModeController(
|
|
|
538
751
|
active: state !== "idle",
|
|
539
752
|
errorMessage: error,
|
|
540
753
|
chatVisible,
|
|
754
|
+
audioLevels,
|
|
541
755
|
start,
|
|
542
756
|
end,
|
|
543
757
|
toggleChat,
|
|
@@ -568,10 +782,10 @@ export function RealtimeVoiceModeProvider({
|
|
|
568
782
|
state={voice.state === "idle" ? "ending" : voice.state}
|
|
569
783
|
copy={copy}
|
|
570
784
|
chatVisible={voice.chatVisible}
|
|
785
|
+
audioLevels={voice.audioLevels}
|
|
571
786
|
onToggleChat={voice.toggleChat}
|
|
572
787
|
onEndVoiceMode={voice.end}
|
|
573
788
|
errorMessage={voice.errorMessage}
|
|
574
|
-
className="z-[270]"
|
|
575
789
|
/>,
|
|
576
790
|
document.body,
|
|
577
791
|
)
|