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(() => {
sendTtsCancel();
ttsGate.current.interrupt();
@@ -1864,7 +1876,7 @@ export default function ConversationScreen() {
}
ListFooterComponent={
<>
{awaitingAssistantReply && !streamingMessage ? (
{showAssistantTypingFooter ? (
<AssistantTypingBubble
agentName={t('agentName')}
labelStyle={chatTypingLabelStyle}

View File

@@ -199,8 +199,12 @@ interface RealtimeSessionState {
/** 服务端下发的 quick-start 话题 chips用户首次发文本/语音后清空 */
topicSuggestions: TopicSuggestion[];
dismissTopicSuggestions: () => void;
sendText: (text: string) => void;
sendVoiceMessage: (uri: string, durationMs: number) => Promise<boolean>;
sendText: (text: string, options?: { ttsThisTurn?: boolean }) => void;
sendVoiceMessage: (
uri: string,
durationMs: number,
options?: { ttsThisTurn?: boolean },
) => Promise<boolean>;
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 }) => {

View File

@@ -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 {