fix(app-expo): 稳定 WS TTS 与「正在回复」状态

- useRealtimeSession:恢复 uiRef 同步与 AppState 后台/前台重连,收窄长连 effect 依赖,修正 send API 类型
- 会话页:用派生条件控制 AssistantTypingBubble,避免缓存已有助手末条仍显示 typing
- RealtimeSession:在 commit 落缓存时统一 clearAssistantPendingUi,避免漏掉流式完成帧

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-12 13:42:54 +08:00
parent ddefb78dc4
commit 71bf62166e
3 changed files with 52 additions and 14 deletions

View File

@@ -1447,6 +1447,18 @@ export default function ConversationScreen() {
}, },
}); });
/**
* 列表末条已是助手时不再展示「正在回复」footer避免 WS 完成回调缺失时状态与缓存不一致。
* 用渲染期派生条件代替 effect 回写 stateVercelrerender-derived-state-no-effect
*/
const showAssistantTypingFooter = useMemo(
() =>
awaitingAssistantReply &&
!streamingMessage &&
messages?.at(-1)?.senderType !== 'assistant',
[awaitingAssistantReply, streamingMessage, messages],
);
const handleInterruptAssistantTts = useCallback(() => { const handleInterruptAssistantTts = useCallback(() => {
sendTtsCancel(); sendTtsCancel();
ttsGate.current.interrupt(); ttsGate.current.interrupt();
@@ -1864,7 +1876,7 @@ export default function ConversationScreen() {
} }
ListFooterComponent={ ListFooterComponent={
<> <>
{awaitingAssistantReply && !streamingMessage ? ( {showAssistantTypingFooter ? (
<AssistantTypingBubble <AssistantTypingBubble
agentName={t('agentName')} agentName={t('agentName')}
labelStyle={chatTypingLabelStyle} labelStyle={chatTypingLabelStyle}

View File

@@ -199,8 +199,12 @@ interface RealtimeSessionState {
/** 服务端下发的 quick-start 话题 chips用户首次发文本/语音后清空 */ /** 服务端下发的 quick-start 话题 chips用户首次发文本/语音后清空 */
topicSuggestions: TopicSuggestion[]; topicSuggestions: TopicSuggestion[];
dismissTopicSuggestions: () => void; dismissTopicSuggestions: () => void;
sendText: (text: string) => void; sendText: (text: string, options?: { ttsThisTurn?: boolean }) => void;
sendVoiceMessage: (uri: string, durationMs: number) => Promise<boolean>; sendVoiceMessage: (
uri: string,
durationMs: number,
options?: { ttsThisTurn?: boolean },
) => Promise<boolean>;
sendEndConversation: () => void; sendEndConversation: () => void;
sendTtsCancel: () => void; sendTtsCancel: () => void;
requestAssistantSegmentTts: (body: { requestAssistantSegmentTts: (body: {
@@ -283,6 +287,35 @@ export function useRealtimeSession({
setTopicSuggestions([]); 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(() => { useEffect(() => {
if (!enabled || !conversationId) return; if (!enabled || !conversationId) return;
@@ -318,15 +351,7 @@ export function useRealtimeSession({
setAwaitingAssistantReply(false); setAwaitingAssistantReply(false);
setTopicSuggestions([]); setTopicSuggestions([]);
}; };
}, [ }, [conversationId, enabled, queryClient, foregroundResumeGeneration]);
conversationId,
enabled,
queryClient,
handleStreamingText,
handleError,
handleTopicSuggestions,
onTtsSegment,
]);
const sendText = useCallback( const sendText = useCallback(
(text: string, options?: { ttsThisTurn?: boolean }) => { (text: string, options?: { ttsThisTurn?: boolean }) => {

View File

@@ -463,7 +463,6 @@ export class RealtimeSession {
); );
if (bufferedTts) { if (bufferedTts) {
this.commitOneAssistantMessage(event.text, id); this.commitOneAssistantMessage(event.text, id);
this.clearAssistantPendingUi();
this.onTtsSegment?.(bufferedTts); this.onTtsSegment?.(bufferedTts);
this.finishAssistantTurnIfLastSegment(index, total); this.finishAssistantTurnIfLastSegment(index, total);
} else { } else {
@@ -475,7 +474,6 @@ export class RealtimeSession {
const idCaptured = id; const idCaptured = id;
this.scheduleDeferredSyncCommit(key, index, total, () => { this.scheduleDeferredSyncCommit(key, index, total, () => {
this.commitOneAssistantMessage(textCaptured, idCaptured); this.commitOneAssistantMessage(textCaptured, idCaptured);
this.clearAssistantPendingUi();
}); });
} }
} else { } else {
@@ -557,6 +555,8 @@ export class RealtimeSession {
return [...(old ?? []), message]; return [...(old ?? []), message];
}); });
this.updateConversationListPreview(content); this.updateConversationListPreview(content);
/** 与列表真相对齐:任何助手气泡落缓存都应收起「正在回复」占位(防止漏掉 onStreamingText 完成帧) */
this.clearAssistantPendingUi();
} }
private commitStreamingBufferWithId(messageId: string): void { private commitStreamingBufferWithId(messageId: string): void {
@@ -585,6 +585,7 @@ export class RealtimeSession {
}); });
this.updateConversationListPreview(content); this.updateConversationListPreview(content);
this.clearAssistantPendingUi();
} }
private updateConversationListPreview(latestContent: string): void { private updateConversationListPreview(latestContent: string): void {