@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
|
@@ -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
|
)
|
|
@@ -939,15 +939,39 @@ export function useChatThreads(
|
|
|
939
939
|
titleSource,
|
|
940
940
|
{ preserveUserTitle },
|
|
941
941
|
);
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
942
|
+
const payload = {
|
|
943
|
+
...threadDataPayload,
|
|
944
|
+
title,
|
|
945
|
+
scope: localScope,
|
|
946
|
+
};
|
|
947
|
+
let response = await fetch(
|
|
948
|
+
`${apiUrl}/threads/${encodeURIComponent(id)}`,
|
|
949
|
+
{
|
|
950
|
+
method: "PUT",
|
|
951
|
+
headers: { "Content-Type": "application/json" },
|
|
952
|
+
body: JSON.stringify(payload),
|
|
953
|
+
},
|
|
954
|
+
);
|
|
955
|
+
// A passive realtime-voice transcript can be the first content in a
|
|
956
|
+
// client-created thread, so no agent run has created its SQL row yet.
|
|
957
|
+
// Materialize that row idempotently and retry the same full save.
|
|
958
|
+
if (response.status === 404) {
|
|
959
|
+
const created = await fetch(`${apiUrl}/threads`, {
|
|
960
|
+
method: "POST",
|
|
961
|
+
headers: { "Content-Type": "application/json" },
|
|
962
|
+
body: JSON.stringify({ id, title, scope: localScope }),
|
|
963
|
+
});
|
|
964
|
+
if (!created.ok) return;
|
|
965
|
+
response = await fetch(
|
|
966
|
+
`${apiUrl}/threads/${encodeURIComponent(id)}`,
|
|
967
|
+
{
|
|
968
|
+
method: "PUT",
|
|
969
|
+
headers: { "Content-Type": "application/json" },
|
|
970
|
+
body: JSON.stringify(payload),
|
|
971
|
+
},
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
if (!response.ok) return;
|
|
951
975
|
emitThreadsUpdated();
|
|
952
976
|
// Update local thread list metadata. If the thread isn't in our
|
|
953
977
|
// local list yet (an optimistic-only thread that the server just
|
package/corpus/core/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md
CHANGED
|
@@ -46,8 +46,11 @@ from the dictation providers below. It requires the authenticated user's
|
|
|
46
46
|
configuration, and keeps the key out of browser code.
|
|
47
47
|
|
|
48
48
|
- Starting voice mode collapses the chat into a persistent bottom-end speech
|
|
49
|
-
orb.
|
|
50
|
-
|
|
49
|
+
orb. The orb stays visible above the chat even when the chat opens
|
|
50
|
+
automatically. Clicking it shows or hides chat without ending the session;
|
|
51
|
+
ending the session is a separate, explicit control.
|
|
52
|
+
- The orb's compact waveform must reflect actual microphone or assistant audio
|
|
53
|
+
activity. It is an audio-level indicator, not a decorative animation.
|
|
51
54
|
- Semantic VAD keeps listening, starts responses automatically, and supports
|
|
52
55
|
barge-in while the agent is speaking.
|
|
53
56
|
- Function calls must cross the authenticated realtime tool bridge and enter
|
|
@@ -57,9 +60,12 @@ configuration, and keeps the key out of browser code.
|
|
|
57
60
|
- Preserve the active browser-tab id in request context so `set-url`,
|
|
58
61
|
`set-search-params`, `view-screen`, and tab-scoped application state affect
|
|
59
62
|
the app the user is actually speaking to.
|
|
60
|
-
- Do not persist audio.
|
|
61
|
-
|
|
62
|
-
session
|
|
63
|
+
- Do not persist audio or interim transcript deltas. Append completed user and
|
|
64
|
+
assistant utterances as ordinary text messages to the exact chat thread
|
|
65
|
+
captured when the session starts. Ending voice mode opens that chat so the
|
|
66
|
+
user can continue over text. Store only compact lifecycle and latest context
|
|
67
|
+
at `application_state["realtime-voice-session"]`; delete it when the session
|
|
68
|
+
ends.
|
|
63
69
|
- Missing-key failures should open Settings focused on the user-scoped
|
|
64
70
|
`OPENAI_API_KEY` field. Never send, log, or echo the key to the browser.
|
|
65
71
|
|
|
@@ -717,6 +717,8 @@ function CommentCard({
|
|
|
717
717
|
return updatedReactions;
|
|
718
718
|
}
|
|
719
719
|
|
|
720
|
+
const commentContent = linkifyCommentContent(comment.content);
|
|
721
|
+
|
|
720
722
|
return (
|
|
721
723
|
<div className={cn("flex gap-2", comment.resolved && "opacity-60")}>
|
|
722
724
|
<Avatar className="h-7 w-7 shrink-0">
|
|
@@ -747,7 +749,7 @@ function CommentCard({
|
|
|
747
749
|
) : null}
|
|
748
750
|
</div>
|
|
749
751
|
<p className="text-sm text-foreground whitespace-pre-wrap break-words mt-0.5">
|
|
750
|
-
{
|
|
752
|
+
{commentContent}
|
|
751
753
|
</p>
|
|
752
754
|
|
|
753
755
|
<div className="flex items-center gap-2 mt-1.5 text-xs text-muted-foreground">
|
|
@@ -856,6 +858,101 @@ function CommentCard({
|
|
|
856
858
|
);
|
|
857
859
|
}
|
|
858
860
|
|
|
861
|
+
const COMMENT_URL_PATTERN = /\b(?:https?:\/\/|www\.)[^\s<]+/gi;
|
|
862
|
+
const ALWAYS_TRAILING_PUNCTUATION = new Set([
|
|
863
|
+
".",
|
|
864
|
+
",",
|
|
865
|
+
"!",
|
|
866
|
+
"?",
|
|
867
|
+
";",
|
|
868
|
+
":",
|
|
869
|
+
"'",
|
|
870
|
+
'"',
|
|
871
|
+
]);
|
|
872
|
+
const PAIRED_TRAILING_PUNCTUATION: Record<string, string> = {
|
|
873
|
+
")": "(",
|
|
874
|
+
"]": "[",
|
|
875
|
+
"}": "{",
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
function trimTrailingUrlPunctuation(value: string): {
|
|
879
|
+
url: string;
|
|
880
|
+
trailing: string;
|
|
881
|
+
} {
|
|
882
|
+
let end = value.length;
|
|
883
|
+
|
|
884
|
+
while (end > 0) {
|
|
885
|
+
const lastCharacter = value[end - 1];
|
|
886
|
+
if (ALWAYS_TRAILING_PUNCTUATION.has(lastCharacter)) {
|
|
887
|
+
end -= 1;
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const openingCharacter = PAIRED_TRAILING_PUNCTUATION[lastCharacter];
|
|
892
|
+
if (openingCharacter) {
|
|
893
|
+
const candidate = value.slice(0, end);
|
|
894
|
+
const openingCount = candidate.split(openingCharacter).length - 1;
|
|
895
|
+
const closingCount = candidate.split(lastCharacter).length - 1;
|
|
896
|
+
if (closingCount > openingCount) {
|
|
897
|
+
end -= 1;
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return {
|
|
906
|
+
url: value.slice(0, end),
|
|
907
|
+
trailing: value.slice(end),
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function linkifyCommentContent(content: string): React.ReactNode[] {
|
|
912
|
+
const result: React.ReactNode[] = [];
|
|
913
|
+
let lastIndex = 0;
|
|
914
|
+
|
|
915
|
+
for (const match of content.matchAll(COMMENT_URL_PATTERN)) {
|
|
916
|
+
const matchIndex = match.index;
|
|
917
|
+
const matchedText = match[0];
|
|
918
|
+
const { url, trailing } = trimTrailingUrlPunctuation(matchedText);
|
|
919
|
+
const href = url.toLowerCase().startsWith("www.") ? `https://${url}` : url;
|
|
920
|
+
|
|
921
|
+
let isValidUrl = false;
|
|
922
|
+
try {
|
|
923
|
+
isValidUrl = Boolean(new URL(href).hostname);
|
|
924
|
+
} catch {
|
|
925
|
+
// Leave malformed URL-like text unlinked.
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (!isValidUrl) continue;
|
|
929
|
+
|
|
930
|
+
if (matchIndex > lastIndex) {
|
|
931
|
+
result.push(content.slice(lastIndex, matchIndex));
|
|
932
|
+
}
|
|
933
|
+
result.push(
|
|
934
|
+
<a
|
|
935
|
+
key={`${matchIndex}-${url}`}
|
|
936
|
+
href={href}
|
|
937
|
+
target="_blank"
|
|
938
|
+
rel="noopener noreferrer"
|
|
939
|
+
className="text-primary hover:underline"
|
|
940
|
+
>
|
|
941
|
+
{url}
|
|
942
|
+
</a>,
|
|
943
|
+
);
|
|
944
|
+
if (trailing) result.push(trailing);
|
|
945
|
+
|
|
946
|
+
lastIndex = matchIndex + matchedText.length;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (lastIndex < content.length) {
|
|
950
|
+
result.push(content.slice(lastIndex));
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
return result;
|
|
954
|
+
}
|
|
955
|
+
|
|
859
956
|
function parseReactions(raw: string): Record<string, string[]> {
|
|
860
957
|
try {
|
|
861
958
|
const v = JSON.parse(raw ?? "{}");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { IconX } from "@tabler/icons-react";
|
|
1
2
|
import { useState } from "react";
|
|
2
3
|
|
|
3
4
|
import {
|
|
@@ -73,7 +74,7 @@ export function UpdateBanner() {
|
|
|
73
74
|
<div className="update-banner update-banner--pending">
|
|
74
75
|
<DownloadIcon />
|
|
75
76
|
<span className="update-banner-text">
|
|
76
|
-
Update
|
|
77
|
+
Update available — downloading…
|
|
77
78
|
</span>
|
|
78
79
|
</div>
|
|
79
80
|
);
|
|
@@ -94,15 +95,17 @@ export function UpdateBanner() {
|
|
|
94
95
|
return (
|
|
95
96
|
<div className="update-banner update-banner--ready">
|
|
96
97
|
<span className="update-banner-text">
|
|
97
|
-
Update
|
|
98
|
+
Update ready — restart to install
|
|
98
99
|
</span>
|
|
99
100
|
<div className="update-banner-actions">
|
|
100
101
|
<button
|
|
101
102
|
type="button"
|
|
102
|
-
className="update-banner-btn update-banner-btn--
|
|
103
|
+
className="update-banner-btn update-banner-btn--dismiss"
|
|
103
104
|
onClick={() => setDismissedVersion(status.version)}
|
|
105
|
+
aria-label="Dismiss update"
|
|
106
|
+
title="Dismiss update"
|
|
104
107
|
>
|
|
105
|
-
|
|
108
|
+
<IconX size={15} strokeWidth={1.8} aria-hidden />
|
|
106
109
|
</button>
|
|
107
110
|
<button
|
|
108
111
|
type="button"
|