UNPKG

ai

Version:

AI SDK by Vercel - build apps like ChatGPT, Claude, Gemini, and more with a single interface for any model using the Vercel AI Gateway or go direct to OpenAI, Anthropic, Google, or any other model provider.

524 lines (460 loc) • 13 kB
import { safeParseJSON } from '@ai-sdk/provider-utils'; import type { RealtimeServerEvent } from '../types/realtime-model'; import type { DynamicToolUIPart, TextUIPart, UIMessage, } from '../ui/ui-messages'; export type RealtimeStatus = | 'disconnected' | 'connecting' | 'connected' | 'error'; export interface RealtimeState { status: RealtimeStatus; messages: UIMessage[]; events: RealtimeServerEvent[]; isCapturing: boolean; isPlaying: boolean; } export type RealtimeReducerEffect = | { type: 'play-audio'; itemId: string; delta: string; } | { type: 'speech-started'; } | { type: 'tool-call'; callId: string; name: string; args: Record<string, unknown>; rawArguments: string; } | { type: 'error'; error: Error; }; export type RealtimeToolOutput = { callId: string; name?: string; output: string; }; export function createInitialRealtimeState(): RealtimeState { return { status: 'disconnected', messages: [], events: [], isCapturing: false, isPlaying: false, }; } export class RealtimeEventReducer { private currentAssistantMessageId: string | null = null; private textAccumulators = new Map<string, string>(); private toolArgAccumulators = new Map<string, string>(); private toolCallIdToMessageId = new Map<string, string>(); private toolCallIdToName = new Map<string, string>(); private inputAudioMessageInsertIndex = new Map<string, number>(); private itemIdToPartLocation = new Map< string, { messageId: string; partIndex: number } >(); constructor(private readonly maxEvents = 500) {} setStatus(state: RealtimeState, status: RealtimeStatus): RealtimeState { return { ...state, status }; } setCapturing(state: RealtimeState, isCapturing: boolean): RealtimeState { return { ...state, isCapturing }; } setPlaying(state: RealtimeState, isPlaying: boolean): RealtimeState { return { ...state, isPlaying }; } addUserTextMessage(state: RealtimeState, text: string): RealtimeState { return { ...state, messages: [ ...state.messages, { id: `user-${Date.now()}`, role: 'user', parts: [{ type: 'text', text, state: 'done' } as TextUIPart], }, ], }; } addToolOutput( state: RealtimeState, callId: string, result: unknown, ): { state: RealtimeState; output: RealtimeToolOutput } { return { state: this.updateToolPartState(state, callId, result), output: { callId, name: this.toolCallIdToName.get(callId), output: JSON.stringify(result), }, }; } async reduceServerEvent( state: RealtimeState, event: RealtimeServerEvent, ): Promise<{ state: RealtimeState; effects: RealtimeReducerEffect[] }> { let nextState = this.pushEvent(state, event); const effects: RealtimeReducerEffect[] = []; switch (event.type) { case 'session-created': case 'session-updated': { if (nextState.status === 'connecting') { nextState = this.setStatus(nextState, 'connected'); } break; } case 'audio-delta': { effects.push({ type: 'play-audio', itemId: event.itemId, delta: event.delta, }); break; } case 'audio-committed': { if (event.itemId != null) { this.inputAudioMessageInsertIndex.set( event.itemId, nextState.messages.length, ); } break; } case 'audio-transcript-delta': case 'text-delta': { nextState = this.appendTextDelta(nextState, event.itemId, event.delta); break; } case 'audio-transcript-done': { nextState = this.finalizeText( nextState, event.itemId, event.transcript, ); break; } case 'text-done': { nextState = this.finalizeText(nextState, event.itemId, event.text); break; } case 'input-transcription-completed': { nextState = this.addInputTranscriptionMessage( nextState, event.itemId, event.transcript, ); break; } case 'response-created': case 'response-done': { this.currentAssistantMessageId = null; break; } case 'speech-started': { this.currentAssistantMessageId = null; effects.push({ type: 'speech-started' }); break; } case 'function-call-arguments-delta': { const { state: updatedState, messageId } = this.getOrCreateAssistantMessage(nextState); nextState = updatedState; this.toolCallIdToMessageId.set(event.callId, messageId); const acc = this.toolArgAccumulators.get(event.callId) ?? ''; this.toolArgAccumulators.set(event.callId, acc + event.delta); nextState = this.ensureToolPart(nextState, messageId, event.callId); break; } case 'function-call-arguments-done': { this.toolArgAccumulators.delete(event.callId); this.toolCallIdToName.set(event.callId, event.name); const parseResult = await safeParseJSON({ text: event.arguments }); const parsedInput = parseResult.success ? (parseResult.value as Record<string, unknown>) : {}; const messageId = this.toolCallIdToMessageId.get(event.callId); if (messageId != null) { nextState = this.markToolInputAvailable( nextState, messageId, event.callId, event.name, parsedInput, ); } if (!parseResult.success) { effects.push({ type: 'error', error: new Error( `Failed to parse tool arguments: ${event.arguments}`, ), }); } else { effects.push({ type: 'tool-call', callId: event.callId, name: event.name, args: parsedInput, rawArguments: event.arguments, }); } break; } case 'error': { effects.push({ type: 'error', error: new Error(event.message) }); break; } } return { state: nextState, effects }; } private pushEvent( state: RealtimeState, event: RealtimeServerEvent, ): RealtimeState { const events = [...state.events, event]; return { ...state, events: events.length > this.maxEvents ? events.slice(-this.maxEvents) : events, }; } private getOrCreateAssistantMessage(state: RealtimeState): { state: RealtimeState; messageId: string; } { if (this.currentAssistantMessageId != null) { return { state, messageId: this.currentAssistantMessageId }; } const messageId = `assistant-${Date.now()}-${Math.random() .toString(36) .slice(2, 6)}`; this.currentAssistantMessageId = messageId; return { state: { ...state, messages: [ ...state.messages, { id: messageId, role: 'assistant', parts: [], }, ], }, messageId, }; } private addInputTranscriptionMessage( state: RealtimeState, itemId: string, transcript: string, ): RealtimeState { const messageId = `user-${itemId}`; const existingMessage = state.messages.find( message => message.id === messageId, ); if (existingMessage != null) { return { ...state, messages: state.messages.map(message => message.id === messageId ? { ...message, parts: [ { type: 'text', text: transcript, state: 'done', } as TextUIPart, ], } : message, ), }; } const insertIndex = Math.min( this.inputAudioMessageInsertIndex.get(itemId) ?? state.messages.length, state.messages.length, ); const messages = [...state.messages]; messages.splice(insertIndex, 0, { id: messageId, role: 'user', parts: [ { type: 'text', text: transcript, state: 'done', } as TextUIPart, ], }); return { ...state, messages, }; } private appendTextDelta( state: RealtimeState, itemId: string, delta: string, ): RealtimeState { const { state: stateWithMessage, messageId } = this.getOrCreateAssistantMessage(state); const acc = this.textAccumulators.get(itemId) ?? ''; const text = acc + delta; this.textAccumulators.set(itemId, text); const location = this.itemIdToPartLocation.get(itemId); if (location != null) { return this.updateMessagePart( stateWithMessage, location.messageId, location.partIndex, { type: 'text', text, state: 'streaming' } as TextUIPart, ); } return { ...stateWithMessage, messages: stateWithMessage.messages.map(message => { if (message.id !== messageId) return message; const partIndex = message.parts.length; this.itemIdToPartLocation.set(itemId, { messageId, partIndex }); return { ...message, parts: [ ...message.parts, { type: 'text', text, state: 'streaming' } as TextUIPart, ], }; }), }; } private finalizeText( state: RealtimeState, itemId: string, finalText?: string, ): RealtimeState { const text = finalText ?? this.textAccumulators.get(itemId) ?? ''; this.textAccumulators.delete(itemId); const location = this.itemIdToPartLocation.get(itemId); if (location == null) return state; this.itemIdToPartLocation.delete(itemId); return this.updateMessagePart( state, location.messageId, location.partIndex, { type: 'text', text, state: 'done' } as TextUIPart, ); } private ensureToolPart( state: RealtimeState, messageId: string, callId: string, ): RealtimeState { return { ...state, messages: state.messages.map(message => { if (message.id !== messageId) return message; const existingPart = message.parts.find( part => part.type === 'dynamic-tool' && (part as DynamicToolUIPart).toolCallId === callId, ); if (existingPart != null) return message; return { ...message, parts: [ ...message.parts, { type: 'dynamic-tool', toolName: '', toolCallId: callId, state: 'input-streaming', input: undefined, } as DynamicToolUIPart, ], }; }), }; } private markToolInputAvailable( state: RealtimeState, messageId: string, callId: string, name: string, input: Record<string, unknown>, ): RealtimeState { return { ...state, messages: state.messages.map(message => { if (message.id !== messageId) return message; return { ...message, parts: message.parts.map(part => { if (part.type !== 'dynamic-tool') return part; const toolPart = part as DynamicToolUIPart; if (toolPart.toolCallId !== callId) return part; return { ...toolPart, toolName: name, state: 'input-available', input, } as DynamicToolUIPart; }), }; }), }; } private updateToolPartState( state: RealtimeState, callId: string, result: unknown, ): RealtimeState { const messageId = this.toolCallIdToMessageId.get(callId); if (messageId == null) return state; return { ...state, messages: state.messages.map(message => { if (message.id !== messageId) return message; return { ...message, parts: message.parts.map(part => { if (part.type !== 'dynamic-tool') return part; const toolPart = part as DynamicToolUIPart; if (toolPart.toolCallId !== callId) return part; return { ...toolPart, state: 'output-available', output: result, } as DynamicToolUIPart; }), }; }), }; } private updateMessagePart( state: RealtimeState, messageId: string, partIndex: number, part: TextUIPart, ): RealtimeState { return { ...state, messages: state.messages.map(message => { if (message.id !== messageId) return message; const parts = [...message.parts]; parts[partIndex] = part; return { ...message, parts }; }), }; } }