fix(s2s-text): 9 review bugs - text stream, loading, history, unmount safety

Backend (VoiceGatewayService):
- [P0 Bug-1] handleAssistantChunk/Final: text mode must never apply blockUpstreamAudio
  gate to text events (blockUpstreamAudio is for audio frames only). Non-KB text
  queries now correctly stream subtitle back to client.
- [P0 Bug-3] sendUpstreamChatTextQuery: when upstream not ready, send
  assistant_pending:false before error so client loading spinner can clear.
- [P1 Bug-6] handleUserPartial/handleUserFinal: early-return if textMode, guard
  against spurious ASR echoes from S2S.

Frontend (ChatPanel S2S effect):
- [P0 Bug-2] Connection success now clears error; added cancelled flag to all
  async setState paths to prevent state reversal on unmount.
- [P1 Bug-4] onAssistantPending: (false) always clears isLoading; (true) only
  sets isLoading if not already streaming (streamingId drives UI, pending is
  advisory).
- [P1 Bug-5] S2S mode loads session history via getSessionHistory (was Coze-only).
- [P2 Bug-8] When s2sService ref is null, also remove the placeholder assistant
  bubble to avoid stale empty bubble in chat.
- [P2 Bug-9] All callbacks guard on cancelled flag to prevent React setState
  warnings after unmount (cleanup triggers svc.disconnect which emits
  'disconnected' state).

Verification: mvn test VoiceGatewaySmokeTest 20/20 pass, no voice regression.
This commit is contained in:
User
2026-04-17 09:44:36 +08:00
parent 3e72cd54d3
commit 4b78f81cbc
2 changed files with 83 additions and 25 deletions

View File

