Spaces:
Running
Running
| 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.isRecording = false; | |
| 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); | |
| } | |
| 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 payload = { | |
| type: 'user_message', | |
| message: text, | |
| chat_id: chatId | |
| }; | |
| 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); | |
| } | |
| }); | |
| } | |
| 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 | |
| }); | |
| this.audioSource = this.audioContext.createMediaStreamSource(this.audioStream); | |
| this.audioProcessor = this.audioContext.createScriptProcessor(4096, 1, 1); | |
| this.audioSource.connect(this.audioProcessor); | |
| this.audioProcessor.connect(this.audioContext.destination); | |
| this.send({ | |
| type: 'audio_start', | |
| sample_rate: 16000, | |
| mode: 'realtime_chat', // 即時轉錄模式(使用 OpenAI Realtime API) | |
| language: 'auto' // 自動檢測語言(支援:zh/en/id/ja/vi) | |
| }); | |
| this.isRecording = true; | |
| this.audioProcessor.onaudioprocess = (e) => { | |
| if (!this.isRecording) return; | |
| try { | |
| const inputData = e.inputBuffer.getChannelData(0); | |
| const pcm16 = new Int16Array(inputData.length); | |
| for (let i = 0; i < inputData.length; i++) { | |
| let sample = Math.max(-1, Math.min(1, inputData[i])); | |
| pcm16[i] = sample < 0 ? sample * 0x8000 : sample * 0x7FFF; | |
| } | |
| const bytes = new Uint8Array(pcm16.buffer); | |
| 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.isRecording = false; | |
| return false; | |
| } | |
| } | |
| stopRecording() { | |
| if (!this.isRecording) { | |
| console.warn('⚠️ 目前沒有在錄音'); | |
| return; | |
| } | |
| if (this.audioProcessor) { | |
| this.audioProcessor.disconnect(); | |
| 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(); | |
| this.audioContext = null; | |
| } | |
| this.send({ | |
| type: 'audio_stop', | |
| mode: 'realtime_chat' // 即時轉錄模式 | |
| }); | |
| 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') { | |
| setState('thinking'); | |
| if (typeof hideToolCards === 'function') { | |
| hideToolCards(); | |
| } | |
| } | |
| break; | |
| case 'bot_message': | |
| // 【統一】不在此處套用情緒,只由 emotion_detected 事件控制 | |
| // 保留情緒資訊在 data 中供調試使用 | |
| if (data.care_mode && typeof hideToolCards === 'function') { | |
| hideToolCards(); | |
| } | |
| setState('speaking', { | |
| outputText: data.message, | |
| enableTTS: true | |
| }); | |
| 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': | |
| if (!window.realtimeTranscript) { | |
| window.realtimeTranscript = ''; | |
| } | |
| window.realtimeTranscript += data.text; | |
| transcript.textContent = window.realtimeTranscript; | |
| transcript.className = 'voice-transcript realtime'; | |
| break; | |
| case 'stt_final': | |
| transcript.textContent = data.text; | |
| transcript.className = 'voice-transcript final'; | |
| window.realtimeTranscript = ''; | |
| // 【統一】不在此處套用情緒,只由 emotion_detected 事件控制 | |
| break; | |
| case 'realtime_stt_status': | |
| if (data.status === 'connected') { | |
| window.realtimeTranscript = ''; | |
| } | |
| break; | |
| case 'chat_ready': | |
| window.currentChatId = data.chat_id; | |
| break; | |
| case 'error': | |
| console.error('❌ 後端錯誤:', data.message); | |
| 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); | |
| } | |
| 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) { | |
| setState('speaking', { | |
| outputText: data.welcome, | |
| enableTTS: true | |
| }); | |
| setTimeout(() => setState('idle'), 5000); | |
| } | |
| } else { | |
| console.warn('⚠️ 語音登入失敗:', data.error); | |
| showErrorNotification(`語音登入失敗: ${data.error || '未知錯誤'}`); | |
| } | |
| } | |