From 4b78f81cbcf91e347ed6bb2ace07d28621dfc7b3 Mon Sep 17 00:00:00 2001 From: User Date: Fri, 17 Apr 2026 09:44:36 +0800 Subject: [PATCH] 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. --- .../service/VoiceGatewayService.java | 19 +++- test2/client/src/components/ChatPanel.jsx | 89 ++++++++++++++----- 2 files changed, 83 insertions(+), 25 deletions(-) diff --git a/java-server/src/main/java/com/bigwo/javaserver/service/VoiceGatewayService.java b/java-server/src/main/java/com/bigwo/javaserver/service/VoiceGatewayService.java index 3ce494d..930c965 100644 --- a/java-server/src/main/java/com/bigwo/javaserver/service/VoiceGatewayService.java +++ b/java-server/src/main/java/com/bigwo/javaserver/service/VoiceGatewayService.java @@ -322,6 +322,9 @@ public class VoiceGatewayService { private void sendUpstreamChatTextQuery(VoiceSessionState state, String text) { if (state.upstream == null || !state.upstreamReady) { 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", "语音服务尚未就绪,请稍后重试")); return; } @@ -502,6 +505,10 @@ public class VoiceGatewayService { } 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); if (!StringUtils.hasText(text)) { return; @@ -590,6 +597,9 @@ public class VoiceGatewayService { } private void handleUserFinal(VoiceSessionState state, JsonNode payload, int eventCode) { + if (state.textMode) { + return; + } String rawFinalText = extractUserText(payload, state.sessionId); String normalizedFinal = StringUtils.hasText(rawFinalText) ? knowledgeQueryResolver.normalizeKnowledgeText(rawFinalText, true) : ""; String finalText = StringUtils.hasText(normalizedFinal) ? normalizedFinal : state.latestUserText; @@ -666,7 +676,11 @@ public class VoiceGatewayService { private void handleAssistantFinal(VoiceSessionState state, JsonNode payload) { boolean isLocalChatTTSActive = state.isSendingChatTTSText && state.chatTTSUntil > 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(); return; } @@ -744,7 +758,8 @@ public class VoiceGatewayService { if (isSuppressing && !"external_rag".equals(state.currentTtsType)) { 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; } String chunk = extractRawText(payload); diff --git a/test2/client/src/components/ChatPanel.jsx b/test2/client/src/components/ChatPanel.jsx index c893626..c36e759 100644 --- a/test2/client/src/components/ChatPanel.jsx +++ b/test2/client/src/components/ChatPanel.jsx @@ -23,48 +23,86 @@ export default function ChatPanel({ sessionId, voiceSubtitles, settings, onBack, if (!useS2S || !sessionId) { 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(); 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) => { - if (!data || !data.role) return; + if (!data || !data.role || cancelled) return; if (data.role === 'user') { - // User subtitle is just an echo of what we already inserted; skip return; } - // assistant subtitle: streaming chunks (isFinal=false) or final (isFinal=true) const assistantId = s2sStreamingIdRef.current; if (!assistantId) return; - setMessages((prev) => prev.map((m) => ( - m.id === assistantId - ? { ...m, content: data.text || '', streaming: !data.isFinal } - : m - ))); + safeSet(() => { + setMessages((prev) => prev.map((m) => ( + m.id === assistantId + ? { ...m, content: data.text || '', streaming: !data.isFinal } + : m + ))); + }); if (data.isFinal) { - setIsLoading(false); - setStreamingId(null); + safeSet(() => { + setIsLoading(false); + setStreamingId(null); + }); s2sStreamingIdRef.current = null; 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) => { - setIsLoading(!!active); + if (cancelled) return; + if (!active) { + safeSet(() => setIsLoading(false)); + } else if (!s2sStreamingIdRef.current) { + safeSet(() => setIsLoading(true)); + } }); svc.on('onError', (err) => { - setError(err?.message || 'S2S 文字模式错误'); - setIsLoading(false); - setStreamingId(null); + if (cancelled) return; + safeSet(() => { + setError(err?.message || 'S2S 文字模式错误'); + setIsLoading(false); + setStreamingId(null); + }); s2sStreamingIdRef.current = null; }); svc.on('onIdleTimeout', () => { - setError('S2S 连接超时,已断开。请刷新页面重连'); - setIsInitialized(false); - }); - svc.on('onConnectionStateChange', (state) => { - if (state === 'connected') { - // wait for onReady (handled via promise in connect) - } else if (state === 'disconnected' || state === 'error') { + if (cancelled) return; + safeSet(() => { + setError('S2S 连接超时,已断开。请刷新页面重连'); 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, disableGreeting: true, }); - setIsInitialized(true); + if (cancelled) return; + safeSet(() => { setIsInitialized(true); setError(null); }); } catch (e) { - setError(`S2S 连接失败:${e?.message || e}`); + if (cancelled) return; + safeSet(() => setError(`S2S 连接失败:${e?.message || e}`)); } })(); return () => { + cancelled = true; svc.disconnect().catch(() => {}); s2sServiceRef.current = null; s2sStreamingIdRef.current = null; @@ -208,6 +249,8 @@ export default function ChatPanel({ sessionId, voiceSubtitles, settings, onBack, setError('S2S 服务未就绪'); setIsLoading(false); setStreamingId(null); + // Bug-8: remove the empty placeholder assistant bubble we just inserted + setMessages((prev) => prev.filter((m) => m.id !== assistantId)); return; } s2sStreamingIdRef.current = assistantId;