@agent-native/core 0.94.0 → 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 +12 -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 +75 -1
- 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 +6 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +57 -1
- 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/provider-api/corpus-jobs.d.ts +2 -2
- 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
|
@@ -146,6 +146,10 @@ import { AgentComposerFrame } from "./composer/AgentComposerFrame.js";
|
|
|
146
146
|
import { TextAttachmentAdapter } from "./composer/attachment-accept.js";
|
|
147
147
|
import { isPastedTextAttachmentName } from "./composer/pasted-text.js";
|
|
148
148
|
import { PastedTextChip } from "./composer/PastedTextChip.js";
|
|
149
|
+
import {
|
|
150
|
+
appendRealtimeVoiceTranscriptToRepository,
|
|
151
|
+
realtimeVoiceTranscriptRegistry,
|
|
152
|
+
} from "./composer/realtime-voice-transcript.js";
|
|
149
153
|
import {
|
|
150
154
|
TiptapComposer,
|
|
151
155
|
type ComposerSubmitIntent,
|
|
@@ -354,6 +358,27 @@ function activeRunMatchesThread(
|
|
|
354
358
|
return Boolean(threadId && state?.threadId === threadId && state.runId);
|
|
355
359
|
}
|
|
356
360
|
|
|
361
|
+
export function assistantMessageRunId(message: unknown): string | undefined {
|
|
362
|
+
const metadata = (message as { metadata?: unknown })?.metadata as
|
|
363
|
+
| { custom?: { runId?: unknown }; runId?: unknown }
|
|
364
|
+
| undefined;
|
|
365
|
+
return typeof metadata?.custom?.runId === "string"
|
|
366
|
+
? metadata.custom.runId
|
|
367
|
+
: typeof metadata?.runId === "string"
|
|
368
|
+
? metadata.runId
|
|
369
|
+
: undefined;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function shouldAcceptRunError(args: {
|
|
373
|
+
errorRunId?: string;
|
|
374
|
+
activeRunId?: string;
|
|
375
|
+
latestAssistantRunId?: string;
|
|
376
|
+
}): boolean {
|
|
377
|
+
if (!args.errorRunId) return true;
|
|
378
|
+
const expectedRunId = args.activeRunId ?? args.latestAssistantRunId;
|
|
379
|
+
return !expectedRunId || args.errorRunId === expectedRunId;
|
|
380
|
+
}
|
|
381
|
+
|
|
357
382
|
function isAssistantUiDuplicateMessageIdError(error: unknown): boolean {
|
|
358
383
|
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
359
384
|
return (
|
|
@@ -2259,6 +2284,32 @@ const AssistantChatInner = forwardRef<
|
|
|
2259
2284
|
[threadRuntime],
|
|
2260
2285
|
);
|
|
2261
2286
|
|
|
2287
|
+
const appendRealtimeVoiceTranscript = useCallback(
|
|
2288
|
+
(
|
|
2289
|
+
transcript: Parameters<typeof realtimeVoiceTranscriptRegistry.publish>[0],
|
|
2290
|
+
) => {
|
|
2291
|
+
if (isRestoring || isRunning) return false;
|
|
2292
|
+
const result = appendRealtimeVoiceTranscriptToRepository(
|
|
2293
|
+
exportCleanThreadRepo(),
|
|
2294
|
+
transcript,
|
|
2295
|
+
);
|
|
2296
|
+
if (!result.appended) return true;
|
|
2297
|
+
threadRuntime.import(ensureMessageMetadata(result.repository));
|
|
2298
|
+
return true;
|
|
2299
|
+
},
|
|
2300
|
+
[exportCleanThreadRepo, isRestoring, isRunning, threadRuntime],
|
|
2301
|
+
);
|
|
2302
|
+
|
|
2303
|
+
useEffect(() => {
|
|
2304
|
+
const transcriptThreadId = threadId ?? tabId;
|
|
2305
|
+
if (!transcriptThreadId) return;
|
|
2306
|
+
return realtimeVoiceTranscriptRegistry.register({
|
|
2307
|
+
threadId: transcriptThreadId,
|
|
2308
|
+
active: isActiveComposer,
|
|
2309
|
+
append: appendRealtimeVoiceTranscript,
|
|
2310
|
+
});
|
|
2311
|
+
}, [appendRealtimeVoiceTranscript, isActiveComposer, tabId, threadId]);
|
|
2312
|
+
|
|
2262
2313
|
const appendThreadMessage = useCallback(
|
|
2263
2314
|
(message: Parameters<typeof threadRuntime.append>[0]) => {
|
|
2264
2315
|
try {
|
|
@@ -3260,6 +3311,16 @@ const AssistantChatInner = forwardRef<
|
|
|
3260
3311
|
return () => window.removeEventListener("agent-chat:loop-limit", handler);
|
|
3261
3312
|
}, [tabId]);
|
|
3262
3313
|
|
|
3314
|
+
const latestAssistantRunId = useMemo(() => {
|
|
3315
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
3316
|
+
const message = messages[index];
|
|
3317
|
+
if (message?.role !== "assistant") continue;
|
|
3318
|
+
const runId = assistantMessageRunId(message);
|
|
3319
|
+
if (runId) return runId;
|
|
3320
|
+
}
|
|
3321
|
+
return undefined;
|
|
3322
|
+
}, [messages]);
|
|
3323
|
+
|
|
3263
3324
|
useEffect(() => {
|
|
3264
3325
|
const handler = (e: Event) => {
|
|
3265
3326
|
const detail = (e as CustomEvent).detail as RunErrorInfo & {
|
|
@@ -3267,6 +3328,19 @@ const AssistantChatInner = forwardRef<
|
|
|
3267
3328
|
};
|
|
3268
3329
|
if (tabId && detail?.tabId && detail.tabId !== tabId) return;
|
|
3269
3330
|
if (!detail?.message) return;
|
|
3331
|
+
const activeRun = getActiveRun();
|
|
3332
|
+
const activeRunId = activeRunMatchesThread(activeRun, threadId)
|
|
3333
|
+
? activeRun?.runId
|
|
3334
|
+
: undefined;
|
|
3335
|
+
if (
|
|
3336
|
+
!shouldAcceptRunError({
|
|
3337
|
+
errorRunId: detail.runId,
|
|
3338
|
+
activeRunId,
|
|
3339
|
+
latestAssistantRunId,
|
|
3340
|
+
})
|
|
3341
|
+
) {
|
|
3342
|
+
return;
|
|
3343
|
+
}
|
|
3270
3344
|
const stopped = userStoppedRunRef.current;
|
|
3271
3345
|
if (
|
|
3272
3346
|
stopped &&
|
|
@@ -3286,7 +3360,7 @@ const AssistantChatInner = forwardRef<
|
|
|
3286
3360
|
};
|
|
3287
3361
|
window.addEventListener("agent-chat:run-error", handler);
|
|
3288
3362
|
return () => window.removeEventListener("agent-chat:run-error", handler);
|
|
3289
|
-
}, [tabId]);
|
|
3363
|
+
}, [latestAssistantRunId, tabId, threadId]);
|
|
3290
3364
|
|
|
3291
3365
|
// Real activity means the next chunk has started. Surface longer-lived
|
|
3292
3366
|
// activity such as "Still generating image" so active-run reconnects do not
|
|
@@ -2117,13 +2117,31 @@ export function MultiTabAssistantChat({
|
|
|
2117
2117
|
useEffect(() => {
|
|
2118
2118
|
const handleOpenThread = (event: Event) => {
|
|
2119
2119
|
const detail = (event as CustomEvent).detail as
|
|
2120
|
-
| {
|
|
2120
|
+
| {
|
|
2121
|
+
threadId?: unknown;
|
|
2122
|
+
newThread?: unknown;
|
|
2123
|
+
onlyIfActiveThreadId?: unknown;
|
|
2124
|
+
openRequestId?: unknown;
|
|
2125
|
+
}
|
|
2121
2126
|
| undefined;
|
|
2122
2127
|
const threadId =
|
|
2123
2128
|
typeof detail?.threadId === "string" ? detail.threadId : "";
|
|
2124
2129
|
if (!detail || !threadId) return;
|
|
2125
2130
|
if (!claimAgentChatOpenRequest(detail.openRequestId)) return;
|
|
2126
2131
|
|
|
2132
|
+
const onlyIfActiveThreadId =
|
|
2133
|
+
typeof detail.onlyIfActiveThreadId === "string"
|
|
2134
|
+
? detail.onlyIfActiveThreadId.trim()
|
|
2135
|
+
: "";
|
|
2136
|
+
const activeThreadId = activeThreadIdRef.current;
|
|
2137
|
+
if (
|
|
2138
|
+
onlyIfActiveThreadId &&
|
|
2139
|
+
activeThreadId &&
|
|
2140
|
+
activeThreadId !== onlyIfActiveThreadId
|
|
2141
|
+
) {
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2127
2145
|
if (detail?.newThread === true) {
|
|
2128
2146
|
newThreadIds.current.add(threadId);
|
|
2129
2147
|
void createThread(threadId).then((createdId) => {
|
|
@@ -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();
|