File size: 8,782 Bytes
79df050
 
 
3f0377e
79df050
 
 
3f0377e
79df050
3f0377e
 
 
79df050
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f0377e
79df050
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f0377e
 
 
 
79df050
3f0377e
79df050
3f0377e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79df050
3f0377e
79df050
 
 
 
 
 
 
 
 
3f0377e
 
 
 
 
 
 
 
 
 
 
 
 
79df050
3f0377e
 
 
 
 
 
 
 
 
79df050
 
 
 
 
 
 
3f0377e
79df050
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0d99f82
 
3f0377e
 
0d99f82
 
79df050
 
 
3f0377e
 
 
 
 
79df050
 
3f0377e
79df050
 
 
 
 
 
 
 
 
 
 
3f0377e
 
 
 
 
 
 
 
79df050
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f0377e
79df050
3f0377e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79df050
3f0377e
 
 
 
 
79df050
 
 
 
3f0377e
 
 
 
 
 
 
 
 
 
79df050
3f0377e
 
 
79df050
3f0377e
 
79df050
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f0377e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
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');
    }
  });
}