diff --git a/app-expo/src/app/(main)/conversation/[id].tsx b/app-expo/src/app/(main)/conversation/[id].tsx index 00bb701..4e264d7 100644 --- a/app-expo/src/app/(main)/conversation/[id].tsx +++ b/app-expo/src/app/(main)/conversation/[id].tsx @@ -1447,6 +1447,18 @@ export default function ConversationScreen() { }, }); + /** + * 列表末条已是助手时不再展示「正在回复」footer,避免 WS 完成回调缺失时状态与缓存不一致。 + * 用渲染期派生条件代替 effect 回写 state(Vercel:rerender-derived-state-no-effect)。 + */ + const showAssistantTypingFooter = useMemo( + () => + awaitingAssistantReply && + !streamingMessage && + messages?.at(-1)?.senderType !== 'assistant', + [awaitingAssistantReply, streamingMessage, messages], + ); + const handleInterruptAssistantTts = useCallback(() => { sendTtsCancel(); ttsGate.current.interrupt(); @@ -1864,7 +1876,7 @@ export default function ConversationScreen() { } ListFooterComponent={ <> - {awaitingAssistantReply && !streamingMessage ? ( + {showAssistantTypingFooter ? ( void; - sendText: (text: string) => void; - sendVoiceMessage: (uri: string, durationMs: number) => Promise; + sendText: (text: string, options?: { ttsThisTurn?: boolean }) => void; + sendVoiceMessage: ( + uri: string, + durationMs: number, + options?: { ttsThisTurn?: boolean }, + ) => Promise; sendEndConversation: () => void; sendTtsCancel: () => void; requestAssistantSegmentTts: (body: { @@ -283,6 +287,35 @@ export function useRealtimeSession({ 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; @@ -318,15 +351,7 @@ export function useRealtimeSession({ setAwaitingAssistantReply(false); setTopicSuggestions([]); }; - }, [ - conversationId, - enabled, - queryClient, - handleStreamingText, - handleError, - handleTopicSuggestions, - onTtsSegment, - ]); + }, [conversationId, enabled, queryClient, foregroundResumeGeneration]); const sendText = useCallback( (text: string, options?: { ttsThisTurn?: boolean }) => { diff --git a/app-expo/src/features/conversation/realtime-session.ts b/app-expo/src/features/conversation/realtime-session.ts index 1a82c12..bafc7e2 100644 --- a/app-expo/src/features/conversation/realtime-session.ts +++ b/app-expo/src/features/conversation/realtime-session.ts @@ -463,7 +463,6 @@ export class RealtimeSession { ); if (bufferedTts) { this.commitOneAssistantMessage(event.text, id); - this.clearAssistantPendingUi(); this.onTtsSegment?.(bufferedTts); this.finishAssistantTurnIfLastSegment(index, total); } else { @@ -475,7 +474,6 @@ export class RealtimeSession { const idCaptured = id; this.scheduleDeferredSyncCommit(key, index, total, () => { this.commitOneAssistantMessage(textCaptured, idCaptured); - this.clearAssistantPendingUi(); }); } } else { @@ -557,6 +555,8 @@ export class RealtimeSession { return [...(old ?? []), message]; }); this.updateConversationListPreview(content); + /** 与列表真相对齐:任何助手气泡落缓存都应收起「正在回复」占位(防止漏掉 onStreamingText 完成帧) */ + this.clearAssistantPendingUi(); } private commitStreamingBufferWithId(messageId: string): void { @@ -585,6 +585,7 @@ export class RealtimeSession { }); this.updateConversationListPreview(content); + this.clearAssistantPendingUi(); } private updateConversationListPreview(latestContent: string): void {