Files
life-echo/app-expo/src/features/conversation/hooks.ts
Kevin 71bf62166e fix(app-expo): 稳定 WS TTS 与「正在回复」状态
- useRealtimeSession:恢复 uiRef 同步与 AppState 后台/前台重连,收窄长连 effect 依赖,修正 send API 类型
- 会话页:用派生条件控制 AssistantTypingBubble,避免缓存已有助手末条仍显示 typing
- RealtimeSession:在 commit 落缓存时统一 clearAssistantPendingUi,避免漏掉流式完成帧

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 13:42:54 +08:00

514 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 i18n from '@/i18n';
import type { TopicSuggestion, 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 RealtimeSessionUiOwner,
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<string> {
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<ConversationListItem[]>(
conversationKeys.lists(),
(old) => {
const startedMs = Date.parse(newConversation.started_at);
const now = Date.now();
const item: ConversationListItem = {
id: newConversation.id,
title: i18n.t('agentName', { ns: 'conversation' }),
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<ConversationListItem[]>(
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;
/** 服务端下发的 quick-start 话题 chips用户首次发文本/语音后清空 */
topicSuggestions: TopicSuggestion[];
dismissTopicSuggestions: () => void;
sendText: (text: string, options?: { ttsThisTurn?: boolean }) => void;
sendVoiceMessage: (
uri: string,
durationMs: number,
options?: { ttsThisTurn?: boolean },
) => Promise<boolean>;
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<RealtimeSession | null>(null);
const uiOwnerRef = useRef<RealtimeSessionUiOwner>(
Symbol('conversation-screen-ui'),
);
const uiRef = useRef({
handleStreamingText: (() => {}) as StreamingTextCallback,
handleError: (() => {}) as ErrorCallback,
onTtsSegment: undefined as
| ((payload: TtsSegmentPayload) => void)
| undefined,
});
const [connectionState, setConnectionState] =
useState<WsConnectionState>('disconnected');
const [streamingMessage, setStreamingMessage] =
useState<StreamingAgentMessage | null>(null);
const [awaitingAssistantReply, setAwaitingAssistantReply] = useState(false);
const [error, setError] = useState<string | null>(null);
const [topicSuggestions, setTopicSuggestions] = useState<TopicSuggestion[]>(
[],
);
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;
}
/**
* 空文本 + 未完成时不能写成 `{text: '', isComplete: false}`
* UI 会渲染一只空 `StreamingBubbles`pulsing 气泡 + 光标),看上去与
* 「正在回复…」typing 气泡难以区分,且会一直挂在底部不消失。
*/
if (text.length === 0) {
return;
}
setStreamingMessage({ text, isComplete });
},
[],
);
const handleError: ErrorCallback = useCallback((message) => {
setAwaitingAssistantReply(false);
setError(message);
}, []);
const handleTopicSuggestions = useCallback(
(payload: { suggestions: TopicSuggestion[] }) => {
setTopicSuggestions(payload.suggestions);
},
[],
);
const dismissTopicSuggestions = useCallback(() => {
setTopicSuggestions([]);
}, []);
/** 每轮 render 写入 ref`attachUiCallbacks` 的包装函数始终读最新实现TTS / 流式 / 错误)。 */
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]);
/**
* 依赖保持最小:`handleStreamingText` / `onTtsSegment` 等由上方 `uiRef` 每轮 render 同步,
* 避免因父组件 `enqueue` 等导致 identity 变化而反复 teardown WS短时 noop 回调会丢 TTS
*/
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),
onTopicSuggestions: (payload) =>
handleTopicSuggestions({ suggestions: payload.suggestions }),
onError: (message, code) => uiRef.current.handleError(message, code),
onStateChange: setConnectionState,
},
uiOwnerRef.current,
);
sessionRef.current = session;
setConnectionState(session.getConnectionState());
return () => {
releaseConversationWsUi(session, uiOwnerRef.current);
sessionRef.current = null;
setConnectionState('disconnected');
setStreamingMessage(null);
setAwaitingAssistantReply(false);
setTopicSuggestions([]);
};
}, [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);
setTopicSuggestions([]);
onTtsPlaybackResume?.();
const localId = `pending_${Date.now()}`;
queryClient.setQueryData<MessageItem[]>(
conversationKeys.messages(conversationId),
(old) => {
const msg: MessageItem = {
id: localId,
conversationId,
content: text,
senderType: 'user',
timestamp: Date.now(),
messageType: 'text',
};
return [...(old ?? []), msg];
},
);
queryClient.setQueryData<ConversationListItem[]>(
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<boolean> => {
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);
setTopicSuggestions([]);
const localId = `pending_voice_${Date.now()}`;
await voiceSegmentStore.recordSentSegment({
voiceSessionId,
conversationId,
fileUri: uri,
durationMs,
});
queryClient.setQueryData<MessageItem[]>(
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<ConversationListItem[]>(
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,
topicSuggestions,
dismissTopicSuggestions,
sendText,
sendVoiceMessage,
sendEndConversation,
sendTtsCancel,
requestAssistantSegmentTts,
};
}