mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
fix(eve): track realtime in-flight state for all responses; fix voice itemId
Set responseInFlightRef on every realtime response-created, not only ones solicited via speak(), so a server-VAD auto-response suppresses user-transcript enqueuing for its lifetime and its echoed audio can't start a spurious Eve turn. Removes the now-redundant expectSpeechResponseRef. Thread each utterance's itemId through the turn queue so onTranscript reports the correct item when multiple transcripts finalize before the queue drains (was reading a shared latest-id ref). Docs: scope the channel explicitly to Vercel AI Gateway (eve/channels/vercel/speech), document the shipped browser<->Gateway audio / browser<->Eve durable-turn topology, note the future Gateway<->Eve WS control plane as roadmap only, add the sessionConfig.instructions caveat, and fix stale gallery copy that referenced the removed /turn route.
This commit is contained in:
@@ -334,7 +334,7 @@ export function ComposerActions() {
|
||||
return <button onClick={() => (active ? voice.stop() : void voice.start())}>Talk</button>;
|
||||
}
|
||||
\`\`\``,
|
||||
configure: `Set \`AI_GATEWAY_API_KEY\` so the setup route can mint short-lived AI Gateway realtime client secrets. The browser keeps the realtime audio socket open, while each finalized utterance calls Eve's turn route. Eve binds the voice session id to the authenticated principal before deriving the durable continuation token.`,
|
||||
configure: `Set \`AI_GATEWAY_API_KEY\` so the setup route can mint short-lived AI Gateway realtime client secrets. The browser keeps the realtime audio socket open, while each finalized utterance runs as an ordinary durable turn through the existing \`/eve/v1/session\` routes and event stream. The voice session id is a client-visible correlation id only; principal binding comes from normal session-route auth.`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
---
|
||||
title: "Realtime Speech"
|
||||
description: "Add long-lived browser speech sessions that call your Eve agent through normal durable turns."
|
||||
title: "Realtime Speech (Vercel AI Gateway)"
|
||||
description: "Add a browser microphone backed by Vercel AI Gateway realtime audio; finalized utterances run as normal durable Eve turns."
|
||||
---
|
||||
|
||||
Realtime Speech adds a microphone surface for agents without turning Eve into a long-running audio runtime. The browser keeps a realtime audio WebSocket open to AI Gateway. Eve serves a setup route that authenticates the caller and mints a short-lived Gateway client secret, then maps each finalized utterance into one normal durable agent turn through the existing session API.
|
||||
Realtime Speech adds a microphone surface for agents without turning Eve into a long-running audio runtime.
|
||||
|
||||
This channel is specific to **Vercel AI Gateway** realtime audio and is exported from `eve/channels/vercel/speech`. It is not a provider-agnostic speech API. The shipped topology is:
|
||||
|
||||
- **Audio:** browser ↔ AI Gateway realtime WebSocket (Eve is never in the audio path).
|
||||
- **Turns:** browser/app ↔ Eve `/eve/v1/session` (+ `/stream`) as ordinary durable turns.
|
||||
|
||||
Eve serves a setup route that authenticates the caller and mints a short-lived Gateway client secret for the browser audio socket; each finalized utterance then becomes one normal durable agent turn through the existing session API.
|
||||
|
||||
`useEveVoice` is audio-first by default: it requests audio output from the realtime model and uses transcription events for visible text. Override `sessionConfig.outputModalities` only if you intentionally want a text-only or provider-specific realtime mode.
|
||||
|
||||
@@ -75,7 +82,7 @@ console.log(reply.message);
|
||||
- The client consumes the session event stream (`GET /eve/v1/session/:sessionId/stream`) and, on a non-tool-call `message.completed`, sends that text back to the realtime session for audio playback.
|
||||
- The durable continuation token returned by the session route keeps the same Eve conversation across utterances, advancing the stream cursor each turn.
|
||||
|
||||
No Eve HTTP request blocks for a full model turn: the setup and turn POSTs return right away, and replies arrive over the event stream. The speech transport can stay open for many utterances. Eve still sees discrete durable turns and parks between them, so history, tools, compaction, auth, and instrumentation behave the same as other channels.
|
||||
No Eve HTTP request blocks for a full model turn: the setup POST and each session turn POST return right away, and replies arrive over the event stream. The speech transport can stay open for many utterances. Eve still sees discrete durable turns and parks between them, so history, tools, compaction, auth, and instrumentation behave the same as other channels.
|
||||
|
||||
Eve is the durable source of truth for the conversation: transcripts enter Eve as user turns, and spoken replies are readback of Eve output. Realtime provider suppression is still client/provider mediated, so do not treat the realtime model as a security boundary for policy decisions.
|
||||
|
||||
@@ -92,3 +99,9 @@ export default realtimeSpeechChannel({
|
||||
`useEveVoice` defaults to the `/eve/v1/realtime-speech/setup` route and same-origin `/eve/v1/session` routes. Override `setupUrl` only if you changed the channel `basePath`, and pass `host`, `auth`, `headers`, `client`, or `session` to run turns against a custom origin or a session you already manage (for example one shared with `useEveAgent`).
|
||||
|
||||
The default client session config uses `outputModalities: ["audio"]`, `inputAudioTranscription: {}`, and `outputAudioTranscription: {}`. This keeps the speech UX compatible with realtime providers that reject mixed `audio` + `text` output modalities while still letting the UI observe transcripts.
|
||||
|
||||
`sessionConfig` is merged over the defaults, so passing `instructions` replaces the built-in speech-adapter prompt that drives reply playback (it tells the model to speak only the text after the `EVE_SPEAK:` marker). If you override `instructions`, keep that marker behavior or Eve's replies will not be spoken.
|
||||
|
||||
## Roadmap
|
||||
|
||||
In the shipped mode above, the client (or app) owns turn timing. A future **server-owned control mode** is planned but **not yet available**: AI Gateway will dial an Eve `WS()` control route with a signed, short-lived control token. Eve will verify the token and own turn coordination (debounce, barge-in, enforced ears-only), receiving final-transcript and lifecycle packets and streaming `response.delta` / `response.done` back as semantic packets for Gateway to inject into the provider's TTS. Audio still never flows through Eve. Until that ships, use the durable-session path described here.
|
||||
|
||||
@@ -240,6 +240,73 @@ describe("useEveVoice", () => {
|
||||
expect(realtimeState.requestResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses transcripts during an unsolicited model response", async () => {
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
const { useEveVoice } = await import("#react/voice.js");
|
||||
|
||||
function TestComponent() {
|
||||
useEveVoice({ voiceSessionId: "voice-1" });
|
||||
return null;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
create(createElement(TestComponent));
|
||||
});
|
||||
|
||||
// A server-VAD auto-response we never solicited still marks a response in
|
||||
// flight, so its echoed-audio transcript must not start an Eve turn.
|
||||
realtimeOptions[0].onEvent({ raw: {}, responseId: "auto-1", type: "response-created" });
|
||||
realtimeOptions[0].onEvent({
|
||||
itemId: "echo-1",
|
||||
raw: {},
|
||||
transcript: "model echo",
|
||||
type: "input-transcription-completed",
|
||||
});
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes each transcript's own itemId to onTranscript", async () => {
|
||||
const { useEveVoice } = await import("#react/voice.js");
|
||||
const seen: Array<{ itemId: string; transcript: string }> = [];
|
||||
|
||||
function TestComponent() {
|
||||
useEveVoice({
|
||||
voiceSessionId: "voice-1",
|
||||
onTranscript: ({ itemId, transcript }) => {
|
||||
seen.push({ itemId, transcript });
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
create(createElement(TestComponent));
|
||||
});
|
||||
|
||||
// Both finalize before the serialized turn queue drains; each turn must
|
||||
// report the itemId captured at enqueue time, not the latest one.
|
||||
realtimeOptions[0].onEvent({
|
||||
itemId: "item-1",
|
||||
raw: {},
|
||||
transcript: "first",
|
||||
type: "input-transcription-completed",
|
||||
});
|
||||
realtimeOptions[0].onEvent({
|
||||
itemId: "item-2",
|
||||
raw: {},
|
||||
transcript: "second",
|
||||
type: "input-transcription-completed",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(seen).toHaveLength(2));
|
||||
expect(seen).toEqual([
|
||||
{ itemId: "item-1", transcript: "first" },
|
||||
{ itemId: "item-2", transcript: "second" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("suppresses transcriptions that arrive while the Eve reply is speaking", async () => {
|
||||
const fetch = sessionFetchMock([
|
||||
{ sessionId: "session-1", events: [completedMessageEvent("Agent reply"), waiting()] },
|
||||
|
||||
@@ -252,7 +252,6 @@ export function useEveVoice(options: UseEveVoiceOptions = {}): UseEveVoiceResult
|
||||
const [lastReply, setLastReply] = useState<string | undefined>(undefined);
|
||||
const [sessionId, setSessionId] = useState<string | undefined>(session.state.sessionId);
|
||||
const [streamIndex, setStreamIndex] = useState(session.state.streamIndex);
|
||||
const expectSpeechResponseRef = useRef(false);
|
||||
const ignoreInputUntilRef = useRef(0);
|
||||
const processedInputItemsRef = useRef(new Set<string>());
|
||||
const requestResponseRef = useRef<((options?: { modalities?: string[] }) => void) | undefined>(
|
||||
@@ -305,7 +304,6 @@ export function useEveVoice(options: UseEveVoiceOptions = {}): UseEveVoiceResult
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length === 0) return;
|
||||
|
||||
expectSpeechResponseRef.current = true;
|
||||
sendEventRef.current?.({
|
||||
type: "conversation-item-create",
|
||||
item: {
|
||||
@@ -325,10 +323,10 @@ export function useEveVoice(options: UseEveVoiceOptions = {}): UseEveVoiceResult
|
||||
}, [options.fallbackReply, speakEveReply]);
|
||||
|
||||
const runEveTurn = useCallback(
|
||||
async (message: string) => {
|
||||
async (message: string, itemId: string) => {
|
||||
if (options.onTranscript !== undefined) {
|
||||
const reply = await options.onTranscript({
|
||||
itemId: latestInputItemIdRef.current ?? "",
|
||||
itemId,
|
||||
transcript: message,
|
||||
voiceSessionId,
|
||||
});
|
||||
@@ -400,12 +398,11 @@ export function useEveVoice(options: UseEveVoiceOptions = {}): UseEveVoiceResult
|
||||
);
|
||||
|
||||
const turnQueueRef = useRef(Promise.resolve());
|
||||
const latestInputItemIdRef = useRef<string | undefined>(undefined);
|
||||
const enqueueEveTurn = useCallback(
|
||||
(message: string) => {
|
||||
(message: string, itemId: string) => {
|
||||
turnQueueRef.current = turnQueueRef.current
|
||||
.catch(() => undefined)
|
||||
.then(() => runEveTurn(message))
|
||||
.then(() => runEveTurn(message, itemId))
|
||||
.catch((cause) => {
|
||||
const nextError = cause instanceof Error ? cause : new Error(String(cause));
|
||||
handleError(nextError);
|
||||
@@ -418,16 +415,15 @@ export function useEveVoice(options: UseEveVoiceOptions = {}): UseEveVoiceResult
|
||||
(event: Experimental_RealtimeServerEvent) => {
|
||||
switch (event.type) {
|
||||
case "response-created":
|
||||
if (!expectSpeechResponseRef.current) {
|
||||
break;
|
||||
}
|
||||
expectSpeechResponseRef.current = false;
|
||||
// Suppress user transcripts for the lifetime of ANY model response,
|
||||
// not only ones solicited via speak(). A server-VAD auto-response
|
||||
// would otherwise play with no in-flight flag set, and its own audio
|
||||
// could be transcribed back and enqueued as a spurious user turn.
|
||||
responseInFlightRef.current = true;
|
||||
break;
|
||||
case "response-done":
|
||||
case "error":
|
||||
responseInFlightRef.current = false;
|
||||
expectSpeechResponseRef.current = false;
|
||||
ignoreInputUntilRef.current = Date.now() + ECHO_SUPPRESSION_MS;
|
||||
break;
|
||||
case "speech-started":
|
||||
@@ -447,7 +443,6 @@ export function useEveVoice(options: UseEveVoiceOptions = {}): UseEveVoiceResult
|
||||
const oldest = processedInputItemsRef.current.values().next().value;
|
||||
if (oldest !== undefined) processedInputItemsRef.current.delete(oldest);
|
||||
}
|
||||
latestInputItemIdRef.current = event.itemId;
|
||||
const transcript = event.transcript.trim();
|
||||
if (transcript.length === 0) {
|
||||
break;
|
||||
@@ -455,7 +450,7 @@ export function useEveVoice(options: UseEveVoiceOptions = {}): UseEveVoiceResult
|
||||
if (responseInFlightRef.current || Date.now() < ignoreInputUntilRef.current) {
|
||||
break;
|
||||
}
|
||||
enqueueEveTurn(transcript);
|
||||
enqueueEveTurn(transcript, event.itemId);
|
||||
break;
|
||||
}
|
||||
options.onEvent?.(event as EveVoiceEvent);
|
||||
@@ -481,7 +476,6 @@ export function useEveVoice(options: UseEveVoiceOptions = {}): UseEveVoiceResult
|
||||
realtime.stopAudioCapture();
|
||||
realtime.stopPlayback();
|
||||
realtime.disconnect();
|
||||
expectSpeechResponseRef.current = false;
|
||||
ignoreInputUntilRef.current = 0;
|
||||
processedInputItemsRef.current.clear();
|
||||
responseInFlightRef.current = false;
|
||||
|
||||
Reference in New Issue
Block a user