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(() => {
|
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}
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user