import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { File, Paths } from 'expo-file-system'; import { useCallback, useEffect, useRef, useState } from 'react'; import { AppState, type AppStateStatus } from 'react-native'; import type { WsConnectionState } from '@/core/ws/types'; import { conversationApi } from './api'; import { acquireBackgroundConversationWs, disposeAllBackgroundConversationWs, disposeBackgroundConversationWs, releaseConversationWsUi, } from './conversation-ws-background-pool'; import { conversationMessagesRepository } from './conversation-messages-repository'; import { conversationKeys } from './query-keys'; import { takePreparedRealtimeSession } from './prepared-session-registry'; import { type ErrorCallback, type StreamingTextCallback, type TtsSegmentPayload, type RealtimeSession, } from './realtime-session'; import { type ConversationListItem, type MessageItem, type StreamingAgentMessage, } from './types'; import { voiceSegmentStore } from '@/features/voice/voice-segment-store'; /** Expo `File` 需要规范 `file://` URI;部分录音 API 会返回裸绝对路径。 */ function ensureFileUri(uri: string): string { const u = uri.trim(); if (u.startsWith('file://')) return u; if (u.startsWith('/')) return `file://${u}`; return u; } function guessAudioExtension(uri: string): string { const pathOnly = uri.split('?')[0] ?? uri; const m = /\.[^/.]+$/u.exec(pathOnly); return m ? m[0] : '.m4a'; } /** * 使用主包 `File`/`Paths`(见 Expo 文档:新 File 与旧 readAsStringAsync 互操作示例)。 * 先 copy 到 cache 下唯一文件名再 `base64()`,避免直接读源路径时偶发读到陈旧/错误内容。 */ async function readRecordingPayload(uri: string): Promise { const resolved = ensureFileUri(uri); const source = new File(resolved); if (!source.exists) { throw new Error('recording file missing'); } const stagedName = `voice-upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}${guessAudioExtension(resolved)}`; const staged = new File(Paths.cache, stagedName); try { source.copy(staged); } catch { return await source.base64(); } try { return await staged.base64(); } finally { try { staged.delete(); } catch { // ignore } } } // ─── Query hooks ─── // TODO: 连接不上后端时 isLoading 可能一直为 true,需加超时或展示错误态 export function useConversations() { return useQuery({ queryKey: conversationKeys.lists(), queryFn: () => conversationApi.list(), }); } export function useConversationDetail(conversationId: string) { return useQuery({ queryKey: conversationKeys.detail(conversationId), queryFn: () => conversationApi.detail(conversationId), enabled: !!conversationId, }); } export function useMessages(conversationId: string) { return useQuery({ queryKey: conversationKeys.messages(conversationId), queryFn: () => conversationMessagesRepository.loadMessages(conversationId), enabled: !!conversationId, }); } // ─── Mutation hooks ─── export function useCreateConversation() { const queryClient = useQueryClient(); return useMutation({ mutationFn: () => conversationApi.create(), onSuccess: (newConversation) => { queryClient.setQueryData( conversationKeys.lists(), (old) => { const startedMs = Date.parse(newConversation.started_at); const now = Date.now(); const item: ConversationListItem = { id: newConversation.id, title: '岁月知己', avatarUrl: null, latestMessagePreview: '', latestMessageTime: now, startedAt: Number.isFinite(startedMs) ? startedMs : now, unreadCount: 0, isDefaultAssistant: true, hasUserMessage: false, }; return [item, ...(old ?? [])]; }, ); }, }); } export function useDeleteConversation() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (conversationId: string) => conversationApi.delete(conversationId), onSuccess: async (_, conversationId) => { disposeBackgroundConversationWs(conversationId); await voiceSegmentStore.clearConversation(conversationId); queryClient.setQueryData( conversationKeys.lists(), (old) => old?.filter((item) => item.id !== conversationId), ); queryClient.removeQueries({ queryKey: conversationKeys.detail(conversationId), }); queryClient.removeQueries({ queryKey: conversationKeys.messages(conversationId), }); }, }); } export function useEndConversation() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (conversationId: string) => conversationApi.end(conversationId), onSuccess: (_, conversationId) => { queryClient.invalidateQueries({ queryKey: conversationKeys.detail(conversationId), }); queryClient.invalidateQueries({ queryKey: conversationKeys.lists(), }); }, }); } // ─── Realtime session hook ─── interface UseRealtimeSessionOptions { conversationId: string; enabled?: boolean; onTtsSegment?: (payload: TtsSegmentPayload) => void; /** 用户发出下一条文本/语音成功后调用,用于恢复接受 TTS 片段(打断后丢弃迟到片段) */ onTtsPlaybackResume?: () => void; /** 本条发送是否请求了「本轮助手朗读」,用于仅在该轮自动播放 WS TTS */ onUserSendTtsPreference?: (requestedTts: boolean) => void; } const MIN_RECORDING_DURATION_SEC = 1; function generateUUID(): string { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } interface RealtimeSessionState { connectionState: WsConnectionState; streamingMessage: StreamingAgentMessage | null; /** 已发出用户消息,尚未收到助手首段流式文本(用于「正在回复」气泡) */ awaitingAssistantReply: boolean; error: string | null; sendText: (text: string, options?: { ttsThisTurn?: boolean }) => void; sendVoiceMessage: ( uri: string, durationMs: number, options?: { ttsThisTurn?: boolean }, ) => Promise; sendEndConversation: () => void; sendTtsCancel: () => void; requestAssistantSegmentTts: (body: { assistantMessageId: string; segmentIndex: number; segmentText?: string; }) => boolean; } export function useRealtimeSession({ conversationId, enabled = true, onTtsSegment, onTtsPlaybackResume, onUserSendTtsPreference, }: UseRealtimeSessionOptions): RealtimeSessionState { const queryClient = useQueryClient(); const sessionRef = useRef(null); const uiRef = useRef({ handleStreamingText: (() => {}) as StreamingTextCallback, handleError: (() => {}) as ErrorCallback, onTtsSegment: undefined as | ((payload: TtsSegmentPayload) => void) | undefined, }); const [connectionState, setConnectionState] = useState('disconnected'); const [streamingMessage, setStreamingMessage] = useState(null); const [awaitingAssistantReply, setAwaitingAssistantReply] = useState(false); const [error, setError] = useState(null); const [foregroundResumeGeneration, setForegroundResumeGeneration] = useState(0); const needsResumeAfterBackgroundRef = useRef(false); const handleStreamingText: StreamingTextCallback = useCallback( (text, isComplete) => { if (text.trim().length > 0) { setAwaitingAssistantReply(false); } if (isComplete) { setStreamingMessage(null); setAwaitingAssistantReply(false); return; } setStreamingMessage({ text, isComplete }); }, [], ); const handleError: ErrorCallback = useCallback((message) => { setAwaitingAssistantReply(false); setError(message); }, []); uiRef.current.handleStreamingText = handleStreamingText; uiRef.current.handleError = handleError; uiRef.current.onTtsSegment = onTtsSegment; useEffect(() => { if (!enabled || !conversationId) return; const sub = AppState.addEventListener('change', (next: AppStateStatus) => { if (next === 'background') { needsResumeAfterBackgroundRef.current = true; disposeAllBackgroundConversationWs(); sessionRef.current = null; setConnectionState('disconnected'); setStreamingMessage(null); setAwaitingAssistantReply(false); } else if (next === 'active' && needsResumeAfterBackgroundRef.current) { needsResumeAfterBackgroundRef.current = false; setForegroundResumeGeneration((g) => g + 1); } }); return () => sub.remove(); }, [enabled, conversationId]); useEffect(() => { if (!enabled || !conversationId) return; const prepared = takePreparedRealtimeSession(conversationId); const session = acquireBackgroundConversationWs( conversationId, queryClient, prepared, ); session.attachUiCallbacks({ onStreamingText: (text, isComplete) => { uiRef.current.handleStreamingText(text, isComplete); }, onTtsSegment: (payload) => uiRef.current.onTtsSegment?.(payload), onError: (message, code) => uiRef.current.handleError(message, code), onStateChange: setConnectionState, }); sessionRef.current = session; setConnectionState(session.getConnectionState()); return () => { releaseConversationWsUi(session); sessionRef.current = null; setConnectionState('disconnected'); setStreamingMessage(null); setAwaitingAssistantReply(false); }; }, [conversationId, enabled, queryClient, foregroundResumeGeneration]); const sendText = useCallback( (text: string, options?: { ttsThisTurn?: boolean }) => { if (!sessionRef.current) return; const sent = sessionRef.current.sendText(text, options); if (!sent) { setError('消息发送失败,连接未就绪'); return; } onUserSendTtsPreference?.(options?.ttsThisTurn === true); setAwaitingAssistantReply(true); onTtsPlaybackResume?.(); const localId = `pending_${Date.now()}`; queryClient.setQueryData( conversationKeys.messages(conversationId), (old) => { const msg: MessageItem = { id: localId, conversationId, content: text, senderType: 'user', timestamp: Date.now(), messageType: 'text', }; return [...(old ?? []), msg]; }, ); queryClient.setQueryData( conversationKeys.lists(), (old) => { if (!old) return old; return old.map((item) => item.id === conversationId ? { ...item, hasUserMessage: true } : item, ); }, ); }, [conversationId, queryClient, onTtsPlaybackResume, onUserSendTtsPreference], ); const sendVoiceMessage = useCallback( async ( uri: string, durationMs: number, options?: { ttsThisTurn?: boolean }, ): Promise => { const session = sessionRef.current; if (!session) return false; const durationSec = Math.round(durationMs / 1000); if (durationSec < MIN_RECORDING_DURATION_SEC) return false; try { const base64 = await readRecordingPayload(uri); if (!base64) return false; const voiceSessionId = generateUUID(); const sent = session.sendAudioSegment(base64, 0, { voiceSessionId, clientSegmentId: `${voiceSessionId}-0`, isLast: true, duration: durationSec, ttsThisTurn: options?.ttsThisTurn, }); if (!sent) { setError('语音发送失败,连接未就绪'); return false; } onUserSendTtsPreference?.(options?.ttsThisTurn === true); setAwaitingAssistantReply(true); const localId = `pending_voice_${Date.now()}`; await voiceSegmentStore.recordSentSegment({ voiceSessionId, conversationId, fileUri: uri, durationMs, }); queryClient.setQueryData( conversationKeys.messages(conversationId), (old) => { const msg: MessageItem = { id: localId, conversationId, content: '', senderType: 'user', timestamp: Date.now(), messageType: 'voice', voiceSessionId, durationSeconds: durationSec, audioUri: uri, }; return [...(old ?? []), msg]; }, ); queryClient.setQueryData( conversationKeys.lists(), (old) => { if (!old) return old; return old.map((item) => item.id === conversationId ? { ...item, hasUserMessage: true } : item, ); }, ); onTtsPlaybackResume?.(); return true; } catch { setError('语音文件读取失败'); return false; } }, [conversationId, queryClient, onTtsPlaybackResume, onUserSendTtsPreference], ); const sendEndConversation = useCallback(() => { sessionRef.current?.sendEndConversation(); }, []); const sendTtsCancel = useCallback(() => { sessionRef.current?.sendTtsCancel(); }, []); const requestAssistantSegmentTts = useCallback( (body: { assistantMessageId: string; segmentIndex: number; segmentText?: string; }) => sessionRef.current?.requestAssistantSegmentTts(body) ?? false, [], ); return { connectionState, streamingMessage, awaitingAssistantReply, error, sendText, sendVoiceMessage, sendEndConversation, sendTtsCancel, requestAssistantSegmentTts, }; }