Spaces:
Sleeping
Sleeping
feat: overhaul MCP architecture with structured tool schemas, comprehensive care-mode skill definitions, and enhanced test coverage for pipelines and service integration.
79df050 | window.currentState = 'idle'; | |
| window.thinkingTimeout = null; | |
| window.stateBufferTimeout = null; | |
| /** | |
| * 設置 Agent 狀態,加入緩衝機制防止動畫閃爍 | |
| */ | |
| function setState(newState, options = {}) { | |
| if (window.currentState === newState && !window.stateBufferTimeout) { | |
| return; | |
| } | |
| // 如果有待處理的狀態轉換,先清除它 | |
| if (window.stateBufferTimeout) { | |
| clearTimeout(window.stateBufferTimeout); | |
| window.stateBufferTimeout = null; | |
| } | |
| // 針對進入 idle 狀態加入微小延遲,避免在快速切換(如 speaking -> idle -> recording)時花瓣動畫跳轉 | |
| if (newState === 'idle') { | |
| window.stateBufferTimeout = setTimeout(() => { | |
| window.stateBufferTimeout = null; | |
| applyStateChange('idle', options); | |
| }, 150); | |
| } else { | |
| applyStateChange(newState, options); | |
| } | |
| } | |
| /** | |
| * 實際執行狀態變更與 UI 更新 | |
| */ | |
| function applyStateChange(newState, options) { | |
| const oldState = window.currentState; | |
| window.currentState = newState; | |
| console.log(`🔄 狀態切換: ${oldState} -> ${newState}`); | |
| // 清除之前的思考超時計時器 | |
| if (window.thinkingTimeout) { | |
| clearTimeout(window.thinkingTimeout); | |
| window.thinkingTimeout = null; | |
| } | |
| // 更新麥克風容器樣式 | |
| micContainer.classList.remove('recording', 'thinking', 'speaking', 'disconnected'); | |
| switch(newState) { | |
| case 'idle': | |
| // idle 狀態不應主動隱藏輸出,讓使用者能看清最後的回覆 | |
| if (typeof stopSpeaking === 'function') { | |
| stopSpeaking(true, 'state_idle'); | |
| } | |
| if (options.clearCards !== false) { | |
| clearAllCards(); | |
| } | |
| break; | |
| case 'recording': | |
| micContainer.classList.add('recording'); | |
| if (!options.keepOutput) { | |
| hideAgentOutput(); | |
| } | |
| if (!options.keepCards) { | |
| clearAllCards(); | |
| } | |
| transcript.textContent = '聆聽中...'; | |
| transcript.className = 'voice-transcript provisional'; | |
| break; | |
| case 'thinking': | |
| micContainer.classList.add('thinking'); | |
| hideAgentOutput(); | |
| if (typeof stopSpeaking === 'function') { | |
| stopSpeaking(true, 'state_thinking'); | |
| } | |
| // 設定思考超時重置 (45秒) | |
| window.thinkingTimeout = setTimeout(() => { | |
| if (window.currentState === 'thinking') { | |
| console.warn('⚠️ 思考時間過長,自動重置'); | |
| showErrorNotification('抱歉,處理時間過長,請再試一次。'); | |
| resetAgent({clearCards: false}); | |
| } | |
| }, 45000); | |
| break; | |
| case 'speaking': | |
| micContainer.classList.add('speaking'); | |
| if (options.outputText) { | |
| typewriterEffect(options.outputText, 40, options.enableTTS); | |
| } | |
| break; | |
| case 'disconnected': | |
| micContainer.classList.add('disconnected'); | |
| hideAgentOutput(); | |
| if (typeof stopSpeaking === 'function') { | |
| stopSpeaking(true, 'state_disconnected'); | |
| } | |
| clearAllCards(); | |
| break; | |
| default: | |
| console.error(`❌ 未知狀態: ${newState}`); | |
| } | |
| } | |
| window.currentEmotion = 'neutral'; | |
| window.isInCareMode = false; | |
| function applyEmotion(emotion, careMode = null) { | |
| if (careMode !== null) { | |
| window.isInCareMode = !!careMode; | |
| } | |
| const validEmotions = ['neutral', 'happy', 'sad', 'angry', 'fear', 'surprise']; | |
| // 🎯 支援多語言/原始標籤映射,確保 100% 信心 | |
| const mapping = { | |
| '悲傷(sad)': 'sad', '悲傷': 'sad', | |
| '開心(happy)': 'happy', '開心': 'happy', | |
| '生氣(angry)': 'angry', '生氣': 'angry', | |
| '恐懼(fear)': 'fear', '恐懼': 'fear', | |
| '驚訝(surprise)': 'surprise', '驚訝': 'surprise', | |
| '中性(neutral)': 'neutral', '中性': 'neutral' | |
| }; | |
| if (mapping[emotion]) { | |
| emotion = mapping[emotion]; | |
| } | |
| if (!validEmotions.includes(emotion)) { | |
| emotion = 'neutral'; | |
| } | |
| background.className = `voice-immersive-background emotion-${emotion} active`; | |
| emotionIndicator.textContent = `當前情緒: ${emotionEmojis[emotion]}`; | |
| // 🎯 保存到全域狀態,供 TTS 使用 | |
| window.currentEmotion = emotion; | |
| } | |
| function showErrorNotification(message) { | |
| console.error('🚨 錯誤:', message); | |
| // 立即重置 Agent 狀態(關閉花朵,停止錄音/語音) | |
| resetAgent({ clearCards: false }); | |
| // 顯示錯誤訊息於輸出區域,但不切換到 speaking 狀態(讓花朵保持 idle) | |
| if (typeof typewriterEffect === 'function') { | |
| typewriterEffect(`抱歉,發生錯誤:${message}`, 40, false); | |
| } | |
| // 3秒後自動隱藏錯誤訊息 | |
| setTimeout(() => { | |
| if (currentState === 'idle') { | |
| hideAgentOutput(); | |
| } | |
| }, 5000); | |
| } | |
| let isThinking = false; | |
| let isDisconnected = false; | |
| let isRecording = false; | |
| let isSpeaking = false; | |
| function resetAgent(options = {}) { | |
| isRecording = false; | |
| isThinking = false; | |
| isSpeaking = false; | |
| isDisconnected = false; | |
| if (typeof stopSpeaking === 'function') { | |
| stopSpeaking(true, 'reset_agent'); | |
| } | |
| if (typeof stopRealAudioAnalysis === 'function') { | |
| stopRealAudioAnalysis(); | |
| } | |
| if (wsManager && typeof wsManager.stopRecording === 'function') { | |
| wsManager.stopRecording(); | |
| } | |
| if (typeof transcript !== 'undefined' && transcript) { | |
| transcript.textContent = ''; | |
| } | |
| setState('idle', options); | |
| } | |
| function initAgentControls() { | |
| const handleMicInteraction = async () => { | |
| if (currentState === 'recording') { | |
| isRecording = false; | |
| if (typeof stopRealAudioAnalysis === 'function') { | |
| stopRealAudioAnalysis(); | |
| } | |
| if (wsManager && typeof wsManager.stopRecording === 'function') { | |
| wsManager.stopRecording(); | |
| } | |
| setState('thinking'); | |
| return; | |
| } | |
| if (currentState === 'idle' || currentState === 'disconnected' || currentState === 'speaking') { | |
| if (currentState === 'speaking' && typeof stopSpeaking === 'function') { | |
| stopSpeaking(true, 'mic_interrupt'); | |
| } | |
| isRecording = true; | |
| setState('recording', { | |
| keepOutput: true, // 保留前次 Agent 回應 | |
| keepCards: true, // 保留前次工具卡片 | |
| detect_timeout: 20.0, // 考量到 Function Calling 可能較慢 | |
| feature_timeout: 30.0, // MCP 工具內部超時 | |
| ai_timeout: 25.0 // 配合 Streaming | |
| }); | |
| if (typeof startRealAudioAnalysis === 'function') { | |
| await startRealAudioAnalysis(); | |
| } | |
| if (wsManager && typeof wsManager.startRecording === 'function') { | |
| const success = await wsManager.startRecording(); | |
| if (!success) { | |
| console.error('❌ 錄音啟動失敗'); | |
| resetAgent(); | |
| } | |
| } else { | |
| console.error('❌ WebSocket 管理器未初始化'); | |
| resetAgent(); | |
| } | |
| } | |
| }; | |
| // 點擊麥克風中心 | |
| micContainer.addEventListener('click', handleMicInteraction); | |
| // 點擊波形容器(較大區域)也觸發交互,提高可用性 | |
| const waveformContainer = document.querySelector('.voice-waveform-container'); | |
| if (waveformContainer) { | |
| waveformContainer.style.cursor = 'pointer'; | |
| waveformContainer.addEventListener('click', (e) => { | |
| // 如果點擊的是 micContainer 內部,就不重複觸發(事件冒泡) | |
| if (e.target === micContainer || micContainer.contains(e.target)) { | |
| return; | |
| } | |
| handleMicInteraction(); | |
| }); | |
| } | |
| document.getElementById('toggle-recording').addEventListener('click', async () => { | |
| isRecording = !isRecording; | |
| if (isRecording) { | |
| setState('recording'); | |
| await startRealAudioAnalysis(); | |
| } else { | |
| setState('idle'); | |
| stopRealAudioAnalysis(); | |
| } | |
| }); | |
| document.getElementById('toggle-thinking').addEventListener('click', () => { | |
| isThinking = !isThinking; | |
| if (isThinking) { | |
| setState('thinking'); | |
| } else { | |
| setState('idle', {clearCards: false}); // 保留工具卡片 | |
| } | |
| }); | |
| document.getElementById('toggle-speaking').addEventListener('click', () => { | |
| isSpeaking = !isSpeaking; | |
| if (isSpeaking) { | |
| clearAllCards(); | |
| setTimeout(() => addToolCard('weather'), 300); | |
| const responseText = '根據目前的天氣資料,台北今天氣溫約 23°C,天氣晴朗,濕度 65%。建議您外出時可以穿著輕便舒適的衣物,並記得攜帶太陽眼鏡。'; | |
| setState('speaking', {outputText: responseText}); | |
| } else { | |
| setState('idle', {clearCards: false}); // 保留工具卡片 | |
| } | |
| }); | |
| document.getElementById('toggle-disconnected').addEventListener('click', () => { | |
| isDisconnected = !isDisconnected; | |
| if (isDisconnected) { | |
| setState('disconnected'); | |
| } else { | |
| setState('idle'); | |
| } | |
| }); | |
| } | |