Bloom_Ware / static /frontend /js /websocket.js
LawrenceBai's picture
fix: ensure TTS language parameter propagates across all websocket responses (bot_message, bot_delta, voice_login)
190de1a
Raw
History Blame Contribute Delete
23.2 kB
window.inferConversationLanguage = function inferConversationLanguage(text, fallback = null) {
const source = String(text || '').trim();
if (!source) {
return fallback || window.currentConversationLanguage || window.currentSpeechLanguage || navigator.language || 'zh-TW';
}
if (/[\u3040-\u30ff]/.test(source)) {
return 'ja-JP';
}
if (/[\uac00-\ud7af]/.test(source)) {
return 'ko-KR';
}
if (/[\u0e00-\u0e7f]/.test(source)) {
return 'th-TH';
}
if (/[A-Za-z]/.test(source) && !/[\u3400-\u9fff]/.test(source)) {
return 'en-US';
}
return fallback || window.currentConversationLanguage || window.currentSpeechLanguage || navigator.language || 'zh-TW';
};
class WebSocketManager {
constructor(wsUrl) {
this.wsUrl = wsUrl;
this.ws = null;
this.isConnecting = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 2000;
this.onlineCallbacks = [];
this.messageCallbacks = [];
this.audioContext = null;
this.audioStream = null;
this.audioProcessor = null;
this.audioSource = null;
this.audioWorkletModuleUrl = null;
this.isRecording = false;
this.localPreviewFinal = '';
this.localPreviewInterim = '';
this.connect = this.connect.bind(this);
this.handleOpen = this.handleOpen.bind(this);
this.handleMessage = this.handleMessage.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleError = this.handleError.bind(this);
this.startRecording = this.startRecording.bind(this);
this.stopRecording = this.stopRecording.bind(this);
this.cleanupAudioResources = this.cleanupAudioResources.bind(this);
this.ensurePCMRecorderWorklet = this.ensurePCMRecorderWorklet.bind(this);
}
async connect() {
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
return;
}
try {
this.isConnecting = true;
this.ws = new WebSocket(this.wsUrl);
this.notifyOnlineState(false);
this.ws.addEventListener('open', this.handleOpen);
this.ws.addEventListener('message', this.handleMessage);
this.ws.addEventListener('close', this.handleClose);
this.ws.addEventListener('error', this.handleError);
} catch (error) {
console.error('❌ WebSocket 連接失敗:', error);
this.isConnecting = false;
this.scheduleReconnect();
}
}
handleOpen() {
this.isConnecting = false;
this.reconnectAttempts = 0;
this.notifyOnlineState(true);
const cid = window.currentChatId;
if (cid) {
this.send({ type: 'chat_focus', chat_id: cid });
}
if (typeof startLocationTracking === 'function') {
startLocationTracking();
} else {
console.warn('⚠️ startLocationTracking 函數未定義');
}
}
handleMessage(event) {
try {
const data = JSON.parse(event.data);
const silentTypes = ['env_ack', 'typing', 'stt_partial', 'stt_delta'];
if (!silentTypes.includes(data.type)) {
if (window.DEBUG_MODE) {
}
}
this.messageCallbacks.forEach(callback => {
try {
callback(data);
} catch (error) {
console.error('❌ WebSocket 訊息回調錯誤:', error);
}
});
} catch (error) {
console.error('❌ 解析 WebSocket 訊息失敗:', error);
}
}
handleClose(event) {
this.notifyOnlineState(false);
this.isConnecting = false;
const isAuthError = event.code === 1008 ||
event.code === 1006 ||
event.reason?.includes('認證') ||
event.reason?.includes('令牌') ||
event.reason?.includes('Forbidden');
if (isAuthError) {
console.error('❌ 認證失敗,清除 token 並跳轉到登入頁面');
localStorage.removeItem('jwt_token');
setTimeout(() => {
window.location.href = '/login/';
}, 500);
return;
}
this.scheduleReconnect();
}
handleError(error) {
console.error('❌ WebSocket 連接錯誤:', error);
this.notifyOnlineState(false);
this.isConnecting = false;
setTimeout(() => {
if (this.reconnectAttempts > 0) {
const token = localStorage.getItem('jwt_token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const currentTime = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < currentTime) {
console.error('❌ Token 已過期,跳轉到登入頁面');
localStorage.removeItem('jwt_token');
window.location.href = '/login/';
}
} catch (e) {
console.error('❌ Token 解析失敗,跳轉到登入頁面');
localStorage.removeItem('jwt_token');
window.location.href = '/login/';
}
}
}
}, 1000);
}
scheduleReconnect() {
if (this.reconnectAttempts >= 3) {
console.warn('⚠️ 多次重連失敗,檢查 token 有效性...');
const token = localStorage.getItem('jwt_token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const currentTime = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < currentTime) {
console.error('❌ Token 已過期,跳轉到登入頁面');
localStorage.removeItem('jwt_token');
window.location.href = '/login/';
return;
}
} catch (error) {
console.error('❌ Token 解析失敗,可能已損壞');
}
}
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('❌ WebSocket 重連次數已達上限,可能是認證問題,清除 token 並跳轉登入頁');
localStorage.removeItem('jwt_token');
window.location.href = '/login/';
return;
}
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
setTimeout(() => {
this.connect();
}, delay);
}
send(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
const silentTypes = ['audio_chunk', 'env_snapshot'];
if (window.DEBUG_MODE && !silentTypes.includes(data.type)) {
}
return true;
}
console.warn('⚠️ WebSocket 未連接');
return false;
}
sendUserMessage(text, chatId) {
if (!text || !this.isConnected()) {
console.warn('⚠️ WebSocket 未連接或訊息為空');
return false;
}
if (!chatId) {
console.warn('⚠️ 缺少 chat_id');
return false;
}
const inferredLanguage = window.inferConversationLanguage(text, navigator.language || 'zh-TW');
window.currentConversationLanguage = inferredLanguage;
window.currentSpeechLanguage = inferredLanguage;
const payload = {
type: 'user_message',
message: text,
chat_id: chatId,
language: inferredLanguage
};
return this.send(payload);
}
isConnected() {
return this.ws && this.ws.readyState === WebSocket.OPEN;
}
disconnect() {
if (this.ws) {
this.ws.removeEventListener('open', this.handleOpen);
this.ws.removeEventListener('message', this.handleMessage);
this.ws.removeEventListener('close', this.handleClose);
this.ws.removeEventListener('error', this.handleError);
this.ws.close();
this.ws = null;
}
this.notifyOnlineState(false);
}
onOnlineStateChange(callback) {
this.onlineCallbacks.push(callback);
}
onMessage(callback) {
this.messageCallbacks.push(callback);
}
notifyOnlineState(isOnline) {
this.onlineCallbacks.forEach(callback => {
try {
callback(isOnline);
} catch (error) {
console.error('❌ 在線狀態回調錯誤:', error);
}
});
}
updateTranscriptPreview(text, className = 'provisional') {
const transcript = document.getElementById('transcript');
if (!transcript || !text) return;
transcript.textContent = text;
transcript.className = `voice-transcript ${className}`;
}
startLocalTranscriptPreview() {
const recognizer = window.speechRecognition;
this.localPreviewFinal = '';
this.localPreviewInterim = '';
if (!recognizer || !recognizer.isSupported) {
this.updateTranscriptPreview('聆聽中...', 'provisional');
return;
}
recognizer.onResult = (finalText, interimText) => {
if (finalText) {
this.localPreviewFinal += finalText;
}
this.localPreviewInterim = interimText || '';
const preview = `${this.localPreviewFinal}${this.localPreviewInterim}`.trim();
if (preview) {
this.updateTranscriptPreview(preview, this.localPreviewInterim ? 'realtime' : 'provisional');
}
};
recognizer.onError = () => {
this.updateTranscriptPreview('持續接收語音中...', 'provisional');
};
recognizer.onEnd = () => {};
recognizer.start();
}
stopLocalTranscriptPreview() {
const recognizer = window.speechRecognition;
if (recognizer && recognizer.isSupported) {
recognizer.stop();
}
}
cleanupAudioResources() {
if (this.audioProcessor) {
try {
this.audioProcessor.port.onmessage = null;
this.audioProcessor.disconnect();
} catch (e) {
console.warn('⚠️ 斷開音訊處理器失敗:', e);
}
this.audioProcessor = null;
}
if (this.audioSource) {
try {
this.audioSource.disconnect();
} catch (e) {
console.warn('⚠️ 斷開音訊源失敗:', e);
}
this.audioSource = null;
}
if (this.audioStream) {
this.audioStream.getTracks().forEach(track => track.stop());
this.audioStream = null;
}
if (this.audioContext) {
this.audioContext.close().catch(error => {
console.warn('⚠️ 關閉 AudioContext 失敗:', error);
});
this.audioContext = null;
}
}
async ensurePCMRecorderWorklet() {
if (this.audioWorkletModuleUrl) {
return this.audioWorkletModuleUrl;
}
const workletSource = `
class PCMRecorderProcessor extends AudioWorkletProcessor {
process(inputs) {
const channelData = inputs[0] && inputs[0][0];
if (channelData && channelData.length) {
const pcm16 = new Int16Array(channelData.length);
for (let i = 0; i < channelData.length; i++) {
const sample = Math.max(-1, Math.min(1, channelData[i]));
pcm16[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
}
this.port.postMessage(pcm16.buffer, [pcm16.buffer]);
}
return true;
}
}
registerProcessor('pcm-recorder-processor', PCMRecorderProcessor);
`.trim();
this.audioWorkletModuleUrl = URL.createObjectURL(
new Blob([workletSource], { type: 'application/javascript' })
);
return this.audioWorkletModuleUrl;
}
async startRecording() {
if (this.isRecording) {
console.warn('⚠️ 已經在錄音中');
return false;
}
if (!this.isConnected()) {
console.error('❌ WebSocket 未連接,無法開始錄音');
return false;
}
try {
if (typeof unlockAudioPlayback === 'function') {
unlockAudioPlayback();
}
this.audioStream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
sampleRate: 16000,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 16000
});
const workletModuleUrl = await this.ensurePCMRecorderWorklet();
await this.audioContext.audioWorklet.addModule(workletModuleUrl);
await this.audioContext.resume();
this.audioSource = this.audioContext.createMediaStreamSource(this.audioStream);
this.audioProcessor = new AudioWorkletNode(this.audioContext, 'pcm-recorder-processor', {
numberOfInputs: 1,
numberOfOutputs: 0,
channelCount: 1
});
this.audioSource.connect(this.audioProcessor);
this.send({
type: 'audio_start',
sample_rate: 16000,
mode: 'realtime_chat', // 即時轉錄模式(使用 OpenAI Realtime API)
language: 'auto' // 自動檢測語言(支援:zh/en/id/ja/vi)
});
window.currentSpeechLanguage = 'auto';
this.isRecording = true;
window.realtimeTranscript = '';
// 重置串流 TTS 狀態(開始新對話)
if (typeof resetStreamingTTS === 'function') {
resetStreamingTTS();
}
if (typeof stopSpeaking === 'function') {
stopSpeaking();
}
this.startLocalTranscriptPreview();
this.audioProcessor.port.onmessage = (event) => {
if (!this.isRecording) return;
try {
const bytes = new Uint8Array(event.data);
const b64 = btoa(String.fromCharCode(...bytes));
this.send({
type: 'audio_chunk',
pcm16_base64: b64
});
} catch (error) {
console.error('❌ 音訊處理錯誤:', error);
}
};
return true;
} catch (error) {
console.error('❌ 開始錄音失敗:', error);
if (error.name === 'NotAllowedError') {
if (typeof showErrorNotification === 'function') {
showErrorNotification('需要麥克風權限才能使用語音功能');
}
}
this.cleanupAudioResources();
this.isRecording = false;
return false;
}
}
stopRecording() {
if (!this.isRecording) {
console.warn('⚠️ 目前沒有在錄音');
return;
}
this.cleanupAudioResources();
this.send({
type: 'audio_stop',
mode: 'realtime_chat' // 即時轉錄模式
});
this.stopLocalTranscriptPreview();
this.updateTranscriptPreview('轉錄中...', 'provisional');
this.isRecording = false;
}
}
function initializeWebSocket(token) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const voiceEmotion = localStorage.getItem('voice_login_emotion');
let wsUrl = `${protocol}//${host}/ws?token=${token}`;
if (voiceEmotion) {
wsUrl += `&emotion=${encodeURIComponent(voiceEmotion)}`;
localStorage.removeItem('voice_login_emotion');
if (typeof applyEmotion === 'function') {
applyEmotion(voiceEmotion);
}
}
wsManager = new WebSocketManager(wsUrl);
wsManager.onMessage((data) => {
switch(data.type) {
case 'system':
if (data.chat_id) {
window.currentChatId = data.chat_id;
}
if (data.message) {
setState('speaking', {
outputText: data.message,
enableTTS: false,
persistent: true
});
}
break;
case 'typing':
if (data.message === 'thinking') {
window.currentConversationLanguage = null;
window.currentSpeechLanguage = null;
// 重置串流 TTS(進入思考狀態時停止/重置)
if (typeof resetStreamingTTS === 'function') {
resetStreamingTTS();
}
setState('thinking');
if (typeof hideToolCards === 'function') {
hideToolCards();
}
}
break;
case 'bot_delta':
if (data.language) {
window.currentConversationLanguage = data.language;
window.currentSpeechLanguage = data.language;
} else if (data.text) {
const inferredLanguage = window.inferConversationLanguage(data.text, window.currentSpeechLanguage);
window.currentConversationLanguage = inferredLanguage;
window.currentSpeechLanguage = inferredLanguage;
}
if (typeof currentState !== 'undefined' && currentState !== 'speaking') {
setState('speaking');
} else {
micContainer.classList.add('speaking');
}
if (typeof editAgentOutput === 'function') {
editAgentOutput(data.text || '', true);
}
// 即時句子串流 TTS:逐句合成播放,不等全文
if (typeof enqueueStreamingTTS === 'function') {
enqueueStreamingTTS(data.text || '');
}
break;
case 'bot_status':
if (typeof currentState !== 'undefined' && currentState === 'speaking') {
break;
}
micContainer.classList.add('thinking');
if (typeof editAgentOutput === 'function' && data.message) {
editAgentOutput(data.message, true, {progress: true});
}
break;
case 'bot_message':
if (data.language) {
window.currentConversationLanguage = data.language;
window.currentSpeechLanguage = data.language;
} else if (data.message) {
const inferredLanguage = window.inferConversationLanguage(data.message, window.currentSpeechLanguage);
window.currentConversationLanguage = inferredLanguage;
window.currentSpeechLanguage = inferredLanguage;
}
// 完成串流 TTS(說出最後一段未以標點結尾的殘餘文字)
if (typeof finalizeStreamingTTS === 'function') {
finalizeStreamingTTS(data.message || '');
}
const useFullTtsFallback = typeof shouldFallbackToFullTTS === 'function'
? shouldFallbackToFullTTS()
: false;
const awaitSpeechCompletion = typeof hasPendingStreamingSpeech === 'function'
? hasPendingStreamingSpeech() || useFullTtsFallback
: useFullTtsFallback;
if (data.care_mode && typeof hideToolCards === 'function') {
hideToolCards();
}
if (typeof finishAgentOutput === 'function') {
finishAgentOutput(data.message, useFullTtsFallback, {
awaitSpeechCompletion,
});
} else {
setState('speaking', {
outputText: data.message,
enableTTS: useFullTtsFallback
});
}
if (data.executed_tools && data.executed_tools.length > 0) {
if (typeof displayMultipleToolCards === 'function') {
displayMultipleToolCards(data.executed_tools);
} else if (data.tool_name && data.tool_data) {
displayToolCard(data.tool_name, data.tool_data);
}
} else if (data.tool_name && data.tool_data) {
displayToolCard(data.tool_name, data.tool_data);
}
break;
case 'stt_partial':
transcript.textContent = data.text;
transcript.className = 'voice-transcript provisional';
break;
case 'stt_delta':
transcript.textContent = data.text;
transcript.className = 'voice-transcript realtime';
break;
case 'stt_final':
if (data.text) {
const inferredLanguage = window.inferConversationLanguage(data.text, window.currentSpeechLanguage || navigator.language || 'zh-TW');
window.currentConversationLanguage = inferredLanguage;
window.currentSpeechLanguage = inferredLanguage;
}
transcript.textContent = data.text;
transcript.className = 'voice-transcript final';
window.realtimeTranscript = '';
// 【統一】不在此處套用情緒,只由 emotion_detected 事件控制
break;
case 'stt_status':
if (data.status === 'speech_started') {
if (!transcript.textContent || transcript.textContent === '請說話...') {
transcript.textContent = '聆聽中...';
}
transcript.className = 'voice-transcript realtime';
} else if (data.status === 'speech_stopped') {
transcript.textContent = transcript.textContent || '語音結束,整理轉錄中...';
transcript.className = 'voice-transcript provisional';
} else if (data.status === 'transcribing' || String(data.status || '').startsWith('committed')) {
if (!window.realtimeTranscript) {
transcript.textContent = '轉錄中...';
}
transcript.className = 'voice-transcript provisional';
} else if (data.status === 'idle' || data.status === 'error') {
if (typeof currentState !== 'undefined' && currentState === 'speaking') {
break;
}
if (typeof resetAgent === 'function') {
resetAgent();
}
}
break;
case 'realtime_stt_status':
if (data.status === 'connected') {
window.realtimeTranscript = '';
if (data.language) {
window.currentSpeechLanguage = data.language;
window.currentConversationLanguage = data.language;
}
transcript.textContent = '聆聽中...';
transcript.className = 'voice-transcript provisional';
}
break;
case 'chat_ready':
window.currentChatId = data.chat_id;
break;
case 'error':
console.error('❌ 後端錯誤:', data.message);
if (typeof resetAgent === 'function') {
resetAgent();
} else {
setState('idle');
}
showErrorNotification(data.message);
break;
case 'voice_login_result':
handleVoiceLoginResult(data);
break;
case 'voice_login_status':
break;
case 'emotion_detected':
if (data.emotion && typeof applyEmotion === 'function') {
applyEmotion(data.emotion, data.care_mode);
}
if (data.care_mode && typeof hideToolCards === 'function') {
hideToolCards();
}
break;
case 'audio_emotion_detected':
// 【統一】不在此處套用情緒,只由 emotion_detected 事件控制
// 後端會融合音頻和文字情緒後統一發送 emotion_detected
break;
case 'env_ack':
break;
default:
if (window.DEBUG_MODE) {
}
}
});
wsManager.onOnlineStateChange((isOnline) => {
if (!isOnline) {
setState('disconnected');
} else if (currentState === 'disconnected') {
setState('idle');
}
});
wsManager.connect();
}
function handleVoiceLoginResult(data) {
if (data.success && data.user) {
currentUserId = data.user.id;
if (data.emotion && data.emotion.label) {
applyEmotion(data.emotion.label);
}
if (data.welcome) {
const welcomeLang = window.inferConversationLanguage(data.welcome, 'zh-TW');
window.currentConversationLanguage = welcomeLang;
window.currentSpeechLanguage = welcomeLang;
setState('speaking', {
outputText: data.welcome,
enableTTS: true
});
setTimeout(() => setState('idle'), 5000);
}
} else {
console.warn('⚠️ 語音登入失敗:', data.error);
showErrorNotification(`語音登入失敗: ${data.error || '未知錯誤'}`);
}
}