@iloveagents/foundry-web-voice 0.1.12 → 0.1.14
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/README.md +17 -0
- package/dist/adapter/relay-activity.d.ts +4 -0
- package/dist/adapter/relay-activity.js +17 -0
- package/dist/adapter/types.d.ts +22 -0
- package/dist/adapter/voice-bridge.d.ts +13 -1
- package/dist/adapter/voice-bridge.js +90 -4
- package/dist/index.d.ts +1 -1
- package/dist/react/relay-answer-watcher.js +10 -0
- package/dist/react/voice-stage.js +3 -3
- package/dist/react/voice-surface.js +2 -6
- package/dist/react/voice-ui-store.d.ts +4 -0
- package/dist/react/voice-ui-store.js +11 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -43,6 +43,23 @@ retrieval surface, and the reply is spoken back in the Azure voice you configure
|
|
|
43
43
|
that is `turn_detection.create_response: false` plus `pre_generated_assistant_message` — both
|
|
44
44
|
documented Voice Live features, not tricks.
|
|
45
45
|
|
|
46
|
+
For slow app-agent runs, opt into `relayInterimResponse: { text: "One moment, please.",
|
|
47
|
+
delayMs: 15000, skipInitialResponse: true }`. The relay speaks it once while waiting
|
|
48
|
+
for answer text, through the same serialized speech queue. `skipInitialResponse`
|
|
49
|
+
lets the first reply of each call open the conversation without an acknowledgement.
|
|
50
|
+
Answer text (including a buffered short reply), interruption, run completion and
|
|
51
|
+
disconnect cancel a pending acknowledgement. It is presentation feedback, so it
|
|
52
|
+
does not add a message to the persisted conversation. Native Voice Live interim
|
|
53
|
+
responses cannot observe tools executing in the app's separate agent.
|
|
54
|
+
|
|
55
|
+
Optionally provide `describeActivity({ toolName, completed, args })` to return a
|
|
56
|
+
short description of observed tool work, or `null` for unknown activity. The
|
|
57
|
+
watcher reports only tools from the current user turn, preferring pending calls.
|
|
58
|
+
With `intervalMs` (default 18000), changed descriptions can be spoken occasionally,
|
|
59
|
+
never repeating a phrase and capped at three updates per run. Described tool work
|
|
60
|
+
can receive an update during the opening turn; the generic fallback remains
|
|
61
|
+
suppressed there. Curate descriptions rather than speaking raw arguments or results.
|
|
62
|
+
|
|
46
63
|
**`realtime`** hands the conversation to the Voice Live model for the lowest possible latency. It
|
|
47
64
|
can still call your client-side tools: the schemas in `clientToolRegistry` are sent with the
|
|
48
65
|
session, and a call is dispatched through the same registry the typed chat uses, so `ui_navigate`
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ThreadMessage } from "@assistant-ui/react";
|
|
2
|
+
import type { RelayActivity } from "./types.js";
|
|
3
|
+
/** Only describe work in the current user turn; prefer a call still in progress. */
|
|
4
|
+
export declare function currentRelayActivity(messages: readonly ThreadMessage[]): RelayActivity | null;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Only describe work in the current user turn; prefer a call still in progress. */
|
|
2
|
+
export function currentRelayActivity(messages) {
|
|
3
|
+
let userIndex = messages.length - 1;
|
|
4
|
+
while (userIndex >= 0 && messages[userIndex]?.role !== "user")
|
|
5
|
+
userIndex--;
|
|
6
|
+
if (userIndex < 0)
|
|
7
|
+
return null;
|
|
8
|
+
const tools = messages
|
|
9
|
+
.slice(userIndex + 1)
|
|
10
|
+
.flatMap((message) => message.role === "assistant"
|
|
11
|
+
? message.content.filter((part) => part.type === "tool-call")
|
|
12
|
+
: []);
|
|
13
|
+
const tool = [...tools].reverse().find((part) => part.result === undefined) ?? tools[tools.length - 1];
|
|
14
|
+
return tool
|
|
15
|
+
? { toolName: tool.toolName, completed: tool.result !== undefined, args: tool.args }
|
|
16
|
+
: null;
|
|
17
|
+
}
|
package/dist/adapter/types.d.ts
CHANGED
|
@@ -31,9 +31,31 @@ export interface VoiceTranscript {
|
|
|
31
31
|
text: string;
|
|
32
32
|
isFinal: boolean;
|
|
33
33
|
}
|
|
34
|
+
export interface RelayActivity {
|
|
35
|
+
toolName: string;
|
|
36
|
+
completed: boolean;
|
|
37
|
+
args?: Readonly<Record<string, unknown>>;
|
|
38
|
+
}
|
|
34
39
|
export interface VoiceConfig {
|
|
35
40
|
/** @default "relay" */
|
|
36
41
|
mode?: VoiceMode;
|
|
42
|
+
/**
|
|
43
|
+
* Spoken progress when the app's agent takes longer to answer.
|
|
44
|
+
* Relay only: Voice Live cannot observe tools running in the app's agent.
|
|
45
|
+
* Uses the normal speech queue, never adds a message to conversation history.
|
|
46
|
+
* Cancelled on the first answer text, interruption, run end or disconnect.
|
|
47
|
+
*/
|
|
48
|
+
relayInterimResponse?: {
|
|
49
|
+
text: string;
|
|
50
|
+
/** @default 15000 */
|
|
51
|
+
delayMs?: number;
|
|
52
|
+
/** Suppress opening-turn filler; described tool work can still receive updates. */
|
|
53
|
+
skipInitialResponse?: boolean;
|
|
54
|
+
/** Describe observed tool activity without speaking raw arguments or reasoning. */
|
|
55
|
+
describeActivity?: (activity: RelayActivity) => string | null;
|
|
56
|
+
/** Minimum spacing for changed activity updates; at most three updates per run. */
|
|
57
|
+
intervalMs?: number;
|
|
58
|
+
};
|
|
37
59
|
/**
|
|
38
60
|
* How to reach Voice Live. In production this is `{ proxyUrl }`: a
|
|
39
61
|
* browser cannot set an `Authorization` header on a WebSocket, so the
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { type RealtimeVoiceAdapter } from "@assistant-ui/react";
|
|
27
27
|
import type { VoiceLiveClientEvent, VoiceLiveServerEvent, SessionState } from "@iloveagents/foundry-voice-live-react";
|
|
28
|
-
import type { VoiceConfig } from "./types.js";
|
|
28
|
+
import type { RelayActivity, VoiceConfig } from "./types.js";
|
|
29
29
|
/** What the bridge needs from the live session; supplied by the React host. */
|
|
30
30
|
export interface VoiceTransportControls {
|
|
31
31
|
connect: () => Promise<void>;
|
|
@@ -74,6 +74,13 @@ export declare class VoiceBridge {
|
|
|
74
74
|
* stream, and speak the whole reply again. Forever.
|
|
75
75
|
*/
|
|
76
76
|
private answerDone;
|
|
77
|
+
private hasSpokenAnswer;
|
|
78
|
+
private relayRunId;
|
|
79
|
+
private interimTimer;
|
|
80
|
+
private interimActivity;
|
|
81
|
+
private interimSuppressed;
|
|
82
|
+
private interimCount;
|
|
83
|
+
private interimTexts;
|
|
77
84
|
/** The response currently being spoken, once the service has named it. */
|
|
78
85
|
private activeResponseId;
|
|
79
86
|
/**
|
|
@@ -194,6 +201,11 @@ export declare class VoiceBridge {
|
|
|
194
201
|
* the moment the session goes live.
|
|
195
202
|
*/
|
|
196
203
|
primeAnswer(messageId: string | null): void;
|
|
204
|
+
/** A stable user-turn id while the agent runs; null on completion/cancellation. */
|
|
205
|
+
trackRelayRun(runId: string | null): void;
|
|
206
|
+
trackRelayActivity(activity: RelayActivity | null): void;
|
|
207
|
+
private scheduleInterim;
|
|
208
|
+
private clearInterim;
|
|
197
209
|
trackAnswer(messageId: string, text: string, isComplete: boolean): void;
|
|
198
210
|
/**
|
|
199
211
|
* Speak exact text through Voice Live without invoking its model.
|
|
@@ -62,6 +62,13 @@ export class VoiceBridge {
|
|
|
62
62
|
* stream, and speak the whole reply again. Forever.
|
|
63
63
|
*/
|
|
64
64
|
this.answerDone = false;
|
|
65
|
+
this.hasSpokenAnswer = false;
|
|
66
|
+
this.relayRunId = null;
|
|
67
|
+
this.interimTimer = null;
|
|
68
|
+
this.interimActivity = null;
|
|
69
|
+
this.interimSuppressed = false;
|
|
70
|
+
this.interimCount = 0;
|
|
71
|
+
this.interimTexts = new Set();
|
|
65
72
|
/** The response currently being spoken, once the service has named it. */
|
|
66
73
|
this.activeResponseId = null;
|
|
67
74
|
/**
|
|
@@ -266,6 +273,8 @@ export class VoiceBridge {
|
|
|
266
273
|
handleServerEvent(event) {
|
|
267
274
|
switch (event.type) {
|
|
268
275
|
case "input_audio_buffer.speech_started":
|
|
276
|
+
this.interimSuppressed = true;
|
|
277
|
+
this.clearInterim();
|
|
269
278
|
// Barge-in. Drop everything still queued to be spoken; the wire's
|
|
270
279
|
// in-flight response is cancelled by the service (`interrupt_response`),
|
|
271
280
|
// and cancelling locally flushes playback that is already buffered.
|
|
@@ -369,6 +378,8 @@ export class VoiceBridge {
|
|
|
369
378
|
* is exactly the pair of routes on which the transport dies mid-utterance.
|
|
370
379
|
*/
|
|
371
380
|
endCall() {
|
|
381
|
+
this.clearInterim();
|
|
382
|
+
this.relayRunId = null;
|
|
372
383
|
this.connected = false;
|
|
373
384
|
// Cleared here rather than on each exit path: the cancellation branch left
|
|
374
385
|
// it set, and a later `handleClosed()` would then act on the controls of a
|
|
@@ -384,6 +395,7 @@ export class VoiceBridge {
|
|
|
384
395
|
this.sentences.reset();
|
|
385
396
|
this.answerId = null;
|
|
386
397
|
this.answerDone = false;
|
|
398
|
+
this.hasSpokenAnswer = false;
|
|
387
399
|
}
|
|
388
400
|
// ---- relay: speak the agent's answer --------------------------------------
|
|
389
401
|
/**
|
|
@@ -402,12 +414,83 @@ export class VoiceBridge {
|
|
|
402
414
|
primeAnswer(messageId) {
|
|
403
415
|
this.skipAnswerId = messageId;
|
|
404
416
|
}
|
|
417
|
+
/** A stable user-turn id while the agent runs; null on completion/cancellation. */
|
|
418
|
+
trackRelayRun(runId) {
|
|
419
|
+
if (runId === this.relayRunId)
|
|
420
|
+
return;
|
|
421
|
+
this.clearInterim();
|
|
422
|
+
this.relayRunId = runId;
|
|
423
|
+
this.interimActivity = null;
|
|
424
|
+
this.interimSuppressed = false;
|
|
425
|
+
this.interimCount = 0;
|
|
426
|
+
this.interimTexts.clear();
|
|
427
|
+
const interim = this.config.relayInterimResponse;
|
|
428
|
+
if (!runId || !interim?.text.trim() || this.mode !== "relay" || !this.isLive)
|
|
429
|
+
return;
|
|
430
|
+
if (interim.skipInitialResponse && !this.hasSpokenAnswer)
|
|
431
|
+
return;
|
|
432
|
+
this.scheduleInterim(interim.delayMs ?? 15000);
|
|
433
|
+
}
|
|
434
|
+
trackRelayActivity(activity) {
|
|
435
|
+
this.interimActivity = activity;
|
|
436
|
+
const config = this.config.relayInterimResponse;
|
|
437
|
+
if (activity &&
|
|
438
|
+
config?.describeActivity &&
|
|
439
|
+
this.interimTimer === null &&
|
|
440
|
+
this.interimCount === 0 &&
|
|
441
|
+
this.relayRunId &&
|
|
442
|
+
!this.interimSuppressed &&
|
|
443
|
+
this.isLive &&
|
|
444
|
+
this.mode === "relay") {
|
|
445
|
+
// Real tool work can merit an update even in the opening turn; greetings cannot.
|
|
446
|
+
if (config.describeActivity(activity)?.trim())
|
|
447
|
+
this.scheduleInterim(config.delayMs ?? 15000);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
scheduleInterim(delay) {
|
|
451
|
+
if (!Number.isFinite(delay) || delay < 0)
|
|
452
|
+
return;
|
|
453
|
+
this.interimTimer = setTimeout(() => {
|
|
454
|
+
this.interimTimer = null;
|
|
455
|
+
const config = this.config.relayInterimResponse;
|
|
456
|
+
if (!config || !this.isLive || !this.relayRunId || this.interimSuppressed)
|
|
457
|
+
return;
|
|
458
|
+
const activityText = this.interimActivity
|
|
459
|
+
? config.describeActivity?.(this.interimActivity)
|
|
460
|
+
: null;
|
|
461
|
+
const text = activityText?.trim() ||
|
|
462
|
+
(this.interimCount === 0 && !(config.skipInitialResponse && !this.hasSpokenAnswer)
|
|
463
|
+
? config.text.trim()
|
|
464
|
+
: "");
|
|
465
|
+
if (text &&
|
|
466
|
+
!this.interimTexts.has(text) &&
|
|
467
|
+
!this.speech.isSpeaking &&
|
|
468
|
+
this.speech.pendingCount === 0) {
|
|
469
|
+
this.interimTexts.add(text);
|
|
470
|
+
this.interimCount++;
|
|
471
|
+
this.speech.enqueue(text);
|
|
472
|
+
}
|
|
473
|
+
if (config.describeActivity && this.interimCount < 3)
|
|
474
|
+
this.scheduleInterim(Math.max(10000, config.intervalMs ?? 18000));
|
|
475
|
+
}, delay);
|
|
476
|
+
}
|
|
477
|
+
clearInterim() {
|
|
478
|
+
if (this.interimTimer !== null)
|
|
479
|
+
clearTimeout(this.interimTimer);
|
|
480
|
+
this.interimTimer = null;
|
|
481
|
+
}
|
|
405
482
|
trackAnswer(messageId, text, isComplete) {
|
|
406
483
|
// The watcher is mounted for the whole app lifetime, but an answer is
|
|
407
484
|
// only spoken while a voice session is actually live — otherwise every
|
|
408
485
|
// typed conversation would queue speech at a disconnected transport.
|
|
409
486
|
if (this.mode !== "relay" || !this.isLive)
|
|
410
487
|
return;
|
|
488
|
+
// Short replies are buffered until their sentence/turn completes. Once
|
|
489
|
+
// answer text is arriving, a waiting message must not jump ahead of it.
|
|
490
|
+
if (text.trim()) {
|
|
491
|
+
this.interimSuppressed = true;
|
|
492
|
+
this.clearInterim();
|
|
493
|
+
}
|
|
411
494
|
if (messageId === this.skipAnswerId)
|
|
412
495
|
return;
|
|
413
496
|
if (this.answerId !== messageId) {
|
|
@@ -423,13 +506,15 @@ export class VoiceBridge {
|
|
|
423
506
|
// bracket, so it must not be held back — holding it at that point drops
|
|
424
507
|
// it, since `flush()` sees only what was pushed.
|
|
425
508
|
const speakable = toSpeakableText(text, { final: isComplete });
|
|
426
|
-
|
|
427
|
-
this.speech.enqueue(utterance);
|
|
509
|
+
const utterances = this.sentences.push(speakable);
|
|
428
510
|
if (isComplete) {
|
|
429
|
-
|
|
430
|
-
this.speech.enqueue(utterance);
|
|
511
|
+
utterances.push(...this.sentences.flush());
|
|
431
512
|
this.answerDone = true;
|
|
432
513
|
}
|
|
514
|
+
for (const utterance of utterances) {
|
|
515
|
+
this.hasSpokenAnswer = true;
|
|
516
|
+
this.speech.enqueue(utterance);
|
|
517
|
+
}
|
|
433
518
|
}
|
|
434
519
|
/**
|
|
435
520
|
* Speak exact text through Voice Live without invoking its model.
|
|
@@ -441,6 +526,7 @@ export class VoiceBridge {
|
|
|
441
526
|
* response gate as every other turn.
|
|
442
527
|
*/
|
|
443
528
|
sendSpokenText(text) {
|
|
529
|
+
this.clearInterim();
|
|
444
530
|
this.controls?.sendEvent({
|
|
445
531
|
type: "response.create",
|
|
446
532
|
response: {
|
package/dist/index.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
export { createVoiceModule, type VoiceChatModule } from "./react/voice-module.js";
|
|
15
15
|
export { FoundryVoice, installVoice, type VoiceInstallOptions, type VoiceInstallation, } from "./react/install.js";
|
|
16
|
-
export type { VoiceConfig, VoiceMode, VoiceTranscript } from "./adapter/types.js";
|
|
16
|
+
export type { RelayActivity, VoiceConfig, VoiceMode, VoiceTranscript } from "./adapter/types.js";
|
|
17
17
|
export { VoiceMicButton } from "./react/voice-mic-button.js";
|
|
18
18
|
export { VoiceStatusStrip } from "./react/voice-status-strip.js";
|
|
19
19
|
export { VoiceStage, type VoiceStageProps } from "./react/voice-stage.js";
|
|
@@ -12,12 +12,15 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { useEffect, useRef } from "react";
|
|
14
14
|
import { useAuiState, useVoiceState } from "@assistant-ui/react";
|
|
15
|
+
import { currentRelayActivity } from "../adapter/relay-activity.js";
|
|
15
16
|
export function RelayAnswerWatcher({ bridge }) {
|
|
16
17
|
// The array identity changes on every streamed token, which is exactly
|
|
17
18
|
// the update this component exists to observe. It renders nothing, so the
|
|
18
19
|
// re-render costs a comparison and a `useEffect`.
|
|
19
20
|
const messages = useAuiState((s) => s.thread.messages);
|
|
20
21
|
const status = useVoiceState()?.status.type;
|
|
22
|
+
const running = useAuiState((s) => s.thread.isRunning);
|
|
23
|
+
const userTurnId = [...messages].reverse().find((m) => m.role === "user")?.id ?? null;
|
|
21
24
|
// Whatever is on screen when the session goes live was answered before the
|
|
22
25
|
// user asked for voice. Reading it back at them is not a greeting, it is a
|
|
23
26
|
// non-sequitur.
|
|
@@ -29,6 +32,13 @@ export function RelayAnswerWatcher({ bridge }) {
|
|
|
29
32
|
const lastAssistant = [...latest.current].reverse().find((m) => m.role === "assistant");
|
|
30
33
|
bridge.primeAnswer(lastAssistant?.id ?? null);
|
|
31
34
|
}, [bridge, status]);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
bridge.trackRelayRun(status === "running" && running ? userTurnId : null);
|
|
37
|
+
return () => bridge.trackRelayRun(null);
|
|
38
|
+
}, [bridge, status, running, userTurnId]);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
bridge.trackRelayActivity(status === "running" && running ? currentRelayActivity(messages) : null);
|
|
41
|
+
}, [bridge, messages, status, running]);
|
|
32
42
|
useEffect(() => {
|
|
33
43
|
const last = messages[messages.length - 1];
|
|
34
44
|
if (!last || last.role !== "assistant")
|
|
@@ -35,6 +35,7 @@ export function VoiceStage(props = {}) {
|
|
|
35
35
|
const connectionState = useVoiceUiStore((s) => s.connectionState);
|
|
36
36
|
const userText = useVoiceUiStore((s) => s.userText);
|
|
37
37
|
const assistantText = useVoiceUiStore((s) => s.assistantText);
|
|
38
|
+
const transcriptRole = useVoiceUiStore((s) => s.transcriptRole);
|
|
38
39
|
const chromaKey = useVoiceUiStore((s) => s.chromaKey);
|
|
39
40
|
const connecting = !voice || voice.status.type === "starting";
|
|
40
41
|
const muted = voice?.isMuted ?? false;
|
|
@@ -49,9 +50,8 @@ export function VoiceStage(props = {}) {
|
|
|
49
50
|
: sessionState === "speaking"
|
|
50
51
|
? "Speaking"
|
|
51
52
|
: "Listening";
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
const caption = sessionState === "speaking" ? assistantText : userText;
|
|
53
|
+
// Pauses in playback do not change who last spoke.
|
|
54
|
+
const caption = transcriptRole === "assistant" ? assistantText : userText;
|
|
55
55
|
return (_jsxs("div", { className: "relative flex h-full min-h-full flex-col", children: [_jsx("div", { className: "relative min-h-0 flex-1 overflow-hidden", children: videoStream ? (
|
|
56
56
|
// Full-bleed and anchored to the top, so the avatar is framed head
|
|
57
57
|
// and torso with the legs running off the lower edge — the framing
|
|
@@ -26,7 +26,7 @@ import { VoiceStage } from "./voice-stage.js";
|
|
|
26
26
|
import { needsAudioOnlySink } from "./audio-ownership.js";
|
|
27
27
|
import { VoiceAudioSink } from "./voice-audio-sink.js";
|
|
28
28
|
import { useDirectAudioOutput } from "./use-direct-audio-output.js";
|
|
29
|
-
import { resetVoiceSessionState, useVoiceUiStore } from "./voice-ui-store.js";
|
|
29
|
+
import { resetVoiceSessionState, updateVoiceCaption, useVoiceUiStore } from "./voice-ui-store.js";
|
|
30
30
|
export function VoiceSurface({ bridge, config, avatar = true, stage = true }) {
|
|
31
31
|
const mode = config.mode ?? "relay";
|
|
32
32
|
const wantsTools = mode === "realtime" && (config.exposeClientTools ?? true);
|
|
@@ -38,11 +38,7 @@ export function VoiceSurface({ bridge, config, avatar = true, stage = true }) {
|
|
|
38
38
|
const onEvent = useCallback((event) => bridge.handleServerEvent(event), [bridge]);
|
|
39
39
|
const onTranscript = useCallback((role, text, isFinal) => {
|
|
40
40
|
bridge.handleTranscript(role, text, isFinal);
|
|
41
|
-
|
|
42
|
-
// is a VAD misfire and would blank the line for no reason.
|
|
43
|
-
if (text.trim() || !isFinal) {
|
|
44
|
-
useVoiceUiStore.setState(role === "user" ? { userText: text } : { assistantText: text });
|
|
45
|
-
}
|
|
41
|
+
updateVoiceCaption(role, text);
|
|
46
42
|
}, [bridge]);
|
|
47
43
|
const toolExecutor = useMemo(() => (wantsTools ? createRegistryToolExecutor(clientToolRegistry.getState()) : undefined), [wantsTools]);
|
|
48
44
|
const live = useVoiceLive({
|
|
@@ -30,6 +30,8 @@ interface VoiceUiState {
|
|
|
30
30
|
*/
|
|
31
31
|
userText: string;
|
|
32
32
|
assistantText: string;
|
|
33
|
+
/** Latest non-empty transcript's speaker; independent of playback pauses. */
|
|
34
|
+
transcriptRole: "user" | "assistant" | null;
|
|
33
35
|
/** Avatar streams, when the session was configured with one. */
|
|
34
36
|
videoStream: MediaStream | null;
|
|
35
37
|
audioStream: MediaStream | null;
|
|
@@ -53,6 +55,8 @@ export declare const useVoiceUiStore: import("zustand").UseBoundStore<import("zu
|
|
|
53
55
|
* definition, and visibly so on the stage.
|
|
54
56
|
*/
|
|
55
57
|
export declare function resetVoiceSessionState(): void;
|
|
58
|
+
/** Keep the last caption until somebody actually says something new. */
|
|
59
|
+
export declare function updateVoiceCaption(role: "user" | "assistant", text: string): void;
|
|
56
60
|
/** Called by `VoiceAvatarPanel` as it mounts and unmounts. */
|
|
57
61
|
export declare function trackAvatarPanel(delta: 1 | -1): void;
|
|
58
62
|
export {};
|
|
@@ -21,6 +21,7 @@ export const useVoiceUiStore = create(() => ({
|
|
|
21
21
|
analyser: null,
|
|
22
22
|
userText: "",
|
|
23
23
|
assistantText: "",
|
|
24
|
+
transcriptRole: null,
|
|
24
25
|
videoStream: null,
|
|
25
26
|
audioStream: null,
|
|
26
27
|
chromaKey: null,
|
|
@@ -35,11 +36,21 @@ export function resetVoiceSessionState() {
|
|
|
35
36
|
useVoiceUiStore.setState({
|
|
36
37
|
userText: "",
|
|
37
38
|
assistantText: "",
|
|
39
|
+
transcriptRole: null,
|
|
38
40
|
analyser: null,
|
|
39
41
|
videoStream: null,
|
|
40
42
|
audioStream: null,
|
|
41
43
|
});
|
|
42
44
|
}
|
|
45
|
+
/** Keep the last caption until somebody actually says something new. */
|
|
46
|
+
export function updateVoiceCaption(role, text) {
|
|
47
|
+
if (!text.trim())
|
|
48
|
+
return;
|
|
49
|
+
useVoiceUiStore.setState({
|
|
50
|
+
transcriptRole: role,
|
|
51
|
+
...(role === "user" ? { userText: text } : { assistantText: text }),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
43
54
|
/** Called by `VoiceAvatarPanel` as it mounts and unmounts. */
|
|
44
55
|
export function trackAvatarPanel(delta) {
|
|
45
56
|
useVoiceUiStore.setState((s) => ({ avatarPanels: Math.max(0, s.avatarPanels + delta) }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iloveagents/foundry-web-voice",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Optional spoken-conversation tier for Foundry UI — Azure Voice Live wired into assistant-ui's realtime voice contract",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"foundry",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@iloveagents/foundry-voice-live-react": "^0.5.0",
|
|
41
|
-
"@iloveagents/foundry-agent": "^0.
|
|
42
|
-
"@iloveagents/foundry-web-ui": "^0.
|
|
41
|
+
"@iloveagents/foundry-agent": "^0.33.0",
|
|
42
|
+
"@iloveagents/foundry-web-ui": "^0.33.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"@assistant-ui/react": "^0.15.1",
|