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