@@ -322,6 +322,9 @@ public class VoiceGatewayService {
private void sendUpstreamChatTextQuery(VoiceSessionState state, String text) { private void sendUpstreamChatTextQuery(VoiceSessionState state, String text) {
if (state.upstream == null || !state.upstreamReady) { if (state.upstream == null || !state.upstreamReady) {
log.warn("[VoiceGateway][text-mode] upstream not ready, drop text session={}", state.sessionId); log.warn("[VoiceGateway][text-mode] upstream not ready, drop text session={}", state.sessionId);
// Reset pending state so client loading indicator can clear
state.awaitingUpstreamReply = false;
sendJson(state, Map.of("type", "assistant_pending", "active", Boolean.FALSE));
sendJson(state, Map.of("type", "error", "error", "语音服务尚未就绪,请稍后重试")); sendJson(state, Map.of("type", "error", "error", "语音服务尚未就绪,请稍后重试"));
return; return;
} }
@@ -502,6 +505,10 @@ public class VoiceGatewayService {
} }
private void handleUserPartial(VoiceSessionState state, JsonNode payload) { private void handleUserPartial(VoiceSessionState state, JsonNode payload) {
if (state.textMode) {
// Text mode: S2S should not emit ASR events; guard against spurious echoes.
return;
}
String text = extractUserText(payload, state.sessionId); String text = extractUserText(payload, state.sessionId);
if (!StringUtils.hasText(text)) { if (!StringUtils.hasText(text)) {
return; return;
@@ -590,6 +597,9 @@ public class VoiceGatewayService {
} }
private void handleUserFinal(VoiceSessionState state, JsonNode payload, int eventCode) { private void handleUserFinal(VoiceSessionState state, JsonNode payload, int eventCode) {
if (state.textMode) {
return;
}
String rawFinalText = extractUserText(payload, state.sessionId); String rawFinalText = extractUserText(payload, state.sessionId);
String normalizedFinal = StringUtils.hasText(rawFinalText) ? knowledgeQueryResolver.normalizeKnowledgeText(rawFinalText, true) : ""; String normalizedFinal = StringUtils.hasText(rawFinalText) ? knowledgeQueryResolver.normalizeKnowledgeText(rawFinalText, true) : "";
String finalText = StringUtils.hasText(normalizedFinal) ? normalizedFinal : state.latestUserText; String finalText = StringUtils.hasText(normalizedFinal) ? normalizedFinal : state.latestUserText;
@@ -666,7 +676,11 @@ public class VoiceGatewayService {
private void handleAssistantFinal(VoiceSessionState state, JsonNode payload) { private void handleAssistantFinal(VoiceSessionState state, JsonNode payload) {
boolean isLocalChatTTSActive = state.isSendingChatTTSText && state.chatTTSUntil > System.currentTimeMillis(); boolean isLocalChatTTSActive = state.isSendingChatTTSText && state.chatTTSUntil > System.currentTimeMillis();
boolean isSuppressing = state.suppressUpstreamUntil > System.currentTimeMillis(); boolean isSuppressing = state.suppressUpstreamUntil > System.currentTimeMillis();
if (isLocalChatTTSActive || state.blockUpstreamAudio || isSuppressing) { // Text mode: blockUpstreamAudio only gates audio frames, never text finals — always deliver 351 text
boolean shouldDropFinal = state.textMode
? (isLocalChatTTSActive || isSuppressing)
: (isLocalChatTTSActive || state.blockUpstreamAudio || isSuppressing);
if (shouldDropFinal) {
state.clearAssistantBuffer(); state.clearAssistantBuffer();
return; return;
} }
@@ -744,7 +758,8 @@ public class VoiceGatewayService {
if (isSuppressing && !"external_rag".equals(state.currentTtsType)) { if (isSuppressing && !"external_rag".equals(state.currentTtsType)) {
return; return;
} }
if (state.blockUpstreamAudio && !"external_rag".equals(state.currentTtsType)) { // Text mode: blockUpstreamAudio only gates audio frames, never text — always pass text through
if (!state.textMode && state.blockUpstreamAudio && !"external_rag".equals(state.currentTtsType)) {
return; return;
} }
String chunk = extractRawText(payload); String chunk = extractRawText(payload);

View File

@@ -23,48 +23,86 @@ export default function ChatPanel({ sessionId, voiceSubtitles, settings, onBack,
if (!useS2S || !sessionId) { if (!useS2S || !sessionId) {
return undefined; return undefined;
} }
// Track mount status to avoid setState after unmount (Bug-9)
let cancelled = false;
const safeSet = (fn) => { if (!cancelled) fn(); };
const svc = new NativeVoiceService(); const svc = new NativeVoiceService();
s2sServiceRef.current = svc; s2sServiceRef.current = svc;
// Load history messages for S2S mode too (Bug-5)
(async () => {
try {
const historyData = await getSessionHistory(sessionId, 20);
if (cancelled) return;
if (historyData?.messages?.length > 0) {
const historyMsgs = historyData.messages.map((m, i) => ({
id: `history-${i}`,
role: m.role,
content: m.content,
fromVoice: true,
}));
setMessages((prev) => (prev.length === 0 ? historyMsgs : prev));
}
} catch (e) {
console.warn('[ChatPanel][S2S] history load failed:', e.message);
}
})();
svc.on('onSubtitle', (data) => { svc.on('onSubtitle', (data) => {
if (!data || !data.role) return; if (!data || !data.role || cancelled) return;
if (data.role === 'user') { if (data.role === 'user') {
// User subtitle is just an echo of what we already inserted; skip
return; return;
} }
// assistant subtitle: streaming chunks (isFinal=false) or final (isFinal=true)
const assistantId = s2sStreamingIdRef.current; const assistantId = s2sStreamingIdRef.current;
if (!assistantId) return; if (!assistantId) return;
setMessages((prev) => prev.map((m) => ( safeSet(() => {
m.id === assistantId setMessages((prev) => prev.map((m) => (
? { ...m, content: data.text || '', streaming: !data.isFinal } m.id === assistantId
: m ? { ...m, content: data.text || '', streaming: !data.isFinal }
))); : m
)));
});
if (data.isFinal) { if (data.isFinal) {
setIsLoading(false); safeSet(() => {
setStreamingId(null); setIsLoading(false);
setStreamingId(null);
});
s2sStreamingIdRef.current = null; s2sStreamingIdRef.current = null;
inputRef.current?.focus(); inputRef.current?.focus();
} }
}); });
// Bug-4: onAssistantPending(false) should also clear streaming state consistently;
// do NOT override isLoading when pending=true if we're already streaming (streamingId drives UI).
svc.on('onAssistantPending', (active) => { svc.on('onAssistantPending', (active) => {
setIsLoading(!!active); if (cancelled) return;
if (!active) {
safeSet(() => setIsLoading(false));
} else if (!s2sStreamingIdRef.current) {
safeSet(() => setIsLoading(true));
}
}); });
svc.on('onError', (err) => { svc.on('onError', (err) => {
setError(err?.message || 'S2S 文字模式错误'); if (cancelled) return;
setIsLoading(false); safeSet(() => {
setStreamingId(null); setError(err?.message || 'S2S 文字模式错误');
setIsLoading(false);
setStreamingId(null);
});
s2sStreamingIdRef.current = null; s2sStreamingIdRef.current = null;
}); });
svc.on('onIdleTimeout', () => { svc.on('onIdleTimeout', () => {
setError('S2S 连接超时,已断开。请刷新页面重连'); if (cancelled) return;
setIsInitialized(false); safeSet(() => {
}); setError('S2S 连接超时,已断开。请刷新页面重连');
svc.on('onConnectionStateChange', (state) => {
if (state === 'connected') {
// wait for onReady (handled via promise in connect)
} else if (state === 'disconnected' || state === 'error') {
setIsInitialized(false); setIsInitialized(false);
});
});
// Bug-9: only treat as disconnect if we haven't initiated cleanup
svc.on('onConnectionStateChange', (state) => {
if (cancelled) return;
if (state === 'disconnected' || state === 'error') {
safeSet(() => setIsInitialized(false));
} }
}); });
@@ -80,13 +118,16 @@ export default function ChatPanel({ sessionId, voiceSubtitles, settings, onBack,
playAudioReply: !!playAudioReply, playAudioReply: !!playAudioReply,
disableGreeting: true, disableGreeting: true,
}); });
setIsInitialized(true); if (cancelled) return;
safeSet(() => { setIsInitialized(true); setError(null); });
} catch (e) { } catch (e) {
setError(`S2S 连接失败:${e?.message || e}`); if (cancelled) return;
safeSet(() => setError(`S2S 连接失败:${e?.message || e}`));
} }
})(); })();
return () => { return () => {
cancelled = true;
svc.disconnect().catch(() => {}); svc.disconnect().catch(() => {});
s2sServiceRef.current = null; s2sServiceRef.current = null;
s2sStreamingIdRef.current = null; s2sStreamingIdRef.current = null;
@@ -208,6 +249,8 @@ export default function ChatPanel({ sessionId, voiceSubtitles, settings, onBack,
setError('S2S 服务未就绪'); setError('S2S 服务未就绪');
setIsLoading(false); setIsLoading(false);
setStreamingId(null); setStreamingId(null);
// Bug-8: remove the empty placeholder assistant bubble we just inserted
setMessages((prev) => prev.filter((m) => m.id !== assistantId));
return; return;
} }
s2sStreamingIdRef.current = assistantId; s2sStreamingIdRef.current = assistantId;