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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user