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:
@@ -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 ? (
|
||||
<AssistantTypingBubble
|
||||
agentName={t('agentName')}
|
||||
labelStyle={chatTypingLabelStyle}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user