@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.
Files changed (107) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/docs/content/cloneable-saas.mdx +15 -15
  4. package/corpus/core/docs/content/creating-templates.mdx +1 -1
  5. package/corpus/core/docs/content/extensions.mdx +1 -1
  6. package/corpus/core/docs/content/local-file-mode.mdx +6 -6
  7. package/corpus/core/docs/content/locales/ar-SA/voice-input.mdx +2 -2
  8. package/corpus/core/docs/content/locales/de-DE/voice-input.mdx +2 -2
  9. package/corpus/core/docs/content/locales/es-ES/voice-input.mdx +2 -2
  10. package/corpus/core/docs/content/locales/fr-FR/voice-input.mdx +2 -2
  11. package/corpus/core/docs/content/locales/hi-IN/voice-input.mdx +2 -2
  12. package/corpus/core/docs/content/locales/ja-JP/voice-input.mdx +2 -2
  13. package/corpus/core/docs/content/locales/ko-KR/voice-input.mdx +2 -2
  14. package/corpus/core/docs/content/locales/pt-BR/voice-input.mdx +2 -2
  15. package/corpus/core/docs/content/locales/zh-CN/voice-input.mdx +2 -2
  16. package/corpus/core/docs/content/locales/zh-TW/voice-input.mdx +2 -2
  17. package/corpus/core/docs/content/sharing.mdx +4 -4
  18. package/corpus/core/docs/content/template-analytics.mdx +2 -2
  19. package/corpus/core/docs/content/template-calendar.mdx +4 -4
  20. package/corpus/core/docs/content/template-clips.mdx +2 -2
  21. package/corpus/core/docs/content/template-content.mdx +12 -11
  22. package/corpus/core/docs/content/template-mail.mdx +1 -1
  23. package/corpus/core/docs/content/template-slides.mdx +2 -2
  24. package/corpus/core/docs/content/voice-input.mdx +2 -2
  25. package/corpus/core/docs/content/workspace.mdx +1 -1
  26. package/corpus/core/package.json +1 -1
  27. package/corpus/core/src/client/AssistantChat.tsx +75 -1
  28. package/corpus/core/src/client/MultiTabAssistantChat.tsx +19 -1
  29. package/corpus/core/src/client/agent-chat.ts +6 -0
  30. package/corpus/core/src/client/composer/RealtimeVoiceMode.tsx +72 -2
  31. package/corpus/core/src/client/composer/realtime-voice-audio-level.ts +64 -0
  32. package/corpus/core/src/client/composer/realtime-voice-transcript.ts +134 -0
  33. package/corpus/core/src/client/composer/useRealtimeVoiceMode.tsx +220 -6
  34. package/corpus/core/src/client/use-chat-threads.ts +33 -9
  35. package/corpus/core/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +11 -5
  36. package/corpus/templates/clips/app/components/player/comments-panel.tsx +98 -1
  37. package/corpus/templates/clips/changelog/2026-07-10-comment-urls-open-as-clickable-links.md +6 -0
  38. package/corpus/templates/clips/changelog/2026-07-10-desktop-recording-uploads-now-show-a-brief-uploaded-state-wi.md +6 -0
  39. package/corpus/templates/clips/changelog/2026-07-10-desktop-recordings-now-retry-saving-their-locally-captured-t.md +6 -0
  40. package/corpus/templates/clips/changelog/2026-07-10-desktop-update-prompts-are-more-compact-and-easier-to-dismiss.md +6 -0
  41. package/corpus/templates/clips/changelog/2026-07-10-quiet-recordings-now-finish-saving-instead-of-getting-stuck.md +6 -0
  42. package/corpus/templates/clips/desktop/src/components/UpdateBanner.tsx +7 -4
  43. package/corpus/templates/clips/desktop/src/lib/recorder.ts +284 -85
  44. package/corpus/templates/clips/desktop/src/overlays/finalizing.tsx +148 -40
  45. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +13 -13
  46. package/corpus/templates/clips/desktop/src/styles.css +83 -13
  47. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +43 -28
  48. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +130 -65
  49. package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +10 -4
  50. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +82 -1
  51. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +82 -1
  52. package/dist/client/AssistantChat.d.ts +6 -0
  53. package/dist/client/AssistantChat.d.ts.map +1 -1
  54. package/dist/client/AssistantChat.js +57 -1
  55. package/dist/client/AssistantChat.js.map +1 -1
  56. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  57. package/dist/client/MultiTabAssistantChat.js +9 -0
  58. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  59. package/dist/client/agent-chat.d.ts +6 -0
  60. package/dist/client/agent-chat.d.ts.map +1 -1
  61. package/dist/client/agent-chat.js.map +1 -1
  62. package/dist/client/composer/RealtimeVoiceMode.d.ts +3 -1
  63. package/dist/client/composer/RealtimeVoiceMode.d.ts.map +1 -1
  64. package/dist/client/composer/RealtimeVoiceMode.js +32 -3
  65. package/dist/client/composer/RealtimeVoiceMode.js.map +1 -1
  66. package/dist/client/composer/realtime-voice-audio-level.d.ts +14 -0
  67. package/dist/client/composer/realtime-voice-audio-level.d.ts.map +1 -0
  68. package/dist/client/composer/realtime-voice-audio-level.js +47 -0
  69. package/dist/client/composer/realtime-voice-audio-level.js.map +1 -0
  70. package/dist/client/composer/realtime-voice-transcript.d.ts +26 -0
  71. package/dist/client/composer/realtime-voice-transcript.d.ts.map +1 -0
  72. package/dist/client/composer/realtime-voice-transcript.js +103 -0
  73. package/dist/client/composer/realtime-voice-transcript.js.map +1 -0
  74. package/dist/client/composer/useRealtimeVoiceMode.d.ts +14 -0
  75. package/dist/client/composer/useRealtimeVoiceMode.d.ts.map +1 -1
  76. package/dist/client/composer/useRealtimeVoiceMode.js +177 -6
  77. package/dist/client/composer/useRealtimeVoiceMode.js.map +1 -1
  78. package/dist/client/use-chat-threads.d.ts.map +1 -1
  79. package/dist/client/use-chat-threads.js +26 -6
  80. package/dist/client/use-chat-threads.js.map +1 -1
  81. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  82. package/dist/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +11 -5
  83. package/docs/content/cloneable-saas.mdx +15 -15
  84. package/docs/content/creating-templates.mdx +1 -1
  85. package/docs/content/extensions.mdx +1 -1
  86. package/docs/content/local-file-mode.mdx +6 -6
  87. package/docs/content/locales/ar-SA/voice-input.mdx +2 -2
  88. package/docs/content/locales/de-DE/voice-input.mdx +2 -2
  89. package/docs/content/locales/es-ES/voice-input.mdx +2 -2
  90. package/docs/content/locales/fr-FR/voice-input.mdx +2 -2
  91. package/docs/content/locales/hi-IN/voice-input.mdx +2 -2
  92. package/docs/content/locales/ja-JP/voice-input.mdx +2 -2
  93. package/docs/content/locales/ko-KR/voice-input.mdx +2 -2
  94. package/docs/content/locales/pt-BR/voice-input.mdx +2 -2
  95. package/docs/content/locales/zh-CN/voice-input.mdx +2 -2
  96. package/docs/content/locales/zh-TW/voice-input.mdx +2 -2
  97. package/docs/content/sharing.mdx +4 -4
  98. package/docs/content/template-analytics.mdx +2 -2
  99. package/docs/content/template-calendar.mdx +4 -4
  100. package/docs/content/template-clips.mdx +2 -2
  101. package/docs/content/template-content.mdx +12 -11
  102. package/docs/content/template-mail.mdx +1 -1
  103. package/docs/content/template-slides.mdx +2 -2
  104. package/docs/content/voice-input.mdx +2 -2
  105. package/docs/content/workspace.mdx +1 -1
  106. package/package.json +1 -1
  107. 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
- | { threadId?: unknown; newThread?: unknown; openRequestId?: unknown }
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
- <VoiceStateIcon state={state} />
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();