fix: 品牌保护+知识库全量覆盖 - 6层防御解决传销问题 + 30+产品关键词补全
This commit is contained in:
@@ -11,28 +11,19 @@ export function useNativeVoiceChat() {
|
||||
const [duration, setDuration] = useState(0);
|
||||
const sessionRef = useRef(null);
|
||||
const timerRef = useRef(null);
|
||||
const greetingUtteranceRef = useRef(null);
|
||||
const greetingFallbackTimerRef = useRef(null);
|
||||
const greetingAudioDetectedRef = useRef(false);
|
||||
|
||||
const stopGreeting = useCallback(() => {
|
||||
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
const clearGreetingFallback = useCallback(() => {
|
||||
if (greetingFallbackTimerRef.current) {
|
||||
clearTimeout(greetingFallbackTimerRef.current);
|
||||
greetingFallbackTimerRef.current = null;
|
||||
}
|
||||
greetingUtteranceRef.current = null;
|
||||
}, []);
|
||||
|
||||
const playGreeting = useCallback((text) => {
|
||||
const greetingText = String(text || '').trim();
|
||||
if (!greetingText || typeof window === 'undefined' || !('speechSynthesis' in window) || typeof window.SpeechSynthesisUtterance === 'undefined') {
|
||||
return;
|
||||
}
|
||||
stopGreeting();
|
||||
const utterance = new window.SpeechSynthesisUtterance(greetingText);
|
||||
utterance.lang = 'zh-CN';
|
||||
utterance.rate = 1;
|
||||
utterance.pitch = 1;
|
||||
greetingUtteranceRef.current = utterance;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}, [stopGreeting]);
|
||||
const stopGreeting = useCallback(() => {
|
||||
clearGreetingFallback();
|
||||
}, [clearGreetingFallback]);
|
||||
|
||||
useEffect(() => {
|
||||
nativeVoiceService.on('onSubtitle', (subtitle) => {
|
||||
@@ -45,21 +36,60 @@ export function useNativeVoiceChat() {
|
||||
const finals = prev.filter((s) => s.isFinal);
|
||||
return [...finals, subtitle];
|
||||
});
|
||||
if (subtitle?.role === 'assistant' && subtitle?.isFinal && /^greeting_/.test(String(subtitle.sequence || ''))) {
|
||||
clearGreetingFallback();
|
||||
greetingAudioDetectedRef.current = false;
|
||||
greetingFallbackTimerRef.current = setTimeout(() => {
|
||||
if (!greetingAudioDetectedRef.current) {
|
||||
nativeVoiceService.requestGreetingReplay();
|
||||
}
|
||||
greetingFallbackTimerRef.current = null;
|
||||
}, 4000);
|
||||
}
|
||||
});
|
||||
|
||||
nativeVoiceService.on('onConnectionStateChange', setConnectionState);
|
||||
nativeVoiceService.on('onError', (err) => setError(err?.message || 'Native voice error'));
|
||||
nativeVoiceService.on('onDiagnostic', (diag) => {
|
||||
if (!diag) return;
|
||||
if (diag.type === 'audio_chunk') {
|
||||
greetingAudioDetectedRef.current = true;
|
||||
clearGreetingFallback();
|
||||
return;
|
||||
}
|
||||
if (diag.type === 'ws_message' && diag.payload?.type === 'tts_event' && diag.payload?.payload?.tts_type === 'chat_tts_text') {
|
||||
greetingAudioDetectedRef.current = true;
|
||||
clearGreetingFallback();
|
||||
}
|
||||
});
|
||||
nativeVoiceService.on('onIdleTimeout', (timeout) => {
|
||||
const mins = Math.round((timeout || 300000) / 60000);
|
||||
console.log(`[useNativeVoiceChat] Idle timeout (${mins}min), auto disconnecting`);
|
||||
setError(`通话已空闲${mins}分钟,已自动退出`);
|
||||
nativeVoiceService.disconnect();
|
||||
setIsActive(false);
|
||||
setIsMuted(false);
|
||||
setConnectionState('disconnected');
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
sessionRef.current = null;
|
||||
});
|
||||
|
||||
return () => {
|
||||
stopGreeting();
|
||||
nativeVoiceService.disconnect();
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [stopGreeting]);
|
||||
}, [clearGreetingFallback, stopGreeting]);
|
||||
|
||||
const start = useCallback(async (options = {}) => {
|
||||
setError(null);
|
||||
setIsConnecting(true);
|
||||
greetingAudioDetectedRef.current = false;
|
||||
clearGreetingFallback();
|
||||
stopGreeting();
|
||||
|
||||
try {
|
||||
const userId = `user_${Date.now().toString(36)}`;
|
||||
@@ -74,12 +104,12 @@ export function useNativeVoiceChat() {
|
||||
speakingStyle: options.speakingStyle,
|
||||
modelVersion: options.modelVersion,
|
||||
speaker: options.speaker,
|
||||
greetingText: options.greetingText,
|
||||
});
|
||||
|
||||
setIsActive(true);
|
||||
setSubtitles([]);
|
||||
setDuration(0);
|
||||
playGreeting(options.greetingText);
|
||||
timerRef.current = setInterval(() => {
|
||||
setDuration((d) => d + 1);
|
||||
}, 1000);
|
||||
@@ -93,7 +123,7 @@ export function useNativeVoiceChat() {
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, []);
|
||||
}, [clearGreetingFallback, stopGreeting]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
let result = { sessionId: null, subtitles: [] };
|
||||
|
||||
@@ -18,6 +18,7 @@ class NativeVoiceService {
|
||||
onError: null,
|
||||
onAssistantPending: null,
|
||||
onDiagnostic: null,
|
||||
onIdleTimeout: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,7 +77,7 @@ class NativeVoiceService {
|
||||
}
|
||||
}
|
||||
|
||||
async connect({ sessionId, userId, botName, systemRole, speakingStyle, modelVersion, speaker }) {
|
||||
async connect({ sessionId, userId, botName, systemRole, speakingStyle, modelVersion, speaker, greetingText }) {
|
||||
await this.disconnect();
|
||||
const wsUrl = this.resolveWebSocketUrl(sessionId, userId);
|
||||
this.emitConnectionState('connecting');
|
||||
@@ -86,6 +87,22 @@ class NativeVoiceService {
|
||||
}
|
||||
this.playbackTime = this.playbackContext.currentTime;
|
||||
|
||||
// 并行: 同时预获取麦克风和建立WS连接,节省500ms+
|
||||
const micPromise = navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
noiseSuppression: true,
|
||||
echoCancellation: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
video: false,
|
||||
}).catch((err) => {
|
||||
console.warn('[NativeVoice] Pre-fetch getUserMedia failed:', err.message);
|
||||
return null;
|
||||
});
|
||||
|
||||
const CONNECTION_TIMEOUT_MS = 12000;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
this.readyResolver = resolve;
|
||||
this.readyRejector = reject;
|
||||
@@ -93,6 +110,18 @@ class NativeVoiceService {
|
||||
ws.binaryType = 'arraybuffer';
|
||||
this.ws = ws;
|
||||
|
||||
// 超时兜底:避免无限等待
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (this.readyResolver) {
|
||||
console.warn(`[NativeVoice] Connection timeout (${CONNECTION_TIMEOUT_MS}ms), forcing ready`);
|
||||
this.readyResolver();
|
||||
this.readyResolver = null;
|
||||
this.readyRejector = null;
|
||||
}
|
||||
}, CONNECTION_TIMEOUT_MS);
|
||||
|
||||
const clearTimeoutOnSettle = () => clearTimeout(timeoutId);
|
||||
|
||||
ws.onopen = () => {
|
||||
this.emitConnectionState('connected');
|
||||
ws.send(JSON.stringify({
|
||||
@@ -104,10 +133,12 @@ class NativeVoiceService {
|
||||
speakingStyle,
|
||||
modelVersion,
|
||||
speaker,
|
||||
greetingText,
|
||||
}));
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeoutOnSettle();
|
||||
const error = new Error('WebSocket connection failed');
|
||||
this.callbacks.onError?.(error);
|
||||
this.readyRejector?.(error);
|
||||
@@ -117,6 +148,7 @@ class NativeVoiceService {
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
clearTimeoutOnSettle();
|
||||
this.emitConnectionState('disconnected');
|
||||
if (this.readyRejector) {
|
||||
this.readyRejector(new Error('WebSocket closed before ready'));
|
||||
@@ -127,14 +159,20 @@ class NativeVoiceService {
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
this.handleJsonMessage(event.data);
|
||||
const peek = event.data;
|
||||
if (peek.includes('"ready"')) {
|
||||
clearTimeoutOnSettle();
|
||||
}
|
||||
this.handleJsonMessage(peek);
|
||||
return;
|
||||
}
|
||||
this.handleAudioMessage(event.data);
|
||||
};
|
||||
});
|
||||
|
||||
await this.startCapture();
|
||||
// 使用预获取的mediaStream(已并行获取),避免重复申请
|
||||
const preFetchedStream = await micPromise;
|
||||
await this.startCapture(preFetchedStream);
|
||||
}
|
||||
|
||||
handleJsonMessage(raw) {
|
||||
@@ -164,6 +202,14 @@ class NativeVoiceService {
|
||||
this.callbacks.onAssistantPending?.(!!msg.active);
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'idle_timeout') {
|
||||
this.callbacks.onIdleTimeout?.(msg.timeout || 300000);
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'upstream_closed') {
|
||||
this.callbacks.onError?.(new Error('语音服务已断开,请重新开始通话'));
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'error') {
|
||||
this.callbacks.onError?.(new Error(msg.error || 'native voice error'));
|
||||
return;
|
||||
@@ -206,8 +252,8 @@ class NativeVoiceService {
|
||||
this.emitDiagnostic('audio_chunk', { samples: pcm16.length, duration: audioBuffer.duration });
|
||||
}
|
||||
|
||||
async startCapture() {
|
||||
this.mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
async startCapture(preFetchedStream) {
|
||||
this.mediaStream = preFetchedStream || await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
noiseSuppression: true,
|
||||
@@ -274,6 +320,13 @@ class NativeVoiceService {
|
||||
});
|
||||
}
|
||||
|
||||
requestGreetingReplay() {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: 'replay_greeting' }));
|
||||
this.emitDiagnostic('replay_greeting', { sent: true });
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (this.captureProcessor) {
|
||||
this.captureProcessor.disconnect();
|
||||
|
||||
Reference in New Issue
Block a user