feat: voice assistant

This commit is contained in:
weishu
2026-01-19 18:16:57 +08:00
parent 53fff54c84
commit 8f5b773b23
25 changed files with 2091 additions and 24 deletions
+105
View File
@@ -15,6 +15,8 @@ import { createAttachmentAdapter } from '@/lib/attachmentAdapter'
import { SessionHeader } from '@/components/SessionHeader'
import { usePlatform } from '@/hooks/usePlatform'
import { useSessionActions } from '@/hooks/mutations/useSessionActions'
import { useVoiceOptional } from '@/lib/voice-context'
import { RealtimeVoiceSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime'
export function SessionChat(props: {
api: ApiClient
@@ -49,6 +51,96 @@ export function SessionChat(props: {
agentFlavor
)
// Voice assistant integration
const voice = useVoiceOptional()
// Register session store for voice client tools
useEffect(() => {
registerSessionStore({
getSession: () => props.session as { agentState?: { requests?: Record<string, unknown> } } | null,
sendMessage: (_sessionId: string, message: string) => props.onSend(message),
approvePermission: async (_sessionId: string, requestId: string) => {
await props.api.approvePermission(props.session.id, requestId)
props.onRefresh()
},
denyPermission: async (_sessionId: string, requestId: string) => {
await props.api.denyPermission(props.session.id, requestId)
props.onRefresh()
}
})
}, [props.session, props.api, props.onSend, props.onRefresh])
useEffect(() => {
registerVoiceHooksStore(
(sessionId) => (sessionId === props.session.id ? props.session : null),
(sessionId) => (sessionId === props.session.id ? props.messages : [])
)
}, [props.session, props.messages])
// Track and report new messages to voice assistant
// Note: voiceHooks internally checks isVoiceSessionStarted() so we don't need to check voice.status here
const prevMessagesRef = useRef<DecryptedMessage[]>([])
useEffect(() => {
const prevIds = new Set(prevMessagesRef.current.map(m => m.id))
const newMessages = props.messages.filter(m => !prevIds.has(m.id))
if (newMessages.length > 0) {
voiceHooks.onMessages(props.session.id, newMessages)
}
prevMessagesRef.current = props.messages
}, [props.messages, props.session.id])
// Report ready event when thinking stops
// Note: voiceHooks internally checks isVoiceSessionStarted() so we don't need to check voice.status here
const prevThinkingRef = useRef(props.session.thinking)
useEffect(() => {
// Detect transition: thinking → not thinking
if (prevThinkingRef.current && !props.session.thinking) {
voiceHooks.onReady(props.session.id)
}
prevThinkingRef.current = props.session.thinking
}, [props.session.thinking, props.session.id])
// Report permission requests to voice assistant
// Note: voiceHooks internally checks isVoiceSessionStarted() so we don't need to check voice.status here
const prevRequestIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
const requests = props.session.agentState?.requests ?? {}
const currentIds = new Set(Object.keys(requests))
for (const [requestId, request] of Object.entries(requests)) {
if (!prevRequestIdsRef.current.has(requestId)) {
voiceHooks.onPermissionRequested(
props.session.id,
requestId,
(request as { tool?: string }).tool ?? 'unknown',
(request as { arguments?: unknown }).arguments
)
}
}
prevRequestIdsRef.current = currentIds
}, [props.session.agentState?.requests, props.session.id])
const handleVoiceToggle = useCallback(async () => {
if (!voice) return
if (voice.status === 'connected' || voice.status === 'connecting') {
await voice.stopVoice()
} else {
await voice.startVoice(props.session.id)
}
}, [voice, props.session.id])
const handleVoiceMicToggle = useCallback(() => {
if (!voice) return
voice.toggleMic()
}, [voice])
useEffect(() => {
normalizedCacheRef.current.clear()
blocksByIdRef.current.clear()
@@ -218,9 +310,22 @@ export function SessionChat(props: {
onSwitchToRemote={handleSwitchToRemote}
onTerminal={props.session.active ? handleViewTerminal : undefined}
autocompleteSuggestions={props.autocompleteSuggestions}
voiceStatus={voice?.status}
voiceMicMuted={voice?.micMuted}
onVoiceToggle={voice ? handleVoiceToggle : undefined}
onVoiceMicToggle={voice ? handleVoiceMicToggle : undefined}
/>
</div>
</AssistantRuntimeProvider>
{/* Voice session component - renders nothing but initializes ElevenLabs */}
{voice && (
<RealtimeVoiceSession
api={props.api}
micMuted={voice.micMuted}
onStatusChange={voice.setStatus}
/>
)}
</div>
)
}