XiaoBai1221 commited on
Commit
6c78660
·
1 Parent(s): d672369
app.py CHANGED
@@ -70,6 +70,7 @@ from features.mcp.agent_bridge import MCPAgentBridge
70
  # from features.daily_life.time_service import get_current_time_data, format_time_for_messages # 已整合到 MCPAgentBridge
71
  from services.voice_login import VoiceAuthService, VoiceLoginConfig
72
  from services.welcome import compose_welcome
 
73
  from core.pipeline import ChatPipeline, PipelineResult
74
  from core.memory_system import memory_manager
75
  # 環境 Context 寫入 API
@@ -584,18 +585,37 @@ async def websocket_endpoint_with_jwt(
584
  logger.info(f"處理用戶消息 req_id={request_id} user_id={user_id} chat_id={chat_id}")
585
 
586
  async def _do_process_and_send():
587
- response = await handle_message(user_message, user_id, chat_id, messages_for_handler, request_id=request_id)
588
- if not response or str(response).strip() == "":
589
- logger.warning("AI回應為空,使用後備提示")
590
- response = "抱歉,我暫時沒有合適的回應。可以換個說法再試試嗎?"
591
-
592
- # 檢查是否為 dict(包含工具資訊、情緒等)
593
- if isinstance(response, dict):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
594
  tool_name = response.get('tool_name')
595
  tool_data = response.get('tool_data')
596
  message_text = response.get('message', response.get('content', ''))
597
- emotion = response.get('emotion') # 新增:提取情緒
598
- care_mode = response.get('care_mode', False) # 新增:提取關懷模式
 
 
599
 
600
  if care_mode:
601
  tool_name = None
@@ -605,17 +625,18 @@ async def websocket_endpoint_with_jwt(
605
  if tool_data is not None:
606
  tool_data = serialize_for_json(tool_data)
607
 
608
- # 先發送情緒資訊(如果有)
609
- if emotion:
610
- await websocket.send_json({
611
- "type": "emotion_detected",
612
- "emotion": emotion,
613
- "care_mode": care_mode
614
- })
615
- logger.info(f"😊 發送情緒給前端: {emotion}, care_mode={care_mode}")
 
616
 
617
  # 發送擴充格式的 bot_message
618
- await websocket.send_json({
619
  "type": "bot_message",
620
  "message": message_text,
621
  "timestamp": time.time(),
@@ -623,23 +644,25 @@ async def websocket_endpoint_with_jwt(
623
  "tool_data": tool_data,
624
  "care_mode": care_mode,
625
  "emotion": emotion,
626
- })
627
- else:
628
- # 舊格式(純文字)
629
- await manager.send_message(response, user_id, "bot_message")
630
 
631
- if new_chat_info:
632
- await websocket.send_json({
633
- "type": "new_chat_created",
634
- "chat_id": new_chat_info["chat_id"],
635
- "title": new_chat_info["title"]
636
- })
637
 
638
- # 保存訊息(只儲存文字內容)
639
- await save_message_to_db(user_id, chat_id, "user", user_message)
640
- # 如果 response 是 dict,只保存 message 欄位
641
- message_to_save = response.get('message', response) if isinstance(response, dict) else response
642
- await save_message_to_db(user_id, chat_id, "assistant", message_to_save)
 
 
643
 
644
  import asyncio as _asyncio
645
  _asyncio.create_task(_do_process_and_send())
@@ -1064,8 +1087,53 @@ async def websocket_endpoint_with_jwt(
1064
  if transcription:
1065
  logger.info(f"🤖 處理即時轉錄結果: {transcription}")
1066
 
1067
- # 音頻情緒辨識已禁用(改用文字情緒偵測)
1068
  audio_emotion = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1069
  # 清理音頻緩衝
1070
  if audio_buffer:
1071
  client_info.pop("audio_buffer", None)
@@ -1324,20 +1392,17 @@ async def handle_message(user_message, user_id, chat_id, messages, request_id: s
1324
 
1325
  logger.info(f"🎭 handle_message 情緒: emotion={emotion}, care_mode={care_mode}, meta={res.meta}")
1326
 
1327
- # 立即返回完整結果(包含工具信息與情緒)
1328
- # 注意:即使 emotion 是 "neutral" 也要返回 dict,確保前端收到情緒資訊
1329
- if tool_name or tool_data or emotion or care_mode:
1330
- logger.info(f"📤 返回 dict 格式: emotion={emotion}")
1331
- return {
1332
- 'message': res.text,
1333
- 'tool_name': tool_name,
1334
- 'tool_data': tool_data,
1335
- 'emotion': emotion,
1336
- 'care_mode': care_mode
1337
- }
1338
- else:
1339
- logger.info(f"📤 返回純文字格式(無情緒資訊)")
1340
- return res.text
1341
 
1342
 
1343
  async def save_message_to_db(user_id, chat_id, role, content, background: bool = True):
@@ -2518,21 +2583,21 @@ if __name__ == "__main__":
2518
  # 生產模式:關閉 reload(提升效能與穩定性)
2519
  # 開發時如需熱重載,改為:reload=True
2520
  import sys
2521
- print("\n" + "="*60)
2522
- print("🚀 Bloom Ware 後端服務器啟動中...")
2523
- print("="*60)
2524
- print(f"📡 監聽所有網路接口: {host}:{port}")
2525
- print(f"🌐 可用的訪問地址:")
2526
- print(f" • 本機: http://127.0.0.1:{port}")
2527
  try:
2528
  import socket
2529
  hostname = socket.gethostname()
2530
  local_ips = [ip for ip in socket.gethostbyname_ex(hostname)[2] if not ip.startswith("127.")]
2531
  for ip in local_ips:
2532
- print(f" • 局域網: http://{ip}:{port}")
2533
  except:
2534
  pass
2535
- print("="*60 + "\n")
2536
 
2537
- # 生產模式:reload=False, log_level="warning"(只顯示警告和錯誤)
2538
- uvicorn.run("app:app", host=host, port=port, reload=False, log_level="warning")
 
70
  # from features.daily_life.time_service import get_current_time_data, format_time_for_messages # 已整合到 MCPAgentBridge
71
  from services.voice_login import VoiceAuthService, VoiceLoginConfig
72
  from services.welcome import compose_welcome
73
+ from services.audio_emotion_service import predict_emotion_from_audio
74
  from core.pipeline import ChatPipeline, PipelineResult
75
  from core.memory_system import memory_manager
76
  # 環境 Context 寫入 API
 
585
  logger.info(f"處理用戶消息 req_id={request_id} user_id={user_id} chat_id={chat_id}")
586
 
587
  async def _do_process_and_send():
588
+ try:
589
+ logger.info(f"🚀 開始處理訊息: user_id={user_id}, chat_id={chat_id}")
590
+ response = await handle_message(user_message, user_id, chat_id, messages_for_handler, request_id=request_id)
591
+ logger.info(f"📥 handle_message 返回: type={type(response)}, response={response}")
592
+
593
+ # 【優化】處理空回應:轉換為帶情緒的 dict 格式
594
+ if not response or (isinstance(response, str) and not response.strip()):
595
+ logger.warning("AI回應為空,使用後備提示")
596
+ response = {
597
+ 'message': "抱歉,我暫時沒有合適的回應。可以換個說法再試試嗎?",
598
+ 'emotion': 'neutral',
599
+ 'care_mode': False
600
+ }
601
+
602
+ # 【優化】統一轉換為 dict 格式(處理舊版兼容)
603
+ if isinstance(response, str):
604
+ logger.info(f"⚠️ response 是字串,轉換為 dict")
605
+ response = {
606
+ 'message': response,
607
+ 'emotion': 'neutral',
608
+ 'care_mode': False
609
+ }
610
+
611
+ # 提取資訊
612
  tool_name = response.get('tool_name')
613
  tool_data = response.get('tool_data')
614
  message_text = response.get('message', response.get('content', ''))
615
+ emotion = response.get('emotion', 'neutral') # 預設 neutral
616
+ care_mode = response.get('care_mode', False)
617
+
618
+ logger.info(f"🎭 提取的情緒: emotion={emotion}, care_mode={care_mode}")
619
 
620
  if care_mode:
621
  tool_name = None
 
625
  if tool_data is not None:
626
  tool_data = serialize_for_json(tool_data)
627
 
628
+ # 【關鍵】總是發送情緒資訊,確保前端即時更新
629
+ emotion_payload = {
630
+ "type": "emotion_detected",
631
+ "emotion": emotion,
632
+ "care_mode": care_mode
633
+ }
634
+ logger.info(f"📤 準備發送 emotion_detected: {emotion_payload}")
635
+ await websocket.send_json(emotion_payload)
636
+ logger.info(f"✅ emotion_detected 已發送: {emotion}, care_mode={care_mode}")
637
 
638
  # 發送擴充格式的 bot_message
639
+ bot_payload = {
640
  "type": "bot_message",
641
  "message": message_text,
642
  "timestamp": time.time(),
 
644
  "tool_data": tool_data,
645
  "care_mode": care_mode,
646
  "emotion": emotion,
647
+ }
648
+ logger.info(f"📤 準備發送 bot_message")
649
+ await websocket.send_json(bot_payload)
650
+ logger.info(f"✅ bot_message 已發送")
651
 
652
+ if new_chat_info:
653
+ await websocket.send_json({
654
+ "type": "new_chat_created",
655
+ "chat_id": new_chat_info["chat_id"],
656
+ "title": new_chat_info["title"]
657
+ })
658
 
659
+ # 保存訊息(只儲存文字內容)
660
+ await save_message_to_db(user_id, chat_id, "user", user_message)
661
+ # 如果 response 是 dict,只保存 message 欄位
662
+ message_to_save = response.get('message', response) if isinstance(response, dict) else response
663
+ await save_message_to_db(user_id, chat_id, "assistant", message_to_save)
664
+ except Exception as e:
665
+ logger.exception(f"❌ _do_process_and_send 發生異常: {e}")
666
 
667
  import asyncio as _asyncio
668
  _asyncio.create_task(_do_process_and_send())
 
1087
  if transcription:
1088
  logger.info(f"🤖 處理即時轉錄結果: {transcription}")
1089
 
1090
+ # === 方案 B:語音情緒辨識(情緒分佈驗證 + 智能回退)===
1091
  audio_emotion = None
1092
+ if audio_buffer and len(audio_buffer) >= 16000 * 2: # 至少 1 秒
1093
+ try:
1094
+ logger.info(f"🎭 開始語音情緒辨識,音訊長度: {len(audio_buffer)} bytes")
1095
+ emotion_result = await predict_emotion_from_audio(audio_buffer, sample_rate=16000)
1096
+
1097
+ if emotion_result.get("success"):
1098
+ emotion_label = emotion_result.get("emotion", "neutral")
1099
+ confidence = emotion_result.get("confidence", 0.0)
1100
+ all_emotions = emotion_result.get("all_emotions", {})
1101
+
1102
+ # 計算 top-1 與 top-2 的 margin
1103
+ sorted_emotions = sorted(all_emotions.items(), key=lambda x: x[1], reverse=True)
1104
+ margin = sorted_emotions[0][1] - sorted_emotions[1][1] if len(sorted_emotions) >= 2 else confidence
1105
+
1106
+ # 方案 B 判斷邏輯
1107
+ use_audio_emotion = False
1108
+ reason = ""
1109
+
1110
+ if emotion_label == "neutral":
1111
+ # neutral 需要更高置信度,但 margin 可較寬鬆
1112
+ if confidence >= 0.55 and margin >= 0.12:
1113
+ use_audio_emotion = True
1114
+ reason = f"neutral 高信心 (conf={confidence:.3f}, margin={margin:.3f})"
1115
+ else:
1116
+ reason = f"neutral 信心不足 (conf={confidence:.3f}, margin={margin:.3f}) → 回退文字"
1117
+ else:
1118
+ # 非 neutral 需要足夠 confidence 與 margin
1119
+ if confidence >= 0.48 and margin >= 0.18:
1120
+ use_audio_emotion = True
1121
+ reason = f"{emotion_label} 高信心 (conf={confidence:.3f}, margin={margin:.3f})"
1122
+ else:
1123
+ reason = f"{emotion_label} 信心不足 (conf={confidence:.3f}, margin={margin:.3f}) → 回退文字"
1124
+
1125
+ if use_audio_emotion:
1126
+ audio_emotion = emotion_result
1127
+ logger.info(f"✅ 使用語音情緒: {emotion_label}, {reason}")
1128
+ else:
1129
+ audio_emotion = None
1130
+ logger.info(f"📝 {reason}")
1131
+ else:
1132
+ logger.warning(f"⚠️ 語音情緒辨識失敗: {emotion_result.get('error')}")
1133
+ except Exception as e:
1134
+ logger.error(f"❌ 語音情緒辨識異常: {e}")
1135
+ audio_emotion = None
1136
+
1137
  # 清理音頻緩衝
1138
  if audio_buffer:
1139
  client_info.pop("audio_buffer", None)
 
1392
 
1393
  logger.info(f"🎭 handle_message 情緒: emotion={emotion}, care_mode={care_mode}, meta={res.meta}")
1394
 
1395
+ # 【優化】總是返回 dict 格式,確保前端一定收到情緒資訊
1396
+ # 即使沒有工具調用,也要包含 emotion(預設 neutral
1397
+ final_emotion = emotion if emotion else "neutral"
1398
+ logger.info(f"📤 返回 dict 格式: emotion={final_emotion}, care_mode={care_mode}")
1399
+ return {
1400
+ 'message': res.text,
1401
+ 'tool_name': tool_name,
1402
+ 'tool_data': tool_data,
1403
+ 'emotion': final_emotion,
1404
+ 'care_mode': care_mode
1405
+ }
 
 
 
1406
 
1407
 
1408
  async def save_message_to_db(user_id, chat_id, role, content, background: bool = True):
 
2583
  # 生產模式:關閉 reload(提升效能與穩定性)
2584
  # 開發時如需熱重載,改為:reload=True
2585
  import sys
2586
+ logger.info("\n" + "="*60)
2587
+ logger.info("🚀 Bloom Ware 後端服務器啟動中...")
2588
+ logger.info("="*60)
2589
+ logger.info(f"📡 監聽所有網路接口: {host}:{port}")
2590
+ logger.info(f"🌐 可用的訪問地址:")
2591
+ logger.info(f" • 本機: http://127.0.0.1:{port}")
2592
  try:
2593
  import socket
2594
  hostname = socket.gethostname()
2595
  local_ips = [ip for ip in socket.gethostbyname_ex(hostname)[2] if not ip.startswith("127.")]
2596
  for ip in local_ips:
2597
+ logger.info(f" • 局域網: http://{ip}:{port}")
2598
  except:
2599
  pass
2600
+ logger.info("="*60 + "\n")
2601
 
2602
+ # 生產模式:reload=False, log_level="error"(只顯示錯誤),關閉 uvicorn access log
2603
+ uvicorn.run("app:app", host=host, port=port, reload=False, log_level="error", access_log=False)
core/config.py CHANGED
@@ -171,47 +171,59 @@ class Settings:
171
  missing_fields = [name for name, value in required_fields if not value]
172
 
173
  if missing_fields:
174
- print(f"⚠️ 缺少必要環境變數: {', '.join(missing_fields)}")
175
- print("請檢查以下選項:")
176
- print("1. 環境變數是否正確設定")
177
- print("2. .env 檔案是否存在且格式正確")
178
- print("3. 生產環境中是否在部署平台設定了環境變數")
 
 
179
  return False
180
 
181
  # 驗證 Firebase 憑證
182
  try:
183
  cls.get_firebase_credentials()
184
  except ValueError as e:
185
- print(f"⚠️ Firebase 憑證驗證失敗: {e}")
186
- print("請檢查 FIREBASE_CREDENTIALS_JSON 或 FIREBASE_SERVICE_ACCOUNT_PATH")
 
 
187
  return False
188
 
189
  # 驗證 OpenAI API Key 格式(基本檢查)
190
  if not cls.OPENAI_API_KEY.startswith("sk-"):
191
- print("⚠️ OpenAI API Key 格式可能不正確(應以 'sk-' 開頭)")
 
 
192
 
193
  # 驗證 JWT Secret 長度(強制檢查)
194
  if len(cls.JWT_SECRET_KEY) < cls.JWT_SECRET_MIN_LENGTH:
195
- print(f"❌ JWT Secret Key 長度必須至少 {cls.JWT_SECRET_MIN_LENGTH} 個字符")
 
 
196
  if cls.IS_PRODUCTION:
197
  return False
198
- print("⚠️ 開發環境允許繼續,但生產環境將拒絕啟動")
199
 
200
  # 生產環境 CORS 檢查
201
  if cls.IS_PRODUCTION and cls._cors_origins_raw == "*":
202
- print("⚠️ 生產環境建議設定具體的 CORS_ORIGINS,而非 '*'")
 
 
203
 
204
  return True
205
 
206
  @classmethod
207
  def print_summary(cls) -> None:
208
  """列印當前配置摘要(隱藏敏感資訊)"""
209
- print("\n" + "=" * 60)
210
- print("📋 Bloom Ware 配置摘要")
211
- print("=" * 60)
212
- print(f"環境模式: {cls.ENVIRONMENT}")
213
- print(f"是否為生產環境: {cls.IS_PRODUCTION}")
214
- print(f"Firebase 專案 ID: {cls.FIREBASE_PROJECT_ID}")
 
 
215
 
216
  # 判斷 Firebase 憑證來源
217
  if cls._firebase_creds_json:
@@ -222,20 +234,20 @@ class Settings:
222
  firebase_source = "檔案"
223
  else:
224
  firebase_source = "未設定 ❌"
225
- print(f"Firebase 憑證來源: {firebase_source}")
226
- print(f"OpenAI 模型: {cls.OPENAI_MODEL}")
227
- print(f"OpenAI Timeout: {cls.OPENAI_TIMEOUT}s")
228
- print(f"Google OAuth 回調 URI: {cls.GOOGLE_REDIRECT_URI}")
229
- print(f"JWT Token 有效期: {cls.ACCESS_TOKEN_EXPIRE_MINUTES} 分鐘")
230
- print(f"伺服器監聽: {cls.HOST}:{cls.PORT}")
231
- print(f"使用 GPT 意圖檢測: {cls.USE_GPT_INTENT}")
232
- print(f"Weather API Key: {'已設定 ✅' if cls.WEATHER_API_KEY else '未設定 ❌'}")
233
- print(f"NewsData API Key: {'已設定 ✅' if cls.NEWSDATA_API_KEY else '未設定 ❌'}")
234
- print(f"Exchange API Key: {'已設定 ✅' if cls.EXCHANGE_API_KEY else '未設定 ❌'}")
235
- print(f"環境節流距離: {cls.ENV_CONTEXT_DISTANCE_THRESHOLD} m")
236
- print(f"環境節流方位差: {cls.ENV_CONTEXT_HEADING_THRESHOLD}°")
237
- print(f"環境快取 TTL: {cls.ENV_CONTEXT_TTL_SECONDS} 秒")
238
- print("=" * 60 + "\n")
239
 
240
 
241
  # 建立全域設定實例(單例模式)
 
171
  missing_fields = [name for name, value in required_fields if not value]
172
 
173
  if missing_fields:
174
+ import logging
175
+ logger = logging.getLogger("core.config")
176
+ logger.error(f"⚠️ 缺少必要環境變數: {', '.join(missing_fields)}")
177
+ logger.error("請檢查以下選項:")
178
+ logger.error("1. 環境變數是否正確設定")
179
+ logger.error("2. .env 檔案是否存在且格式正確")
180
+ logger.error("3. 生產環境中是否在部署平台設定了環境變數")
181
  return False
182
 
183
  # 驗證 Firebase 憑證
184
  try:
185
  cls.get_firebase_credentials()
186
  except ValueError as e:
187
+ import logging
188
+ logger = logging.getLogger("core.config")
189
+ logger.error(f"⚠️ Firebase 憑證驗證失敗: {e}")
190
+ logger.error("請檢查 FIREBASE_CREDENTIALS_JSON 或 FIREBASE_SERVICE_ACCOUNT_PATH")
191
  return False
192
 
193
  # 驗證 OpenAI API Key 格式(基本檢查)
194
  if not cls.OPENAI_API_KEY.startswith("sk-"):
195
+ import logging
196
+ logger = logging.getLogger("core.config")
197
+ logger.warning("⚠️ OpenAI API Key 格式可能不正確(應以 'sk-' 開頭)")
198
 
199
  # 驗證 JWT Secret 長度(強制檢查)
200
  if len(cls.JWT_SECRET_KEY) < cls.JWT_SECRET_MIN_LENGTH:
201
+ import logging
202
+ logger = logging.getLogger("core.config")
203
+ logger.error(f"❌ JWT Secret Key 長度必須至少 {cls.JWT_SECRET_MIN_LENGTH} 個字符")
204
  if cls.IS_PRODUCTION:
205
  return False
206
+ logger.warning("⚠️ 開發環境允許繼續,但生產環境將拒絕啟動")
207
 
208
  # 生產環境 CORS 檢查
209
  if cls.IS_PRODUCTION and cls._cors_origins_raw == "*":
210
+ import logging
211
+ logger = logging.getLogger("core.config")
212
+ logger.warning("⚠️ 生產環境建議設定具體的 CORS_ORIGINS,而非 '*'")
213
 
214
  return True
215
 
216
  @classmethod
217
  def print_summary(cls) -> None:
218
  """列印當前配置摘要(隱藏敏感資訊)"""
219
+ import logging
220
+ logger = logging.getLogger("core.config")
221
+ logger.info("\n" + "=" * 60)
222
+ logger.info("📋 Bloom Ware 配置摘要")
223
+ logger.info("=" * 60)
224
+ logger.info(f"環境模式: {cls.ENVIRONMENT}")
225
+ logger.info(f"是否為生產環境: {cls.IS_PRODUCTION}")
226
+ logger.info(f"Firebase 專案 ID: {cls.FIREBASE_PROJECT_ID}")
227
 
228
  # 判斷 Firebase 憑證來源
229
  if cls._firebase_creds_json:
 
234
  firebase_source = "檔案"
235
  else:
236
  firebase_source = "未設定 ❌"
237
+ logger.info(f"Firebase 憑證來源: {firebase_source}")
238
+ logger.info(f"OpenAI 模型: {cls.OPENAI_MODEL}")
239
+ logger.info(f"OpenAI Timeout: {cls.OPENAI_TIMEOUT}s")
240
+ logger.info(f"Google OAuth 回調 URI: {cls.GOOGLE_REDIRECT_URI}")
241
+ logger.info(f"JWT Token 有效期: {cls.ACCESS_TOKEN_EXPIRE_MINUTES} 分鐘")
242
+ logger.info(f"伺服器監聽: {cls.HOST}:{cls.PORT}")
243
+ logger.info(f"使用 GPT 意圖檢測: {cls.USE_GPT_INTENT}")
244
+ logger.info(f"Weather API Key: {'已設定 ✅' if cls.WEATHER_API_KEY else '未設定 ❌'}")
245
+ logger.info(f"NewsData API Key: {'已設定 ✅' if cls.NEWSDATA_API_KEY else '未設定 ❌'}")
246
+ logger.info(f"Exchange API Key: {'已設定 ✅' if cls.EXCHANGE_API_KEY else '未設定 ❌'}")
247
+ logger.info(f"環境節流距離: {cls.ENV_CONTEXT_DISTANCE_THRESHOLD} m")
248
+ logger.info(f"環境節流方位差: {cls.ENV_CONTEXT_HEADING_THRESHOLD}°")
249
+ logger.info(f"環境快取 TTL: {cls.ENV_CONTEXT_TTL_SECONDS} 秒")
250
+ logger.info("=" * 60 + "\n")
251
 
252
 
253
  # 建立全域設定實例(單例模式)
core/database/base.py CHANGED
@@ -91,12 +91,12 @@ def connect_to_firestore():
91
 
92
  if not firebase_project_id:
93
  logger.error("Firebase專案ID未正確設置,請在.env文件中設置FIREBASE_PROJECT_ID環境變數")
94
- print("\n❌ 錯誤: Firebase專案ID未設置!請在.env文件中設置FIREBASE_PROJECT_ID\n")
95
  return False
96
 
97
  try:
98
  logger.info("正在嘗試連接Firebase Firestore...")
99
- print("\n🔄 正在連接Firebase Firestore數據庫...\n")
100
 
101
  # 檢查是否已經初始化 Firebase
102
  try:
@@ -113,7 +113,7 @@ def connect_to_firestore():
113
  logger.info(f"Firebase 初始化成功(專案ID:{firebase_project_id})")
114
  except ValueError as e:
115
  logger.error(f"Firebase 憑證載入失敗: {e}")
116
- print(f"\n❌ 錯誤: Firebase 憑證載入失敗!{e}\n")
117
  return False
118
 
119
  # 初始化 Firestore 客戶端
@@ -137,19 +137,18 @@ def connect_to_firestore():
137
  route_cache_collection = firestore_db.collection('route_cache')
138
 
139
  logger.info(f"✅ Firestore連接成功,專案ID:{firebase_project_id}")
140
- print(f"\n✅ Firebase Firestore連接成功!專案ID:{firebase_project_id}\n")
141
  return True
142
 
143
  except Exception as e:
144
  logger.error(f"Firebase Firestore連接失敗:{e}")
145
- print(f"\n❌ Firebase Firestore連接失敗:{e}\n")
146
- print("🔧 故障排除建議:")
147
- print("1. 檢查網絡連接")
148
- print("2. 確認Firebase服務帳戶金鑰文件路徑正確")
149
- print("3. 驗證Firebase專案ID是否正確")
150
- print("4. 確保Firestore Database已在Firebase Console中啟用")
151
- print("5. 檢查服務帳戶權限是否包含Firestore權限")
152
- print()
153
  return False
154
  def ensure_indexes():
155
  """Firestore 不需要手動創建索引,由 Google 自動優化"""
 
91
 
92
  if not firebase_project_id:
93
  logger.error("Firebase專案ID未正確設置,請在.env文件中設置FIREBASE_PROJECT_ID環境變數")
94
+ logger.error("\n❌ 錯誤: Firebase專案ID未設置!請在.env文件中設置FIREBASE_PROJECT_ID\n")
95
  return False
96
 
97
  try:
98
  logger.info("正在嘗試連接Firebase Firestore...")
99
+ logger.info("\n🔄 正在連接Firebase Firestore數據庫...\n")
100
 
101
  # 檢查是否已經初始化 Firebase
102
  try:
 
113
  logger.info(f"Firebase 初始化成功(專案ID:{firebase_project_id})")
114
  except ValueError as e:
115
  logger.error(f"Firebase 憑證載入失敗: {e}")
116
+ logger.error(f"\n❌ 錯誤: Firebase 憑證載入失敗!{e}\n")
117
  return False
118
 
119
  # 初始化 Firestore 客戶端
 
137
  route_cache_collection = firestore_db.collection('route_cache')
138
 
139
  logger.info(f"✅ Firestore連接成功,專案ID:{firebase_project_id}")
140
+ logger.info(f"\n✅ Firebase Firestore連接成功!專案ID:{firebase_project_id}\n")
141
  return True
142
 
143
  except Exception as e:
144
  logger.error(f"Firebase Firestore連接失敗:{e}")
145
+ logger.error(f"\n❌ Firebase Firestore連接失敗:{e}\n")
146
+ logger.error("🔧 故障排除建議:")
147
+ logger.error("1. 檢查網絡連接")
148
+ logger.error("2. 確認Firebase服務帳戶金鑰文件路徑正確")
149
+ logger.error("3. 驗證Firebase專案ID是否正確")
150
+ logger.error("4. 確保Firestore Database已在Firebase Console中啟用")
151
+ logger.error("5. 檢查服務帳戶權限是否包含Firestore權限")
 
152
  return False
153
  def ensure_indexes():
154
  """Firestore 不需要手動創建索引,由 Google 自動優化"""
core/emotion_care_manager.py CHANGED
@@ -3,6 +3,11 @@
3
  當偵測到用戶極端情緒時(sad, angry, fear),自動進入關懷模式
4
  關懷模式下禁用所有工具調用,專注於情感支持
5
  用戶說「我沒事了」等關鍵字後才解除
 
 
 
 
 
6
  """
7
 
8
  import logging
@@ -17,13 +22,18 @@ class EmotionCareManager:
17
 
18
  # 極端情緒定義(需要進入關懷模式的情緒)
19
  EXTREME_EMOTIONS = {"sad", "angry", "fear"}
20
-
21
  # 正面情緒定義(可以解除關懷模式的情緒)
22
  POSITIVE_EMOTIONS = {"neutral", "happy", "surprise"}
23
 
24
  # 模式存活與冷卻(避免反覆觸發)
25
- CARE_TTL_SECONDS = 10 * 60 # 10 分鐘自動失效(從 20 分鐘縮短)
26
- COOLDOWN_SECONDS = 5 * 60 # 5 分鐘內不重入(從 10 分鐘縮短)
 
 
 
 
 
27
 
28
  # 解除關懷模式的關鍵字
29
  RELEASE_KEYWORDS = [
@@ -51,10 +61,32 @@ class EmotionCareManager:
51
  ]
52
 
53
  # 用戶關懷狀態
54
- # 結構: {user_id: {chat_key: {"in_care_mode": bool, "emotion": str, "start_time": float}}}
 
 
 
 
 
 
55
  _user_states: Dict[str, Dict[str, Dict]] = {}
56
  _DEFAULT_CHAT_KEY = "__default__"
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  @classmethod
59
  def _resolve_chat_key(cls, chat_id: Optional[str]) -> str:
60
  return chat_id or cls._DEFAULT_CHAT_KEY
@@ -73,26 +105,66 @@ class EmotionCareManager:
73
  user_states[key] = state
74
 
75
  @classmethod
76
- def check_and_enter_care_mode(cls, user_id: str, emotion: str, chat_id: Optional[str] = None) -> bool:
 
 
 
 
 
 
 
77
  """
78
  檢查情緒是否為極端情緒,若是則進入關懷模式
79
 
 
 
80
  參數:
81
  user_id: 用戶 ID
82
  emotion: 偵測到的情緒(neutral, happy, sad, angry, fear, surprise)
 
 
 
83
 
84
  返回:
85
  bool: 是否進入關懷模式(True=進入,False=不需要)
86
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  if not emotion or emotion not in cls.EXTREME_EMOTIONS:
88
  return False
89
 
90
  # 冷卻期防抖:若剛退出不久,避免馬上重入
91
- key = cls._resolve_chat_key(chat_id)
92
- user_states = cls._user_states.get(user_id) or {}
93
- prev_state = user_states.get(key) or {}
94
  last_exit = prev_state.get("last_exit_time", 0.0)
95
  if last_exit and (time.time() - last_exit) < cls.COOLDOWN_SECONDS:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  return False
97
 
98
  # 進入關懷模式
@@ -101,9 +173,10 @@ class EmotionCareManager:
101
  "emotion": emotion,
102
  "start_time": time.time(),
103
  "last_exit_time": prev_state.get("last_exit_time", 0.0),
 
104
  })
105
 
106
- logger.warning(f"⚠️ 用戶 {user_id}(chat={chat_id or 'default'})偵測到極端情緒 [{emotion}],進入關懷模式")
107
  return True
108
 
109
  @classmethod
 
3
  當偵測到用戶極端情緒時(sad, angry, fear),自動進入關懷模式
4
  關懷模式下禁用所有工具調用,專注於情感支持
5
  用戶說「我沒事了」等關鍵字後才解除
6
+
7
+ 【2025 優化版】
8
+ - 加入連續性檢查:需要連續 N 次偵測到極端情緒才觸發(避免誤判)
9
+ - 支援情緒強度權重:音頻情緒 + 文字情緒雙軌融合
10
+ - 調整 TTL 和冷卻時間,更精準的觸發機制
11
  """
12
 
13
  import logging
 
22
 
23
  # 極端情緒定義(需要進入關懷模式的情緒)
24
  EXTREME_EMOTIONS = {"sad", "angry", "fear"}
25
+
26
  # 正面情緒定義(可以解除關懷模式的情緒)
27
  POSITIVE_EMOTIONS = {"neutral", "happy", "surprise"}
28
 
29
  # 模式存活與冷卻(避免反覆觸發)
30
+ CARE_TTL_SECONDS = 8 * 60 # 8 分鐘自動失效(縮短以更快恢復正常)
31
+ COOLDOWN_SECONDS = 2 * 60 # 2 分鐘內不重入(縮短以提高響應性)
32
+
33
+ # 【新增】連續性觸發設定
34
+ # 【優化】降低門檻:第一次明確的極端情緒就觸發,避免「太遲鈍」
35
+ CONSECUTIVE_THRESHOLD = 1 # 需要 1 次極端情緒即可觸發(原本 2 次太嚴格)
36
+ EMOTION_WINDOW_SECONDS = 90 # 情緒計數窗口:90秒內的情緒才計入
37
 
38
  # 解除關懷模式的關鍵字
39
  RELEASE_KEYWORDS = [
 
61
  ]
62
 
63
  # 用戶關懷狀態
64
+ # 結構: {user_id: {chat_key: {
65
+ # "in_care_mode": bool,
66
+ # "emotion": str,
67
+ # "start_time": float,
68
+ # "last_exit_time": float,
69
+ # "emotion_history": [(timestamp, emotion), ...] # 【新增】情緒歷史
70
+ # }}}
71
  _user_states: Dict[str, Dict[str, Dict]] = {}
72
  _DEFAULT_CHAT_KEY = "__default__"
73
 
74
+ @classmethod
75
+ def _count_recent_extreme_emotions(cls, emotion_history: list) -> int:
76
+ """計算窗口內的極端情緒次數"""
77
+ now = time.time()
78
+ count = 0
79
+ for ts, emo in emotion_history:
80
+ if now - ts <= cls.EMOTION_WINDOW_SECONDS and emo in cls.EXTREME_EMOTIONS:
81
+ count += 1
82
+ return count
83
+
84
+ @classmethod
85
+ def _clean_old_emotions(cls, emotion_history: list) -> list:
86
+ """清理過期的情緒記錄"""
87
+ now = time.time()
88
+ return [(ts, emo) for ts, emo in emotion_history if now - ts <= cls.EMOTION_WINDOW_SECONDS * 2]
89
+
90
  @classmethod
91
  def _resolve_chat_key(cls, chat_id: Optional[str]) -> str:
92
  return chat_id or cls._DEFAULT_CHAT_KEY
 
105
  user_states[key] = state
106
 
107
  @classmethod
108
+ def check_and_enter_care_mode(
109
+ cls,
110
+ user_id: str,
111
+ emotion: str,
112
+ chat_id: Optional[str] = None,
113
+ confidence: float = 1.0,
114
+ force: bool = False
115
+ ) -> bool:
116
  """
117
  檢查情緒是否為極端情緒,若是則進入關懷模式
118
 
119
+ 【2025 優化版】加入連續性檢查,避免誤判
120
+
121
  參數:
122
  user_id: 用戶 ID
123
  emotion: 偵測到的情緒(neutral, happy, sad, angry, fear, surprise)
124
+ chat_id: 對話 ID(可選)
125
+ confidence: 情緒置信度(0.0-1.0),高置信度可降低連續性要求
126
+ force: 強制進入(跳過連續性檢查,用於明確極端情況)
127
 
128
  返回:
129
  bool: 是否進入關懷模式(True=進入,False=不需要)
130
  """
131
+ key = cls._resolve_chat_key(chat_id)
132
+ user_states = cls._user_states.get(user_id) or {}
133
+ prev_state = user_states.get(key) or {}
134
+
135
+ # 取得或初始化情緒歷史
136
+ emotion_history = prev_state.get("emotion_history", [])
137
+ emotion_history = cls._clean_old_emotions(emotion_history)
138
+
139
+ # 記錄當前情緒(不管是不是極端情緒都記錄)
140
+ emotion_history.append((time.time(), emotion))
141
+
142
+ # 更新狀態(保存情緒歷史)
143
+ prev_state["emotion_history"] = emotion_history
144
+ cls._set_state(user_id, chat_id, prev_state)
145
+
146
+ # 如果不是極端情緒,直接返回
147
  if not emotion or emotion not in cls.EXTREME_EMOTIONS:
148
  return False
149
 
150
  # 冷卻期防抖:若剛退出不久,避免馬上重入
 
 
 
151
  last_exit = prev_state.get("last_exit_time", 0.0)
152
  if last_exit and (time.time() - last_exit) < cls.COOLDOWN_SECONDS:
153
+ logger.debug(f"⏸️ 用戶 {user_id} 在冷卻期內,不進入關懷模式")
154
+ return False
155
+
156
+ # 【連續性檢查】計算窗口內的極端情緒次數
157
+ extreme_count = cls._count_recent_extreme_emotions(emotion_history)
158
+
159
+ # 高置信度(>0.7)可降低門檻為 1 次
160
+ # 強制模式(force=True)直接進入
161
+ threshold = 1 if (confidence > 0.7 or force) else cls.CONSECUTIVE_THRESHOLD
162
+
163
+ logger.info(f"🎭 情緒檢查: emotion={emotion}, confidence={confidence:.2f}, "
164
+ f"extreme_count={extreme_count}/{threshold}, force={force}")
165
+
166
+ if extreme_count < threshold:
167
+ logger.debug(f"⏸️ 用戶 {user_id} 極端情緒次數不足 ({extreme_count}/{threshold}),不進入關懷模式")
168
  return False
169
 
170
  # 進入關懷模式
 
173
  "emotion": emotion,
174
  "start_time": time.time(),
175
  "last_exit_time": prev_state.get("last_exit_time", 0.0),
176
+ "emotion_history": emotion_history,
177
  })
178
 
179
+ logger.warning(f"⚠️ 用戶 {user_id}(chat={chat_id or 'default'})偵測到連續極端情緒 [{emotion}]({extreme_count}次),進入關懷模式")
180
  return True
181
 
182
  @classmethod
core/pipeline.py CHANGED
@@ -230,21 +230,25 @@ class ChatPipeline:
230
  logger.info(f"🐛 [DEBUG] audio_emotion = {audio_emotion}")
231
  logger.info(f"🐛 [DEBUG] text_emotion = {text_emotion}")
232
 
233
- # 情緒融合邏輯
 
234
  if audio_emotion and audio_emotion.get("success"):
235
  audio_emotion_label = audio_emotion.get("emotion", "neutral")
236
  audio_confidence = audio_emotion.get("confidence", 0.0)
237
-
238
- # 優先使用音頻情緒(置信度 >= 0.5)
239
- if audio_confidence >= 0.5:
240
  emotion_value = audio_emotion_label
 
241
  logger.info(f"🎭 使用音頻情緒: {emotion_value} (置信度: {audio_confidence:.4f})")
242
  logger.info(f"📝 文字情緒: {text_emotion} (輔助)")
243
  else:
244
  emotion_value = text_emotion
 
245
  logger.info(f"📝 使用文字情緒: {emotion_value} (音頻置信度過低: {audio_confidence:.4f})")
246
  else:
247
  emotion_value = text_emotion
 
248
  logger.info(f"📝 使用文字情緒: {emotion_value} (無音頻情緒)")
249
 
250
  # 1) 檢查是否在關懷模式
@@ -278,9 +282,11 @@ class ChatPipeline:
278
  return PipelineResult(text="我在這裡陪你,隨時可以聊聊。", is_fallback=True, reason="ai-care-empty")
279
  return PipelineResult(text=text, is_fallback=False, meta={"care_mode": True, "emotion": care_emotion})
280
 
281
- # 2) 檢查是否需要進入關懷模式
282
- if user_id and EmotionCareManager.check_and_enter_care_mode(user_id, emotion_value, chat_id):
283
- logger.warning(f"⚠️ 偵測到極端情緒 [{emotion_value}],進入關懷模式")
 
 
284
  # 立即使用關懷模式 AI 回應
285
  ai_res = await self._with_timeout(
286
  self._ai_generator(
@@ -341,17 +347,19 @@ class ChatPipeline:
341
  logger.info(f"🌐 無工具資料,跳過翻譯")
342
 
343
  # 返回帶有工具元數據的結果(包含情緒)
344
- meta_dict = {}
 
 
 
345
  if tool_name:
346
  meta_dict['tool_name'] = tool_name
347
  if tool_data:
348
  meta_dict['tool_data'] = tool_data
349
- meta_dict['emotion'] = emotion_value
350
 
351
  return PipelineResult(
352
  text=text,
353
  is_fallback=False,
354
- meta=meta_dict if meta_dict else None
355
  )
356
  else:
357
  # 正常字串
@@ -364,7 +372,7 @@ class ChatPipeline:
364
  return PipelineResult(
365
  text=text,
366
  is_fallback=False,
367
- meta={"emotion": emotion_value},
368
  )
369
 
370
  # 4) 無功能 → 一般聊天(限時)
@@ -388,8 +396,10 @@ class ChatPipeline:
388
  if not text:
389
  return PipelineResult(text="抱歉,我暫時沒有合適的回應。可以換個說法再試試嗎?", is_fallback=True, reason="ai-empty")
390
 
391
- # 一般聊天也包含情緒資訊(新增)
392
- meta_dict = {}
393
- meta_dict['emotion'] = emotion_value
 
 
394
 
395
- return PipelineResult(text=text, is_fallback=False, meta=meta_dict if meta_dict else None)
 
230
  logger.info(f"🐛 [DEBUG] audio_emotion = {audio_emotion}")
231
  logger.info(f"🐛 [DEBUG] text_emotion = {text_emotion}")
232
 
233
+ # 【優化】情緒融合邏輯 - 降低音頻置信度門檻到 0.35
234
+ emotion_confidence = 0.5 # 預設置信度
235
  if audio_emotion and audio_emotion.get("success"):
236
  audio_emotion_label = audio_emotion.get("emotion", "neutral")
237
  audio_confidence = audio_emotion.get("confidence", 0.0)
238
+
239
+ # 【優化】降低門檻到 0.35,更敏感地偵測情緒
240
+ if audio_confidence >= 0.35:
241
  emotion_value = audio_emotion_label
242
+ emotion_confidence = audio_confidence
243
  logger.info(f"🎭 使用音頻情緒: {emotion_value} (置信度: {audio_confidence:.4f})")
244
  logger.info(f"📝 文字情緒: {text_emotion} (輔助)")
245
  else:
246
  emotion_value = text_emotion
247
+ emotion_confidence = 0.5 # 文字情緒預設置信度
248
  logger.info(f"📝 使用文字情緒: {emotion_value} (音頻置信度過低: {audio_confidence:.4f})")
249
  else:
250
  emotion_value = text_emotion
251
+ emotion_confidence = 0.5 # 文字情緒預設置信度
252
  logger.info(f"📝 使用文字情緒: {emotion_value} (無音頻情緒)")
253
 
254
  # 1) 檢查是否在關懷模式
 
282
  return PipelineResult(text="我在這裡陪你,隨時可以聊聊。", is_fallback=True, reason="ai-care-empty")
283
  return PipelineResult(text=text, is_fallback=False, meta={"care_mode": True, "emotion": care_emotion})
284
 
285
+ # 2) 檢查是否需要進入關懷模式(傳遞置信度,用於連續性判斷)
286
+ if user_id and EmotionCareManager.check_and_enter_care_mode(
287
+ user_id, emotion_value, chat_id, confidence=emotion_confidence
288
+ ):
289
+ logger.warning(f"⚠️ 偵測到極端情緒 [{emotion_value}](置信度: {emotion_confidence:.2f}),進入關懷模式")
290
  # 立即使用關懷模式 AI 回應
291
  ai_res = await self._with_timeout(
292
  self._ai_generator(
 
347
  logger.info(f"🌐 無工具資料,跳過翻譯")
348
 
349
  # 返回帶有工具元數據的結果(包含情緒)
350
+ meta_dict = {
351
+ 'emotion': emotion_value,
352
+ 'care_mode': False # 工具調用不是關懷模式
353
+ }
354
  if tool_name:
355
  meta_dict['tool_name'] = tool_name
356
  if tool_data:
357
  meta_dict['tool_data'] = tool_data
 
358
 
359
  return PipelineResult(
360
  text=text,
361
  is_fallback=False,
362
+ meta=meta_dict
363
  )
364
  else:
365
  # 正常字串
 
372
  return PipelineResult(
373
  text=text,
374
  is_fallback=False,
375
+ meta={"emotion": emotion_value, "care_mode": False},
376
  )
377
 
378
  # 4) 無功能 → 一般聊天(限時)
 
396
  if not text:
397
  return PipelineResult(text="抱歉,我暫時沒有合適的回應。可以換個說法再試試嗎?", is_fallback=True, reason="ai-empty")
398
 
399
+ # 一般聊天也包含情緒資訊
400
+ meta_dict = {
401
+ 'emotion': emotion_value,
402
+ 'care_mode': False # 一般聊天不是關懷模式
403
+ }
404
 
405
+ return PipelineResult(text=text, is_fallback=False, meta=meta_dict)
features/mcp/tools/tdx_bus_arrival.py CHANGED
@@ -111,30 +111,30 @@ class TDXBusArrivalTool(MCPTool):
111
  user_lon = arguments.get("lon")
112
  city_param = str(arguments.get("city", "")).strip()
113
 
114
- print(f"🚌 [TDX] tdx_bus_arrival 輸入: route={route_name}, lat={user_lat}, lon={user_lon}, city={city_param}, user_id={user_id}")
115
 
116
  # 從資料庫補充位置和城市(僅當 coordinator 沒有注入時)
117
  if user_id and (user_lat is None or user_lon is None):
118
  try:
119
  env_ctx = await get_user_env_current(user_id)
120
- print(f"📍 [TDX] 資料庫環境查詢結果: {env_ctx}")
121
  if env_ctx and env_ctx.get("success"):
122
  ctx = env_ctx.get("context", {})
123
  # 補充缺失的位置資訊
124
  if user_lat is None:
125
  user_lat = ctx.get("lat")
126
- print(f"📍 [TDX] 從資料庫補充 lat: {user_lat}")
127
  if user_lon is None:
128
  user_lon = ctx.get("lon")
129
- print(f"📍 [TDX] 從資料庫補充 lon: {user_lon}")
130
  # 優先使用環境中的城市(如果參數沒有指定)
131
  if not city_param:
132
  city_param = ctx.get("city", "")
133
- print(f"📍 [TDX] 從資料庫補充 city: {city_param}")
134
  except Exception as e:
135
- print(f"⚠️ [TDX] 資料庫環境查詢失敗: {e}")
136
 
137
- print(f"🚌 [TDX] 補充後: lat={user_lat}, lon={user_lon}, city={city_param}")
138
 
139
  # 檢查必要條件
140
  if not route_name and (user_lat is None or user_lon is None):
@@ -147,9 +147,9 @@ class TDXBusArrivalTool(MCPTool):
147
 
148
  # 2a. 如果有經緯度,嘗試即時反向地理編碼取得精確城市
149
  if user_lat is not None and user_lon is not None:
150
- print(f"🗺️ [TDX] 嘗試反向地理編碼: ({user_lat}, {user_lon})")
151
  geocoded_city = await cls._reverse_geocode_city(user_lat, user_lon)
152
- print(f"🗺️ [TDX] 反向地理編碼結果: {geocoded_city}")
153
  if geocoded_city:
154
  final_city = geocoded_city
155
  city_source = "反向地理編碼"
@@ -158,19 +158,19 @@ class TDXBusArrivalTool(MCPTool):
158
  if not final_city and city_param:
159
  final_city = city_param
160
  city_source = "環境參數"
161
- print(f"📍 [TDX] 使用環境參數城市: {city_param}")
162
 
163
  # 2c. 如果還是沒有,使用經緯度範圍推斷
164
  if not final_city and user_lat is not None and user_lon is not None:
165
  guessed_city = cls._guess_city_from_location(user_lat, user_lon)
166
- print(f"📐 [TDX] 經緯度推斷結果: {guessed_city}")
167
  if guessed_city:
168
  final_city = guessed_city
169
  city_source = "經緯度推斷"
170
 
171
  # 2d. 轉換為 TDX 城市代碼
172
  city = cls._resolve_city(final_city or "")
173
- print(f"🏙️ [TDX] 最終城市: {city} (來源={city_source}, 原始={final_city})")
174
 
175
  # 3. 執行查詢
176
  if route_name:
@@ -194,27 +194,27 @@ class TDXBusArrivalTool(MCPTool):
194
  - GET /v2/Bus/EstimatedTimeOfArrival/City/{City}/{RouteName} - 預估到站時間
195
  - GET /v2/Bus/RealTimeNearStop/City/{City}/{RouteName} - 公車目前在哪站
196
  """
197
- print(f"🚌 [TDX] 查詢公車到站: 路線={route_name}, 城市={city}")
198
 
199
  # 1. 查詢預估到站時間
200
  eta_endpoint = f"Bus/EstimatedTimeOfArrival/City/{city}/{route_name}"
201
  eta_params = {"$orderby": "StopSequence", "$format": "JSON"}
202
 
203
  try:
204
- print(f"🌐 [TDX] 呼叫 API: {eta_endpoint}")
205
  arrival_data = await TDXBaseAPI.call_api(eta_endpoint, eta_params, cache_ttl=30)
206
- print(f"✅ [TDX] API 回應: {len(arrival_data) if arrival_data else 0} 筆資料")
207
  if arrival_data and len(arrival_data) > 0:
208
- print(f"📋 [TDX] 第一筆: {arrival_data[0].get('StopName', {}).get('Zh_tw')}")
209
  except ExecutionError as e:
210
  error_detail = str(e)
211
- print(f"❌ [TDX] API 錯誤: {error_detail}")
212
  if "404" in error_detail:
213
  raise ExecutionError(f"找不到路線「{route_name}」,請確認路線名稱與城市")
214
  raise ExecutionError(f"查詢路線「{route_name}」失敗: {error_detail}")
215
 
216
  if not arrival_data:
217
- print(f"⚠️ [TDX] 無資料,拋出錯誤")
218
  raise ExecutionError(f"路線「{route_name}」目前無班次資訊")
219
 
220
  # 2. 查詢公車即時位置(目前在哪站)
@@ -280,8 +280,8 @@ class TDXBusArrivalTool(MCPTool):
280
  if stop_uid and pos.get("PositionLat") and pos.get("PositionLon"):
281
  stop_positions[stop_uid] = (pos["PositionLat"], pos["PositionLon"])
282
 
283
- print(f"📍 [TDX] 從 StopOfRoute 取得 {len(stop_positions)} 個站點座標")
284
- print(f"🎯 [TDX] 終點站資訊: {destination_stations}")
285
 
286
  # 為每筆到站資料計算「用戶位置」到「站牌」的距離
287
  for arr in arrival_data:
@@ -299,12 +299,12 @@ class TDXBusArrivalTool(MCPTool):
299
  if arrival_data_with_dist:
300
  arrival_data = sorted(arrival_data_with_dist, key=lambda x: x["distance_m"])
301
  nearest = arrival_data[0]
302
- print(f"📍 [TDX] 按距離排序完成,最近站: {nearest.get('StopName', {}).get('Zh_tw')} ({int(nearest['distance_m'])}m)")
303
  else:
304
- print(f"⚠️ [TDX] 無法計算距離,stop_positions={len(stop_positions)}, arrival_data={len(arrival_data)}")
305
 
306
  except Exception as e:
307
- print(f"⚠️ [TDX] 查詢站點座標失敗: {e}")
308
  import traceback
309
  traceback.print_exc()
310
 
@@ -337,7 +337,7 @@ class TDXBusArrivalTool(MCPTool):
337
  # 如果公車已離站且站序 > 用戶站序,表示已過站
338
  if bus_info["event"] == "已離站" and bus_sequence > user_stop_sequence:
339
  bus_passed = True
340
- print(f"🚫 [TDX] 公車已過站: 公車在第 {bus_sequence} 站 > 用戶在第 {user_stop_sequence} 站")
341
 
342
  status_text = cls._get_status_text(stop_status, estimate_time, next_bus_time)
343
 
@@ -365,12 +365,12 @@ class TDXBusArrivalTool(MCPTool):
365
  if len(arrivals) >= limit:
366
  break
367
 
368
- print(f"📊 [TDX] 最終結果: {len(arrivals)} 筆到站資訊")
369
  for arr in arrivals:
370
- print(f" - {arr['stop_name']} ({arr['status']})")
371
 
372
  content = cls._format_arrival_result(arrivals, full_route_name, user_lat is not None)
373
- print(f"📝 [TDX] 格式化內容:\n{content}")
374
 
375
  return cls.create_success_response(
376
  content=content,
 
111
  user_lon = arguments.get("lon")
112
  city_param = str(arguments.get("city", "")).strip()
113
 
114
+ logger.debug(f"🚌 [TDX] tdx_bus_arrival 輸入: route={route_name}, lat={user_lat}, lon={user_lon}, city={city_param}, user_id={user_id}")
115
 
116
  # 從資料庫補充位置和城市(僅當 coordinator 沒有注入時)
117
  if user_id and (user_lat is None or user_lon is None):
118
  try:
119
  env_ctx = await get_user_env_current(user_id)
120
+ logger.debug(f"📍 [TDX] 資料庫環境查詢結果: {env_ctx}")
121
  if env_ctx and env_ctx.get("success"):
122
  ctx = env_ctx.get("context", {})
123
  # 補充缺失的位置資訊
124
  if user_lat is None:
125
  user_lat = ctx.get("lat")
126
+ logger.debug(f"📍 [TDX] 從資料庫補充 lat: {user_lat}")
127
  if user_lon is None:
128
  user_lon = ctx.get("lon")
129
+ logger.debug(f"📍 [TDX] 從資料庫補充 lon: {user_lon}")
130
  # 優先使用環境中的城市(如果參數沒有指定)
131
  if not city_param:
132
  city_param = ctx.get("city", "")
133
+ logger.debug(f"📍 [TDX] 從資料庫補充 city: {city_param}")
134
  except Exception as e:
135
+ logger.debug(f"⚠️ [TDX] 資料庫環境查詢失敗: {e}")
136
 
137
+ logger.debug(f"🚌 [TDX] 補充後: lat={user_lat}, lon={user_lon}, city={city_param}")
138
 
139
  # 檢查必要條件
140
  if not route_name and (user_lat is None or user_lon is None):
 
147
 
148
  # 2a. 如果有經緯度,嘗試即時反向地理編碼取得精確城市
149
  if user_lat is not None and user_lon is not None:
150
+ logger.debug(f"🗺️ [TDX] 嘗試反向地理編碼: ({user_lat}, {user_lon})")
151
  geocoded_city = await cls._reverse_geocode_city(user_lat, user_lon)
152
+ logger.debug(f"🗺️ [TDX] 反向地理編碼結果: {geocoded_city}")
153
  if geocoded_city:
154
  final_city = geocoded_city
155
  city_source = "反向地理編碼"
 
158
  if not final_city and city_param:
159
  final_city = city_param
160
  city_source = "環境參數"
161
+ logger.debug(f"📍 [TDX] 使用環境參數城市: {city_param}")
162
 
163
  # 2c. 如果還是沒有,使用經緯度範圍推斷
164
  if not final_city and user_lat is not None and user_lon is not None:
165
  guessed_city = cls._guess_city_from_location(user_lat, user_lon)
166
+ logger.debug(f"📐 [TDX] 經緯度推斷結果: {guessed_city}")
167
  if guessed_city:
168
  final_city = guessed_city
169
  city_source = "經緯度推斷"
170
 
171
  # 2d. 轉換為 TDX 城市代碼
172
  city = cls._resolve_city(final_city or "")
173
+ logger.debug(f"🏙️ [TDX] 最終城市: {city} (來源={city_source}, 原始={final_city})")
174
 
175
  # 3. 執行查詢
176
  if route_name:
 
194
  - GET /v2/Bus/EstimatedTimeOfArrival/City/{City}/{RouteName} - 預估到站時間
195
  - GET /v2/Bus/RealTimeNearStop/City/{City}/{RouteName} - 公車目前在哪站
196
  """
197
+ logger.debug(f"🚌 [TDX] 查詢公車到站: 路線={route_name}, 城市={city}")
198
 
199
  # 1. 查詢預估到站時間
200
  eta_endpoint = f"Bus/EstimatedTimeOfArrival/City/{city}/{route_name}"
201
  eta_params = {"$orderby": "StopSequence", "$format": "JSON"}
202
 
203
  try:
204
+ logger.debug(f"🌐 [TDX] 呼��� API: {eta_endpoint}")
205
  arrival_data = await TDXBaseAPI.call_api(eta_endpoint, eta_params, cache_ttl=30)
206
+ logger.debug(f"✅ [TDX] API 回應: {len(arrival_data) if arrival_data else 0} 筆資料")
207
  if arrival_data and len(arrival_data) > 0:
208
+ logger.debug(f"📋 [TDX] 第一筆: {arrival_data[0].get('StopName', {}).get('Zh_tw')}")
209
  except ExecutionError as e:
210
  error_detail = str(e)
211
+ logger.debug(f"❌ [TDX] API 錯誤: {error_detail}")
212
  if "404" in error_detail:
213
  raise ExecutionError(f"找不到路線「{route_name}」,請確認路線名稱與城市")
214
  raise ExecutionError(f"查詢路線「{route_name}」失敗: {error_detail}")
215
 
216
  if not arrival_data:
217
+ logger.debug(f"⚠️ [TDX] 無資料,拋出錯誤")
218
  raise ExecutionError(f"路線「{route_name}」目前無班次資訊")
219
 
220
  # 2. 查詢公車即時位置(目前在哪站)
 
280
  if stop_uid and pos.get("PositionLat") and pos.get("PositionLon"):
281
  stop_positions[stop_uid] = (pos["PositionLat"], pos["PositionLon"])
282
 
283
+ logger.debug(f"📍 [TDX] 從 StopOfRoute 取得 {len(stop_positions)} 個站點座標")
284
+ logger.debug(f"🎯 [TDX] 終點站資訊: {destination_stations}")
285
 
286
  # 為每筆到站資料計算「用戶位置」到「站牌」的距離
287
  for arr in arrival_data:
 
299
  if arrival_data_with_dist:
300
  arrival_data = sorted(arrival_data_with_dist, key=lambda x: x["distance_m"])
301
  nearest = arrival_data[0]
302
+ logger.debug(f"📍 [TDX] 按距離排序完成,最近站: {nearest.get('StopName', {}).get('Zh_tw')} ({int(nearest['distance_m'])}m)")
303
  else:
304
+ logger.debug(f"⚠️ [TDX] 無法計算距離,stop_positions={len(stop_positions)}, arrival_data={len(arrival_data)}")
305
 
306
  except Exception as e:
307
+ logger.debug(f"⚠️ [TDX] 查詢站點座標失敗: {e}")
308
  import traceback
309
  traceback.print_exc()
310
 
 
337
  # 如果公車已離站且站序 > 用戶站序,表示已過站
338
  if bus_info["event"] == "已離站" and bus_sequence > user_stop_sequence:
339
  bus_passed = True
340
+ logger.debug(f"🚫 [TDX] 公車已過站: 公車在第 {bus_sequence} 站 > 用戶在第 {user_stop_sequence} 站")
341
 
342
  status_text = cls._get_status_text(stop_status, estimate_time, next_bus_time)
343
 
 
365
  if len(arrivals) >= limit:
366
  break
367
 
368
+ logger.debug(f"📊 [TDX] 最終結果: {len(arrivals)} 筆到站資訊")
369
  for arr in arrivals:
370
+ logger.debug(f" - {arr['stop_name']} ({arr['status']})")
371
 
372
  content = cls._format_arrival_result(arrivals, full_route_name, user_lat is not None)
373
+ logger.debug(f"📝 [TDX] 格式化內容:\n{content}")
374
 
375
  return cls.create_success_response(
376
  content=content,
middleware/exception_handler.py CHANGED
@@ -81,10 +81,16 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
81
  response = await call_next(request)
82
  process_time = (time.time() - start_time) * 1000
83
 
 
84
  if should_log:
85
- logger.info(
86
- f"{method} {path} - {response.status_code} - {process_time:.2f}ms"
87
- )
 
 
 
 
 
88
 
89
  # 添加處理時間到回應標頭
90
  response.headers["X-Process-Time"] = f"{process_time:.2f}ms"
 
81
  response = await call_next(request)
82
  process_time = (time.time() - start_time) * 1000
83
 
84
+ # 只記錄錯誤或慢請求(超過 1000ms)
85
  if should_log:
86
+ if response.status_code >= 400:
87
+ logger.warning(
88
+ f"{method} {path} - {response.status_code} - {process_time:.2f}ms"
89
+ )
90
+ elif process_time > 1000:
91
+ logger.warning(
92
+ f"{method} {path} - SLOW - {response.status_code} - {process_time:.2f}ms"
93
+ )
94
 
95
  # 添加處理時間到回應標頭
96
  response.headers["X-Process-Time"] = f"{process_time:.2f}ms"
models/emotion_recognition/emotion.py CHANGED
@@ -61,17 +61,19 @@ class HubertForSpeechClassification(HubertPreTrainedModel):
61
 
62
  def _load_model():
63
  """延遲載入模型,避免在模組匯入時就下載"""
 
 
64
  global _model, _processor, _config
65
  if _model is None:
66
- print("正在延遲載入情緒辨識模型...")
67
  try:
68
  _config = AutoConfig.from_pretrained(model_name_or_path)
69
  _processor = Wav2Vec2FeatureExtractor.from_pretrained(model_name_or_path)
70
  _model = HubertForSpeechClassification.from_pretrained(model_name_or_path, config=_config)
71
  _model.eval()
72
- print("情緒辨識模型載入完成!")
73
  except Exception as e:
74
- print(f"情緒辨識模型載入失敗: {e}")
75
  _model = None
76
  _processor = None
77
  _config = None
 
61
 
62
  def _load_model():
63
  """延遲載入模型,避免在模組匯入時就下載"""
64
+ import logging
65
+ logger = logging.getLogger("emotion_recognition")
66
  global _model, _processor, _config
67
  if _model is None:
68
+ logger.debug("正在延遲載入情緒辨識模型...")
69
  try:
70
  _config = AutoConfig.from_pretrained(model_name_or_path)
71
  _processor = Wav2Vec2FeatureExtractor.from_pretrained(model_name_or_path)
72
  _model = HubertForSpeechClassification.from_pretrained(model_name_or_path, config=_config)
73
  _model.eval()
74
+ logger.debug("情緒辨識模型載入完成!")
75
  except Exception as e:
76
+ logger.error(f"情緒辨識模型載入失敗: {e}")
77
  _model = None
78
  _processor = None
79
  _config = None
services/ai_service.py CHANGED
@@ -17,12 +17,12 @@ from core.ai_client import get_openai_client
17
  # 超時設定(秒)
18
  OPENAI_TIMEOUT = settings.OPENAI_TIMEOUT
19
 
20
- # 情緒關懷模式 System Prompt
21
- CARE_MODE_SYSTEM_PROMPT = """你是 BloomWare 的情緒關懷助手「小花」,由銘傳大學人工智慧應用學系槓上開發團隊打造。你不是 GPT,也不要自稱 GPT;你的任務是在情緒低落時傾聽、陪伴。
22
 
23
  【回應原則】
24
  1. 第一句必須貼近用戶訊息中的核心事件或感受,必要時引用對方用詞,讓對方感受到被理解
25
- 2. 第二句提供溫柔的陪伴或追問,邀請對方分享需要或下一步;若用戶提出明確請求(如想聽笑話),可在保持關懷語氣下予以回應或確認
26
  3. 句式要自然口語並隨內容調整字詞,避免反覆使用同一套罐頭話術
27
 
28
  【長度限制】
@@ -30,10 +30,69 @@ CARE_MODE_SYSTEM_PROMPT = """你是 BloomWare 的情緒關懷助手「小花」
30
 
31
  【嚴格禁止】
32
  - 提供指示性建議、醫療/心理診斷或引導用戶求助的教科書式說法
33
- - 連續重複完全相同的句型,例如一再出現「我在這裡陪你」而沒有結合具體情境
34
 
35
  【重要】請用與用戶相同的語言回應,匹配他們的語言風格和情感語調。"""
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  # 取得 OpenAI 客戶端(使用統一管理)
38
  def _get_client():
39
  """取得 OpenAI 客戶端"""
@@ -71,7 +130,8 @@ def _build_base_system_prompt(
71
  language: Optional[str] = None, # 保留參數以兼容現有調用,但不使用
72
  ) -> str:
73
  if use_care_mode:
74
- base_prompt = CARE_MODE_SYSTEM_PROMPT.strip()
 
75
  if care_emotion:
76
  base_prompt = f"用戶情緒:{care_emotion}\n{base_prompt}"
77
  else:
 
17
  # 超時設定(秒)
18
  OPENAI_TIMEOUT = settings.OPENAI_TIMEOUT
19
 
20
+ # 【2025 優化版】情緒關懷模式 System Prompt - 根據情緒類型動態調整
21
+ CARE_MODE_BASE_PROMPT = """你是 BloomWare 的情緒關懷助手「小花」,由銘傳大學人工智慧應用學系槓上開發團隊打造。你不是 GPT,也不要自稱 GPT;你的任務是在情緒低落時傾聽、陪伴。
22
 
23
  【回應原則】
24
  1. 第一句必須貼近用戶訊息中的核心事件或感受,必要時引用對方用詞,讓對方感受到被理解
25
+ 2. 第二句提供溫柔的陪伴或追問,邀請對方分享需要或下一步
26
  3. 句式要自然口語並隨內容調整字詞,避免反覆使用同一套罐頭話術
27
 
28
  【長度限制】
 
30
 
31
  【嚴格禁止】
32
  - 提供指示性建議、醫療/心理診斷或引導用戶求助的教科書式說法
33
+ - 連續重複完全相同的句型
34
 
35
  【重要】請用與用戶相同的語言回應,匹配他們的語言風格和情感語調。"""
36
 
37
+ # 根據情緒類型的專屬指引
38
+ EMOTION_SPECIFIC_PROMPTS = {
39
+ "sad": """
40
+ 【悲傷情緒專屬指引】
41
+ - 語氣:溫柔、輕聲、帶有理解
42
+ - 重點:陪伴而非解決問題,讓對方知道悲傷是正常的
43
+ - 避免:說「不要難過」、「振作點」這類否定情緒的話
44
+
45
+ 【範例】
46
+ 用戶:「我好難過」→「聽見你說好難過,心裡一定很不好受。想聊聊發生了什麼嗎?」
47
+ 用戶:「我失去了他」→「失去一個重要的人,那種痛真的很深。我在這裡陪你。」
48
+ 用戶:「I feel so sad」→「It sounds like you're really hurting right now. I'm here if you want to talk.」""",
49
+
50
+ "angry": """
51
+ 【憤怒情緒專屬指引】
52
+ - 語氣:冷靜但帶有同理、不卑不亢
53
+ - 重點:認可對方的憤怒是有原因的,幫助對方感覺被理解
54
+ - 避免:說「冷靜一下」、「別生氣」這類否定情緒的話
55
+
56
+ 【範例】
57
+ 用戶:「我很生氣」→「這件事讓你超級生氣,情緒一定卡著。要不要說說最困擾的地方?」
58
+ 用戶:「氣死我了」→「聽起來真的讓你很火大。是什麼事這麼讓人受不了?」
59
+ 用戶:「I'm so angry」→「Sounds like something really got to you. What's going on?」""",
60
+
61
+ "fear": """
62
+ 【恐懼/焦慮情緒專屬指引】
63
+ - 語氣:穩定、溫暖、帶有安全感
64
+ - 重點:讓對方感覺不孤單,恐懼是可以被接納的
65
+ - 避免:說「沒什麼好怕的」、「想太多了」這類否定情緒的話
66
+
67
+ 【範例】
68
+ 用戶:「我好害怕」→「害怕的感覺一定很不好受。你現在安全的,我陪著你。」
69
+ 用戶:「我很焦慮」→「焦慮的時候心裡好亂對吧。可以跟我說說是什麼讓你不安嗎?」
70
+ 用戶:「I'm scared」→「It's okay to feel scared. You're not alone - I'm right here with you.」"""
71
+ }
72
+
73
+ # 向後兼容:保留原有變數名稱
74
+ CARE_MODE_SYSTEM_PROMPT = CARE_MODE_BASE_PROMPT
75
+
76
+
77
+ def get_care_mode_prompt(emotion: str = None) -> str:
78
+ """
79
+ 根據情緒類型生成專屬的關懷模式 Prompt
80
+
81
+ Args:
82
+ emotion: 情緒標籤 (sad, angry, fear, 或 None)
83
+
84
+ Returns:
85
+ 完整的關懷模式 System Prompt
86
+ """
87
+ base = CARE_MODE_BASE_PROMPT
88
+
89
+ # 根據情緒類型添加專屬指引
90
+ if emotion and emotion.lower() in EMOTION_SPECIFIC_PROMPTS:
91
+ specific = EMOTION_SPECIFIC_PROMPTS[emotion.lower()]
92
+ return f"{base}\n{specific}"
93
+
94
+ return base
95
+
96
  # 取得 OpenAI 客戶端(使用統一管理)
97
  def _get_client():
98
  """取得 OpenAI 客戶端"""
 
130
  language: Optional[str] = None, # 保留參數以兼容現有調用,但不使用
131
  ) -> str:
132
  if use_care_mode:
133
+ # 【優化】使用情緒專屬的關懷 Prompt
134
+ base_prompt = get_care_mode_prompt(care_emotion).strip()
135
  if care_emotion:
136
  base_prompt = f"用戶情緒:{care_emotion}\n{base_prompt}"
137
  else:
services/batch_processor.py CHANGED
@@ -354,6 +354,6 @@ async def get_batch_results(batch_id: str) -> Dict[str, Any]:
354
  results = await get_batch_results(batch_id)
355
  if results["success"]:
356
  for item in results["results"]:
357
- print(item)
358
  """
359
  return await batch_processor.wait_for_completion(batch_id)
 
354
  results = await get_batch_results(batch_id)
355
  if results["success"]:
356
  for item in results["results"]:
357
+ logger.debug(item)
358
  """
359
  return await batch_processor.wait_for_completion(batch_id)
services/tts_service.py CHANGED
@@ -367,9 +367,9 @@ async def test_tts_playback(
367
  """
368
  result = await tts_service.play_locally(text, voice=voice, instruction=instruction)
369
  if result["success"]:
370
- print(f"✅ 播放成功:{text}")
371
  else:
372
- print(f"❌ 播放失敗:{result.get('error')}")
373
 
374
 
375
  if __name__ == "__main__":
 
367
  """
368
  result = await tts_service.play_locally(text, voice=voice, instruction=instruction)
369
  if result["success"]:
370
+ logger.debug(f"✅ 播放成功:{text}")
371
  else:
372
+ logger.debug(f"❌ 播放失敗:{result.get('error')}")
373
 
374
 
375
  if __name__ == "__main__":
static/frontend/index.html CHANGED
@@ -1595,7 +1595,6 @@
1595
  </style>
1596
  </head>
1597
  <body>
1598
- <!-- 控制面板 -->
1599
  <div class="control-panel">
1600
  <h3>🎛️ 控制面板</h3>
1601
 
@@ -1656,34 +1655,23 @@
1656
  </div>
1657
  </div>
1658
 
1659
- <!-- 沉浸式覆蓋層 -->
1660
  <div class="voice-immersive-overlay">
1661
- <!-- 動態漸變背景 -->
1662
  <div class="voice-immersive-background emotion-neutral active" id="background"></div>
1663
 
1664
- <!-- 情緒指示器 -->
1665
  <div class="emotion-indicator" id="emotion-indicator">當前情緒: 😐 中性</div>
1666
 
1667
- <!-- 登出按鈕 -->
1668
  <button class="exit-button" id="logoutBtn">登出</button>
1669
 
1670
- <!-- ChatWindow 切換圖標 -->
1671
  <div class="chat-icon" id="chatIcon" title="切換對話視窗">💬</div>
1672
 
1673
- <!-- 中央容器 -->
1674
  <div class="voice-center-container">
1675
- <!-- 波形與麥克風容器 -->
1676
  <div class="voice-waveform-container">
1677
  <canvas id="waveform-canvas" width="400" height="400"></canvas>
1678
 
1679
- <!-- 多層蓮花設計(Bloom Ware 品牌特色)-->
1680
  <div class="voice-mic-container" id="mic-container">
1681
- <!-- 花蕊中心(多層次金黃色)-->
1682
  <div class="bloom-core"></div>
1683
 
1684
- <!-- 16片蓮花花瓣(雙層結構:外層8片 + 內層8片)-->
1685
  <div class="bloom-petals">
1686
- <!-- 外層 8 片大花瓣 -->
1687
  <div class="bloom-petal"></div>
1688
  <div class="bloom-petal"></div>
1689
  <div class="bloom-petal"></div>
@@ -1692,7 +1680,6 @@
1692
  <div class="bloom-petal"></div>
1693
  <div class="bloom-petal"></div>
1694
  <div class="bloom-petal"></div>
1695
- <!-- 內層 8 片小花瓣 -->
1696
  <div class="bloom-petal"></div>
1697
  <div class="bloom-petal"></div>
1698
  <div class="bloom-petal"></div>
@@ -1705,37 +1692,29 @@
1705
  </div>
1706
  </div>
1707
 
1708
- <!-- Agent 文字輸出(打字機效果,位於花朵下方)-->
1709
  <div class="voice-agent-output" id="agent-output"></div>
1710
 
1711
- <!-- 實時字幕 -->
1712
  <div class="voice-transcript provisional" id="transcript">
1713
  請說話...
1714
  </div>
1715
  </div>
1716
 
1717
- <!-- 工具卡片容器(保留用於桌面端) -->
1718
  <div id="tool-cards-container"></div>
1719
  </div>
1720
 
1721
- <!-- 工具抽屜遮罩層 -->
1722
  <div class="tool-drawer-overlay" id="toolDrawerOverlay"></div>
1723
 
1724
- <!-- 工具抽屜切換按鈕(右側透明箭頭) -->
1725
  <div class="tool-drawer-toggle" id="toolDrawerToggle"></div>
1726
 
1727
- <!-- 工具抽屜面板 -->
1728
  <div class="tool-drawer" id="toolDrawer">
1729
  <div class="tool-drawer-header">
1730
  <h3>📊 工具結果</h3>
1731
  <button class="tool-drawer-close" id="toolDrawerClose">✕</button>
1732
  </div>
1733
  <div class="tool-drawer-content" id="toolDrawerContent">
1734
- <!-- 工具卡片將渲染到這裡 -->
1735
  </div>
1736
  </div>
1737
 
1738
- <!-- JavaScript 模組化引入 -->
1739
  <script src="js/config.js"></script>
1740
  <script src="js/ui.js"></script>
1741
  <script src="js/tts.js"></script>
@@ -1746,7 +1725,6 @@
1746
  <script src="js/websocket.js"></script>
1747
  <script src="js/app.js"></script>
1748
 
1749
- <!-- 響應式螢幕檢測 -->
1750
  <script>
1751
  (function() {
1752
  function detectScreenOrientation() {
@@ -1759,16 +1737,16 @@
1759
 
1760
  if (isLargePortrait) {
1761
  document.body.classList.add('portrait-mode');
1762
- console.log('📱 螢幕模式: 直立螢幕(90度橫屏)', width, 'x', height);
1763
  } else if (width <= 480) {
1764
  document.body.classList.add('mobile-mode');
1765
- console.log('📱 螢幕模式: 手機', width, 'x', height);
1766
  } else if (width <= 1024) {
1767
  document.body.classList.add('tablet-mode');
1768
- console.log('📱 螢幕模式: 平板', width, 'x', height);
1769
  } else {
1770
  document.body.classList.add('desktop-mode');
1771
- console.log('🖥️ 螢幕模式: 桌機/筆電', width, 'x', height);
1772
  }
1773
  }
1774
 
 
1595
  </style>
1596
  </head>
1597
  <body>
 
1598
  <div class="control-panel">
1599
  <h3>🎛️ 控制面板</h3>
1600
 
 
1655
  </div>
1656
  </div>
1657
 
 
1658
  <div class="voice-immersive-overlay">
 
1659
  <div class="voice-immersive-background emotion-neutral active" id="background"></div>
1660
 
 
1661
  <div class="emotion-indicator" id="emotion-indicator">當前情緒: 😐 中性</div>
1662
 
 
1663
  <button class="exit-button" id="logoutBtn">登出</button>
1664
 
 
1665
  <div class="chat-icon" id="chatIcon" title="切換對話視窗">💬</div>
1666
 
 
1667
  <div class="voice-center-container">
 
1668
  <div class="voice-waveform-container">
1669
  <canvas id="waveform-canvas" width="400" height="400"></canvas>
1670
 
 
1671
  <div class="voice-mic-container" id="mic-container">
 
1672
  <div class="bloom-core"></div>
1673
 
 
1674
  <div class="bloom-petals">
 
1675
  <div class="bloom-petal"></div>
1676
  <div class="bloom-petal"></div>
1677
  <div class="bloom-petal"></div>
 
1680
  <div class="bloom-petal"></div>
1681
  <div class="bloom-petal"></div>
1682
  <div class="bloom-petal"></div>
 
1683
  <div class="bloom-petal"></div>
1684
  <div class="bloom-petal"></div>
1685
  <div class="bloom-petal"></div>
 
1692
  </div>
1693
  </div>
1694
 
 
1695
  <div class="voice-agent-output" id="agent-output"></div>
1696
 
 
1697
  <div class="voice-transcript provisional" id="transcript">
1698
  請說話...
1699
  </div>
1700
  </div>
1701
 
 
1702
  <div id="tool-cards-container"></div>
1703
  </div>
1704
 
 
1705
  <div class="tool-drawer-overlay" id="toolDrawerOverlay"></div>
1706
 
 
1707
  <div class="tool-drawer-toggle" id="toolDrawerToggle"></div>
1708
 
 
1709
  <div class="tool-drawer" id="toolDrawer">
1710
  <div class="tool-drawer-header">
1711
  <h3>📊 工具結果</h3>
1712
  <button class="tool-drawer-close" id="toolDrawerClose">✕</button>
1713
  </div>
1714
  <div class="tool-drawer-content" id="toolDrawerContent">
 
1715
  </div>
1716
  </div>
1717
 
 
1718
  <script src="js/config.js"></script>
1719
  <script src="js/ui.js"></script>
1720
  <script src="js/tts.js"></script>
 
1725
  <script src="js/websocket.js"></script>
1726
  <script src="js/app.js"></script>
1727
 
 
1728
  <script>
1729
  (function() {
1730
  function detectScreenOrientation() {
 
1737
 
1738
  if (isLargePortrait) {
1739
  document.body.classList.add('portrait-mode');
1740
+ // console.log('📱 螢幕模式: 直立螢幕(90度橫屏)', width, 'x', height);
1741
  } else if (width <= 480) {
1742
  document.body.classList.add('mobile-mode');
1743
+ // console.log('📱 螢幕模式: 手機', width, 'x', height);
1744
  } else if (width <= 1024) {
1745
  document.body.classList.add('tablet-mode');
1746
+ // console.log('📱 螢幕模式: 平板', width, 'x', height);
1747
  } else {
1748
  document.body.classList.add('desktop-mode');
1749
+ // console.log('🖥️ 螢幕模式: 桌機/筆電', width, 'x', height);
1750
  }
1751
  }
1752
 
static/frontend/js/agent.js CHANGED
@@ -1,30 +1,17 @@
1
- // ========== Agent 狀態管理(單一狀態機)==========
2
- // 狀態定義:idle | recording | thinking | speaking | disconnected
3
  let currentState = 'idle';
4
 
5
- /**
6
- * 狀態轉換函數
7
- * @param {string} newState - 新狀態 (idle | recording | thinking | speaking | disconnected)
8
- * @param {object} options - 選項 {clearCards, showOutput, outputText, enableTTS}
9
- */
10
  function setState(newState, options = {}) {
11
- // 防止重複設置相同狀態
12
  if (currentState === newState) {
13
- console.log(`⚠️ 狀態已經是 ${newState},忽略`);
14
  return;
15
  }
16
 
17
  const oldState = currentState;
18
  currentState = newState;
19
- console.log(`🔄 狀態轉換: ${oldState} → ${newState}`);
20
 
21
- // 移除所有狀態 class
22
  micContainer.classList.remove('recording', 'thinking', 'speaking', 'disconnected');
23
 
24
- // 套用新狀態
25
  switch(newState) {
26
  case 'idle':
27
- // 閒置狀態:清除所有動畫
28
  hideAgentOutput();
29
  if (typeof stopSpeaking === 'function') {
30
  stopSpeaking();
@@ -35,23 +22,18 @@ function setState(newState, options = {}) {
35
  break;
36
 
37
  case 'recording':
38
- // 錄音狀態:紅色脈動核心
39
  micContainer.classList.add('recording');
40
- // 不清除 Agent 輸出,保留前次回應
41
  if (!options.keepOutput) {
42
  hideAgentOutput();
43
  }
44
- // 不清除工具卡片,保留前次結果
45
  if (!options.keepCards) {
46
  clearAllCards();
47
  }
48
- // 清除舊的 transcript,顯示錄音提示
49
  transcript.textContent = '聆聽中...';
50
  transcript.className = 'voice-transcript provisional';
51
  break;
52
 
53
  case 'thinking':
54
- // 思考中狀態:花瓣順時針綻放
55
  micContainer.classList.add('thinking');
56
  hideAgentOutput();
57
  if (typeof stopSpeaking === 'function') {
@@ -60,16 +42,13 @@ function setState(newState, options = {}) {
60
  break;
61
 
62
  case 'speaking':
63
- // 回覆中狀態:完全綻放 + 打字機效果
64
  micContainer.classList.add('speaking');
65
  if (options.outputText) {
66
- // typewriterEffect 內部會自動調用 speakText(如果 enableTTS 為 true)
67
  typewriterEffect(options.outputText, 40, options.enableTTS);
68
  }
69
  break;
70
 
71
  case 'disconnected':
72
- // 斷線狀態:花瓣逆時針變紅
73
  micContainer.classList.add('disconnected');
74
  hideAgentOutput();
75
  if (typeof stopSpeaking === 'function') {
@@ -83,9 +62,6 @@ function setState(newState, options = {}) {
83
  }
84
  }
85
 
86
- /**
87
- * 應用情緒主題
88
- */
89
  function applyEmotion(emotion) {
90
  const validEmotions = ['neutral', 'happy', 'sad', 'angry', 'fear', 'surprise'];
91
  if (!validEmotions.includes(emotion)) {
@@ -96,14 +72,9 @@ function applyEmotion(emotion) {
96
  emotionIndicator.textContent = `當前情緒: ${emotionEmojis[emotion]}`;
97
  }
98
 
99
- /**
100
- * 顯示錯誤通知
101
- */
102
  function showErrorNotification(message) {
103
- // 簡單的錯誤提示(可以改為更友善的 UI)
104
  console.error('🚨 錯誤:', message);
105
 
106
- // 使用打字機效果顯示錯誤
107
  setState('speaking', {
108
  outputText: `抱歉,發生錯誤:${message}`,
109
  enableTTS: false
@@ -112,42 +83,31 @@ function showErrorNotification(message) {
112
  setTimeout(() => setState('idle'), 3000);
113
  }
114
 
115
- // ========== 測試按鈕事件監聽器 ==========
116
 
117
- // 舊變數保留(向後兼容)
118
  let isThinking = false;
119
  let isDisconnected = false;
120
  let isRecording = false;
121
  let isSpeaking = false;
122
 
123
  function initAgentControls() {
124
- // === 真實的麥克風點擊事件(非測試按鈕)===
125
  micContainer.addEventListener('click', async () => {
126
- console.log('🎤 用戶點擊麥克風,當前狀態:', currentState);
127
 
128
- // 如果正在錄音,停止錄音
129
  if (currentState === 'recording') {
130
- console.log('⏹️ 停止錄音');
131
  isRecording = false;
132
 
133
- // 停止視覺化
134
  if (typeof stopRealAudioAnalysis === 'function') {
135
  stopRealAudioAnalysis();
136
  }
137
 
138
- // 停止 WebSocket 錄音
139
  if (wsManager && typeof wsManager.stopRecording === 'function') {
140
  wsManager.stopRecording();
141
  }
142
 
143
- // 轉換為思考狀態(等待 STT 和 Agent 回應)
144
  setState('thinking');
145
  return;
146
  }
147
 
148
- // 開始錄音狀態(不清除之前的回應,保留顯示)
149
  if (currentState === 'idle' || currentState === 'disconnected' || currentState === 'speaking') {
150
- // 如果在 speaking 狀態,先停止 TTS 播放
151
  if (currentState === 'speaking' && typeof stopSpeaking === 'function') {
152
  stopSpeaking();
153
  }
@@ -157,14 +117,11 @@ function initAgentControls() {
157
  keepOutput: true, // 保留前次 Agent 回應
158
  keepCards: true // 保留前次工具卡片
159
  });
160
- console.log('🎙️ 開始錄音(保留前次回應顯示)');
161
 
162
- // 啟動視覺化
163
  if (typeof startRealAudioAnalysis === 'function') {
164
  await startRealAudioAnalysis();
165
  }
166
 
167
- // 啟動 WebSocket 錄音(實際傳輸音訊數據)
168
  if (wsManager && typeof wsManager.startRecording === 'function') {
169
  const success = await wsManager.startRecording();
170
  if (!success) {
@@ -186,21 +143,17 @@ function initAgentControls() {
186
  }
187
  });
188
 
189
- // 錄音狀態(改用狀態機 + 真實音訊分析)
190
  document.getElementById('toggle-recording').addEventListener('click', async () => {
191
  isRecording = !isRecording;
192
  if (isRecording) {
193
  setState('recording');
194
- // 啟動真實音訊分析
195
  await startRealAudioAnalysis();
196
  } else {
197
  setState('idle');
198
- // 停止真實音訊分析
199
  stopRealAudioAnalysis();
200
  }
201
  });
202
 
203
- // 思考中狀態(改用狀態機)
204
  document.getElementById('toggle-thinking').addEventListener('click', () => {
205
  isThinking = !isThinking;
206
  if (isThinking) {
@@ -210,15 +163,12 @@ function initAgentControls() {
210
  }
211
  });
212
 
213
- // 回覆中狀態(改用狀態機)
214
  document.getElementById('toggle-speaking').addEventListener('click', () => {
215
  isSpeaking = !isSpeaking;
216
  if (isSpeaking) {
217
- // 回覆開始:顯示工具卡片
218
  clearAllCards();
219
  setTimeout(() => addToolCard('weather'), 300);
220
 
221
- // 模擬 Agent 回覆文字
222
  const responseText = '根據目前的天氣資料,台北今天氣溫約 23°C,天氣晴朗,濕度 65%。建議您外出時可以穿著輕便舒適的衣物,並記得攜帶太陽眼鏡。';
223
  setState('speaking', {outputText: responseText});
224
  } else {
@@ -226,7 +176,6 @@ function initAgentControls() {
226
  }
227
  });
228
 
229
- // 斷線狀態(改用狀態機)
230
  document.getElementById('toggle-disconnected').addEventListener('click', () => {
231
  isDisconnected = !isDisconnected;
232
  if (isDisconnected) {
 
 
 
1
  let currentState = 'idle';
2
 
 
 
 
 
 
3
  function setState(newState, options = {}) {
 
4
  if (currentState === newState) {
 
5
  return;
6
  }
7
 
8
  const oldState = currentState;
9
  currentState = newState;
 
10
 
 
11
  micContainer.classList.remove('recording', 'thinking', 'speaking', 'disconnected');
12
 
 
13
  switch(newState) {
14
  case 'idle':
 
15
  hideAgentOutput();
16
  if (typeof stopSpeaking === 'function') {
17
  stopSpeaking();
 
22
  break;
23
 
24
  case 'recording':
 
25
  micContainer.classList.add('recording');
 
26
  if (!options.keepOutput) {
27
  hideAgentOutput();
28
  }
 
29
  if (!options.keepCards) {
30
  clearAllCards();
31
  }
 
32
  transcript.textContent = '聆聽中...';
33
  transcript.className = 'voice-transcript provisional';
34
  break;
35
 
36
  case 'thinking':
 
37
  micContainer.classList.add('thinking');
38
  hideAgentOutput();
39
  if (typeof stopSpeaking === 'function') {
 
42
  break;
43
 
44
  case 'speaking':
 
45
  micContainer.classList.add('speaking');
46
  if (options.outputText) {
 
47
  typewriterEffect(options.outputText, 40, options.enableTTS);
48
  }
49
  break;
50
 
51
  case 'disconnected':
 
52
  micContainer.classList.add('disconnected');
53
  hideAgentOutput();
54
  if (typeof stopSpeaking === 'function') {
 
62
  }
63
  }
64
 
 
 
 
65
  function applyEmotion(emotion) {
66
  const validEmotions = ['neutral', 'happy', 'sad', 'angry', 'fear', 'surprise'];
67
  if (!validEmotions.includes(emotion)) {
 
72
  emotionIndicator.textContent = `當前情緒: ${emotionEmojis[emotion]}`;
73
  }
74
 
 
 
 
75
  function showErrorNotification(message) {
 
76
  console.error('🚨 錯誤:', message);
77
 
 
78
  setState('speaking', {
79
  outputText: `抱歉,發生錯誤:${message}`,
80
  enableTTS: false
 
83
  setTimeout(() => setState('idle'), 3000);
84
  }
85
 
 
86
 
 
87
  let isThinking = false;
88
  let isDisconnected = false;
89
  let isRecording = false;
90
  let isSpeaking = false;
91
 
92
  function initAgentControls() {
 
93
  micContainer.addEventListener('click', async () => {
 
94
 
 
95
  if (currentState === 'recording') {
 
96
  isRecording = false;
97
 
 
98
  if (typeof stopRealAudioAnalysis === 'function') {
99
  stopRealAudioAnalysis();
100
  }
101
 
 
102
  if (wsManager && typeof wsManager.stopRecording === 'function') {
103
  wsManager.stopRecording();
104
  }
105
 
 
106
  setState('thinking');
107
  return;
108
  }
109
 
 
110
  if (currentState === 'idle' || currentState === 'disconnected' || currentState === 'speaking') {
 
111
  if (currentState === 'speaking' && typeof stopSpeaking === 'function') {
112
  stopSpeaking();
113
  }
 
117
  keepOutput: true, // 保留前次 Agent 回應
118
  keepCards: true // 保留前次工具卡片
119
  });
 
120
 
 
121
  if (typeof startRealAudioAnalysis === 'function') {
122
  await startRealAudioAnalysis();
123
  }
124
 
 
125
  if (wsManager && typeof wsManager.startRecording === 'function') {
126
  const success = await wsManager.startRecording();
127
  if (!success) {
 
143
  }
144
  });
145
 
 
146
  document.getElementById('toggle-recording').addEventListener('click', async () => {
147
  isRecording = !isRecording;
148
  if (isRecording) {
149
  setState('recording');
 
150
  await startRealAudioAnalysis();
151
  } else {
152
  setState('idle');
 
153
  stopRealAudioAnalysis();
154
  }
155
  });
156
 
 
157
  document.getElementById('toggle-thinking').addEventListener('click', () => {
158
  isThinking = !isThinking;
159
  if (isThinking) {
 
163
  }
164
  });
165
 
 
166
  document.getElementById('toggle-speaking').addEventListener('click', () => {
167
  isSpeaking = !isSpeaking;
168
  if (isSpeaking) {
 
169
  clearAllCards();
170
  setTimeout(() => addToolCard('weather'), 300);
171
 
 
172
  const responseText = '根據目前的天氣資料,台北今天氣溫約 23°C,天氣晴朗,濕度 65%。建議您外出時可以穿著輕便舒適的衣物,並記得攜帶太陽眼鏡。';
173
  setState('speaking', {outputText: responseText});
174
  } else {
 
176
  }
177
  });
178
 
 
179
  document.getElementById('toggle-disconnected').addEventListener('click', () => {
180
  isDisconnected = !isDisconnected;
181
  if (isDisconnected) {
static/frontend/js/app.js CHANGED
@@ -1,13 +1,7 @@
1
- // ========== 登入狀態檢查 ==========
2
 
3
- /**
4
- * Google OAuth 登入(使用後端生成 PKCE)
5
- */
6
  async function handleGoogleLogin() {
7
  try {
8
- console.log('🚀 開始 Google OAuth 登入流程...');
9
 
10
- // 從後端獲取授權 URL 和 PKCE 參數
11
  const response = await fetch('/auth/google/url');
12
  const data = await response.json();
13
 
@@ -15,19 +9,11 @@ async function handleGoogleLogin() {
15
  throw new Error(data.error || '獲取授權 URL 失敗');
16
  }
17
 
18
- console.log('✅ 獲取授權 URL 成功');
19
 
20
- // 存儲 PKCE 參數到 sessionStorage
21
  sessionStorage.setItem('oauth_state', data.state);
22
  sessionStorage.setItem('oauth_code_verifier', data.code_verifier);
23
 
24
- console.log('🔐 PKCE 參數已存儲:', {
25
- state: data.state.substring(0, 8) + '...',
26
- codeVerifier: data.code_verifier.substring(0, 8) + '...'
27
- });
28
 
29
- // 重定向到 Google 授權頁面
30
- console.log('🌐 重定向到 Google 授權頁面...');
31
  window.location.href = data.auth_url;
32
 
33
  } catch (error) {
@@ -36,19 +22,13 @@ async function handleGoogleLogin() {
36
  }
37
  }
38
 
39
- /**
40
- * 檢查登入狀態,未登入則導向 login.html
41
- */
42
  async function checkLoginStatus() {
43
  const token = localStorage.getItem('jwt_token');
44
  if (!token) {
45
- // 未登入,導向登入頁面
46
- console.log('⚠️ 未登入,導向登入頁面...');
47
  window.location.href = '/login/';
48
  return false;
49
  }
50
 
51
- // 驗證 token 是否有效(解碼 JWT 檢查過期時間)
52
  try {
53
  const payload = JSON.parse(atob(token.split('.')[1]));
54
  const currentTime = Math.floor(Date.now() / 1000);
@@ -60,7 +40,6 @@ async function checkLoginStatus() {
60
  return false;
61
  }
62
 
63
- console.log('✅ Token 有效,初始化應用...');
64
  } catch (error) {
65
  console.error('❌ Token 解析失敗:', error);
66
  localStorage.removeItem('jwt_token');
@@ -68,18 +47,12 @@ async function checkLoginStatus() {
68
  return false;
69
  }
70
 
71
- console.log('✅ 已登入,初始化應用...');
72
  initializeApp(token);
73
  return true;
74
  }
75
 
76
- /**
77
- * 初始化應用(登入後)
78
- */
79
  async function initializeApp(token) {
80
- console.log('🚀 初始化應用...');
81
 
82
- // 初始化各個模組的事件監聽器
83
  initLoginButton();
84
  initLogoutButton();
85
  initChatIcon();
@@ -89,33 +62,21 @@ async function initializeApp(token) {
89
  initAgentControls();
90
  initToolDrawer(); // 初始化工具抽屜
91
 
92
- // 同步 MCP 工具 metadata
93
  syncToolMetadata();
94
 
95
- // 請求必要權限(麥克風 + 地理位置)
96
  await requestRequiredPermissions();
97
 
98
- // 初始化 WebSocket
99
  initializeWebSocket(token);
100
 
101
- console.log('✅ 應用初始化完成');
102
  }
103
 
104
- /**
105
- * 請求必要權限(麥克風 + 地理位置)
106
- */
107
  async function requestRequiredPermissions() {
108
- console.log('🔐 請求必要權限...');
109
 
110
- // 1. 請求麥克風權限
111
  try {
112
- console.log('🎤 請求麥克風權限...');
113
  const stream = await navigator.mediaDevices.getUserMedia({
114
  audio: { channelCount: 1, sampleRate: 48000 }
115
  });
116
- // 立即停止串流(只是為了觸發權限請求)
117
  stream.getTracks().forEach(track => track.stop());
118
- console.log('✅ 麥克風權限已授予');
119
  } catch (error) {
120
  console.warn('⚠️ 麥克風權限被拒絕:', error);
121
  if (typeof showErrorNotification === 'function') {
@@ -125,14 +86,11 @@ async function requestRequiredPermissions() {
125
  }
126
  }
127
 
128
- // 2. 請求地理位置權限
129
  if (navigator.geolocation) {
130
  try {
131
- console.log('📍 請求地理位置權限...');
132
  await new Promise((resolve, reject) => {
133
  navigator.geolocation.getCurrentPosition(
134
  (position) => {
135
- console.log('✅ 地理位置權限已授予');
136
  resolve(position);
137
  },
138
  (error) => {
@@ -152,25 +110,14 @@ async function requestRequiredPermissions() {
152
  console.warn('⚠️ 此瀏覽器不支援地理位置功能');
153
  }
154
 
155
- console.log('✅ 權限請求完成');
156
  }
157
 
158
- // ========== 頁面初始化 ==========
159
 
160
- // 只在聊天室頁面(/static/)執行登入檢查
161
- console.log('📍 當前路徑:', window.location.pathname);
162
  if (window.location.pathname.startsWith('/static')) {
163
- console.log('✅ 在聊天室頁面,執行登入檢查');
164
  checkLoginStatus();
165
  } else {
166
- console.log('⏭️ 不在聊天室頁面,跳過登入檢查');
167
  }
168
 
169
- console.log('💡 WebSocket 整合已載入');
170
- console.log('📝 部署時請執行: initializeWebSocket(your_jwt_token)');
171
 
172
- // ========== 提示訊息 ==========
173
- console.log('%c Bloom Ware 語音沉浸式 - 多層蓮花版', 'color: #16A34A; font-size: 16px; font-weight: bold;');
174
- console.log('%c✨ 核心特色:\n- 8片蓮花瓣設計(clip-path 打造自然曲線)\n- 多層次花蕊(radial gradient + 光澤細節)\n- 花瓣中心脈絡增加真實感\n- 待機狀態:花瓣完全閉合(含苞待放)\n- Agent 思考中:8片花瓣順時針依序綻放\n- 斷線/重連:花瓣逆時針綻放變���色警示\n- 錄音中:花蕊變紅脈衝,花瓣保持閉合\n- 品牌特色:優雅、精緻、現代', 'color: rgba(0,0,0,0.7); font-size: 12px;');
175
 
176
 
 
 
1
 
 
 
 
2
  async function handleGoogleLogin() {
3
  try {
 
4
 
 
5
  const response = await fetch('/auth/google/url');
6
  const data = await response.json();
7
 
 
9
  throw new Error(data.error || '獲取授權 URL 失敗');
10
  }
11
 
 
12
 
 
13
  sessionStorage.setItem('oauth_state', data.state);
14
  sessionStorage.setItem('oauth_code_verifier', data.code_verifier);
15
 
 
 
 
 
16
 
 
 
17
  window.location.href = data.auth_url;
18
 
19
  } catch (error) {
 
22
  }
23
  }
24
 
 
 
 
25
  async function checkLoginStatus() {
26
  const token = localStorage.getItem('jwt_token');
27
  if (!token) {
 
 
28
  window.location.href = '/login/';
29
  return false;
30
  }
31
 
 
32
  try {
33
  const payload = JSON.parse(atob(token.split('.')[1]));
34
  const currentTime = Math.floor(Date.now() / 1000);
 
40
  return false;
41
  }
42
 
 
43
  } catch (error) {
44
  console.error('❌ Token 解析失敗:', error);
45
  localStorage.removeItem('jwt_token');
 
47
  return false;
48
  }
49
 
 
50
  initializeApp(token);
51
  return true;
52
  }
53
 
 
 
 
54
  async function initializeApp(token) {
 
55
 
 
56
  initLoginButton();
57
  initLogoutButton();
58
  initChatIcon();
 
62
  initAgentControls();
63
  initToolDrawer(); // 初始化工具抽屜
64
 
 
65
  syncToolMetadata();
66
 
 
67
  await requestRequiredPermissions();
68
 
 
69
  initializeWebSocket(token);
70
 
 
71
  }
72
 
 
 
 
73
  async function requestRequiredPermissions() {
 
74
 
 
75
  try {
 
76
  const stream = await navigator.mediaDevices.getUserMedia({
77
  audio: { channelCount: 1, sampleRate: 48000 }
78
  });
 
79
  stream.getTracks().forEach(track => track.stop());
 
80
  } catch (error) {
81
  console.warn('⚠️ 麥克風權限被拒絕:', error);
82
  if (typeof showErrorNotification === 'function') {
 
86
  }
87
  }
88
 
 
89
  if (navigator.geolocation) {
90
  try {
 
91
  await new Promise((resolve, reject) => {
92
  navigator.geolocation.getCurrentPosition(
93
  (position) => {
 
94
  resolve(position);
95
  },
96
  (error) => {
 
110
  console.warn('⚠️ 此瀏覽器不支援地理位置功能');
111
  }
112
 
 
113
  }
114
 
 
115
 
 
 
116
  if (window.location.pathname.startsWith('/static')) {
 
117
  checkLoginStatus();
118
  } else {
 
119
  }
120
 
 
 
121
 
 
 
 
122
 
123
 
static/frontend/js/canvas.js CHANGED
@@ -1,4 +1,3 @@
1
- // ========== Canvas 波形渲染(效能優化版 + 真實音訊整合)==========
2
 
3
  const canvas = document.getElementById('waveform-canvas');
4
  const ctx = canvas.getContext('2d');
@@ -7,7 +6,6 @@ const centerY = canvas.height / 2;
7
  const baseRadius = 140;
8
  const maxAmplitude = 50;
9
 
10
- // 預計算角度 cos/sin 值以提升效能
11
  const points = 120; // 從 180 降到 120(降低 33% 計算量)
12
  const angleCache = [];
13
  const cosCache = [];
@@ -20,7 +18,6 @@ for (let i = 0; i <= points; i++) {
20
  sinCache[i] = Math.sin(angle);
21
  }
22
 
23
- // Web Audio API 整合
24
  let canvasAudioContext = null;
25
  let analyser = null;
26
  let dataArray = null;
@@ -28,12 +25,8 @@ let bufferLength = 0;
28
  let audioStream = null;
29
  let useRealAudio = false; // 是否使用真實音訊數據
30
 
31
- /**
32
- * 啟動真實音訊分析
33
- */
34
  async function startRealAudioAnalysis() {
35
  try {
36
- // 請求麥克風權限
37
  audioStream = await navigator.mediaDevices.getUserMedia({
38
  audio: {
39
  channelCount: 1,
@@ -43,7 +36,6 @@ async function startRealAudioAnalysis() {
43
  }
44
  });
45
 
46
- // 創建音訊上下文
47
  canvasAudioContext = new (window.AudioContext || window.webkitAudioContext)();
48
  analyser = canvasAudioContext.createAnalyser();
49
  analyser.fftSize = 256; // FFT 大小(必須是 2 的冪次)
@@ -52,27 +44,21 @@ async function startRealAudioAnalysis() {
52
  const source = canvasAudioContext.createMediaStreamSource(audioStream);
53
  source.connect(analyser);
54
 
55
- // 準備數據陣列
56
  bufferLength = analyser.frequencyBinCount; // fftSize / 2 = 128
57
  dataArray = new Uint8Array(bufferLength);
58
 
59
  useRealAudio = true;
60
- console.log('✅ 真實音訊分析已啟動');
61
 
62
  } catch (error) {
63
  console.warn('⚠️ 無法啟動真實音訊分析(降級為假動畫):', error);
64
  useRealAudio = false;
65
 
66
- // 顯示權限提示
67
  if (error.name === 'NotAllowedError') {
68
  showErrorNotification('需要麥克風權限才能使用語音功能');
69
  }
70
  }
71
  }
72
 
73
- /**
74
- * 停止真實音訊分析
75
- */
76
  function stopRealAudioAnalysis() {
77
  if (audioStream) {
78
  audioStream.getTracks().forEach(track => track.stop());
@@ -88,7 +74,6 @@ function stopRealAudioAnalysis() {
88
  dataArray = null;
89
  useRealAudio = false;
90
 
91
- console.log('🛑 真實音訊分析已停止');
92
  }
93
 
94
  function draw360Waveform() {
@@ -96,12 +81,10 @@ function draw360Waveform() {
96
 
97
  const time = Date.now() * 0.001;
98
 
99
- // 如果有真實音訊數據,使用它
100
  if (useRealAudio && analyser && dataArray) {
101
  analyser.getByteFrequencyData(dataArray); // 獲取頻率數據(0-255)
102
  }
103
 
104
- // 繪製多層波形(淺色主題)
105
  for (let layer = 0; layer < 3; layer++) {
106
  ctx.beginPath();
107
  ctx.strokeStyle = `rgba(0, 0, 0, ${0.08 - layer * 0.02})`;
@@ -116,11 +99,9 @@ function draw360Waveform() {
116
  let amplitude;
117
 
118
  if (useRealAudio && dataArray && bufferLength > 0) {
119
- // 真實音訊模式:將 120 個波形點對應到 bufferLength 個頻率數據
120
  const dataIndex = Math.floor((i / points) * bufferLength);
121
  const audioValue = dataArray[dataIndex] / 255.0; // 標準化到 0-1
122
 
123
- // 結合音訊數據和時間動畫
124
  const wave1 = audioValue * 0.6; // 主要由音訊驅動
125
  const wave2 = Math.sin(angle * 4 - time * 1.2) * 0.1; // 保留少量動畫
126
  const wave3 = sinCache[i * 6 % points] * 0.05 * Math.cos(time * 2);
@@ -128,7 +109,6 @@ function draw360Waveform() {
128
  amplitude = (wave1 + wave2 + wave3) * layerMultiplier;
129
 
130
  } else {
131
- // 假動畫模式(原邏輯)
132
  const wave1 = Math.sin(angle * 2 + time * 1.5 + layerOffset) * 0.3;
133
  const wave2 = Math.sin(angle * 4 - time * 1.2) * 0.2;
134
  const wave3 = sinCache[i * 6 % points] * 0.15 * Math.cos(time * 2);
@@ -153,5 +133,4 @@ function draw360Waveform() {
153
  requestAnimationFrame(draw360Waveform);
154
  }
155
 
156
- // 啟動波形渲染
157
  draw360Waveform();
 
 
1
 
2
  const canvas = document.getElementById('waveform-canvas');
3
  const ctx = canvas.getContext('2d');
 
6
  const baseRadius = 140;
7
  const maxAmplitude = 50;
8
 
 
9
  const points = 120; // 從 180 降到 120(降低 33% 計算量)
10
  const angleCache = [];
11
  const cosCache = [];
 
18
  sinCache[i] = Math.sin(angle);
19
  }
20
 
 
21
  let canvasAudioContext = null;
22
  let analyser = null;
23
  let dataArray = null;
 
25
  let audioStream = null;
26
  let useRealAudio = false; // 是否使用真實音訊數據
27
 
 
 
 
28
  async function startRealAudioAnalysis() {
29
  try {
 
30
  audioStream = await navigator.mediaDevices.getUserMedia({
31
  audio: {
32
  channelCount: 1,
 
36
  }
37
  });
38
 
 
39
  canvasAudioContext = new (window.AudioContext || window.webkitAudioContext)();
40
  analyser = canvasAudioContext.createAnalyser();
41
  analyser.fftSize = 256; // FFT 大小(必須是 2 的冪次)
 
44
  const source = canvasAudioContext.createMediaStreamSource(audioStream);
45
  source.connect(analyser);
46
 
 
47
  bufferLength = analyser.frequencyBinCount; // fftSize / 2 = 128
48
  dataArray = new Uint8Array(bufferLength);
49
 
50
  useRealAudio = true;
 
51
 
52
  } catch (error) {
53
  console.warn('⚠️ 無法啟動真實音訊分析(降級為假動畫):', error);
54
  useRealAudio = false;
55
 
 
56
  if (error.name === 'NotAllowedError') {
57
  showErrorNotification('需要麥克風權限才能使用語音功能');
58
  }
59
  }
60
  }
61
 
 
 
 
62
  function stopRealAudioAnalysis() {
63
  if (audioStream) {
64
  audioStream.getTracks().forEach(track => track.stop());
 
74
  dataArray = null;
75
  useRealAudio = false;
76
 
 
77
  }
78
 
79
  function draw360Waveform() {
 
81
 
82
  const time = Date.now() * 0.001;
83
 
 
84
  if (useRealAudio && analyser && dataArray) {
85
  analyser.getByteFrequencyData(dataArray); // 獲取頻率數據(0-255)
86
  }
87
 
 
88
  for (let layer = 0; layer < 3; layer++) {
89
  ctx.beginPath();
90
  ctx.strokeStyle = `rgba(0, 0, 0, ${0.08 - layer * 0.02})`;
 
99
  let amplitude;
100
 
101
  if (useRealAudio && dataArray && bufferLength > 0) {
 
102
  const dataIndex = Math.floor((i / points) * bufferLength);
103
  const audioValue = dataArray[dataIndex] / 255.0; // 標準化到 0-1
104
 
 
105
  const wave1 = audioValue * 0.6; // 主要由音訊驅動
106
  const wave2 = Math.sin(angle * 4 - time * 1.2) * 0.1; // 保留少量動畫
107
  const wave3 = sinCache[i * 6 % points] * 0.05 * Math.cos(time * 2);
 
109
  amplitude = (wave1 + wave2 + wave3) * layerMultiplier;
110
 
111
  } else {
 
112
  const wave1 = Math.sin(angle * 2 + time * 1.5 + layerOffset) * 0.3;
113
  const wave2 = Math.sin(angle * 4 - time * 1.2) * 0.2;
114
  const wave3 = sinCache[i * 6 % points] * 0.15 * Math.cos(time * 2);
 
133
  requestAnimationFrame(draw360Waveform);
134
  }
135
 
 
136
  draw360Waveform();
static/frontend/js/config.js CHANGED
@@ -1,6 +1,4 @@
1
- // ========== 全域變數與常數 ==========
2
 
3
- // DOM 元素參照
4
  const background = document.getElementById('background');
5
  const emotionIndicator = document.getElementById('emotion-indicator');
6
  const transcript = document.getElementById('transcript');
@@ -8,7 +6,6 @@ const micContainer = document.getElementById('mic-container');
8
  const cardsContainer = document.getElementById('tool-cards-container');
9
  const agentOutput = document.getElementById('agent-output');
10
 
11
- // 情緒表情對照表
12
  const emotionEmojis = {
13
  neutral: '😐 中性',
14
  happy: '😊 開心',
@@ -18,7 +15,6 @@ const emotionEmojis = {
18
  surprise: '😲 驚訝'
19
  };
20
 
21
- // 背景顏色對照表
22
  const emotionColors = {
23
  neutral: 'linear-gradient(135deg, #E6F7F0 0%, #F5F1ED 100%)',
24
  happy: 'linear-gradient(135deg, #FFF9E6 0%, #FFE6E6 100%)',
@@ -28,11 +24,8 @@ const emotionColors = {
28
  surprise: 'linear-gradient(135deg, #FFFFE6 0%, #FFF5E6 100%)'
29
  };
30
 
31
- // 打字機效果變數
32
  let typingInterval = null;
33
 
34
- // WebSocket 連接
35
  let ws = null;
36
 
37
- // MCP 工具 metadata 快取
38
  let toolsMetadata = [];
 
 
1
 
 
2
  const background = document.getElementById('background');
3
  const emotionIndicator = document.getElementById('emotion-indicator');
4
  const transcript = document.getElementById('transcript');
 
6
  const cardsContainer = document.getElementById('tool-cards-container');
7
  const agentOutput = document.getElementById('agent-output');
8
 
 
9
  const emotionEmojis = {
10
  neutral: '😐 中性',
11
  happy: '😊 開心',
 
15
  surprise: '😲 驚訝'
16
  };
17
 
 
18
  const emotionColors = {
19
  neutral: 'linear-gradient(135deg, #E6F7F0 0%, #F5F1ED 100%)',
20
  happy: 'linear-gradient(135deg, #FFF9E6 0%, #FFE6E6 100%)',
 
24
  surprise: 'linear-gradient(135deg, #FFFFE6 0%, #FFF5E6 100%)'
25
  };
26
 
 
27
  let typingInterval = null;
28
 
 
29
  let ws = null;
30
 
 
31
  let toolsMetadata = [];
static/frontend/js/location.js CHANGED
@@ -1,23 +1,16 @@
1
- // ========== 位置追蹤與環境感知 ==========
2
 
3
- /**
4
- * 位置追蹤管理器
5
- * 負責:
6
- * 1. 請求瀏覽器定位權限
7
- * 2. 定期追蹤用戶位置
8
- * 3. 發送 env_snapshot 到後端
9
- */
10
 
11
  let watchId = null;
12
  let lastPosition = null;
 
 
13
  let isTracking = false;
14
 
15
- /**
16
- * 啟動位置追蹤
17
- */
18
  async function startLocationTracking() {
19
  if (isTracking) {
20
- console.log('📍 位置追蹤已經在運行');
21
  return;
22
  }
23
 
@@ -26,10 +19,8 @@ async function startLocationTracking() {
26
  return;
27
  }
28
 
29
- console.log('📍 請求位置權限...');
30
 
31
  try {
32
- // 首次獲取位置(觸發權限請求)
33
  const position = await new Promise((resolve, reject) => {
34
  navigator.geolocation.getCurrentPosition(resolve, reject, {
35
  enableHighAccuracy: false, // 不需要高精度(省電)
@@ -38,10 +29,8 @@ async function startLocationTracking() {
38
  });
39
  });
40
 
41
- console.log('✅ 位置權限已授予');
42
  handlePositionUpdate(position);
43
 
44
- // 開始持續追蹤(每 30 秒更新一次)
45
  watchId = navigator.geolocation.watchPosition(
46
  handlePositionUpdate,
47
  handlePositionError,
@@ -53,38 +42,51 @@ async function startLocationTracking() {
53
  );
54
 
55
  isTracking = true;
56
- console.log('📍 位置追蹤已啟動(每 30 秒更新)');
57
 
58
  } catch (error) {
59
  handlePositionError(error);
60
  }
61
  }
62
 
63
- /**
64
- * 停止位置追蹤
65
- */
66
  function stopLocationTracking() {
67
  if (watchId !== null) {
68
  navigator.geolocation.clearWatch(watchId);
69
  watchId = null;
70
  isTracking = false;
71
- console.log('🛑 位置追蹤已停止');
72
  }
73
  }
74
 
75
- /**
76
- * 處理位置更新
77
- */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  function handlePositionUpdate(position) {
79
  const { latitude, longitude, accuracy, heading, speed } = position.coords;
80
  const timestamp = position.timestamp;
81
 
82
- console.log('📍 位置更新:', {
83
- lat: latitude.toFixed(6),
84
- lon: longitude.toFixed(6),
85
- accuracy: Math.round(accuracy) + 'm'
86
- });
87
-
88
  lastPosition = {
89
  lat: latitude,
90
  lon: longitude,
@@ -94,13 +96,13 @@ function handlePositionUpdate(position) {
94
  timestamp: timestamp
95
  };
96
 
97
- // 發送環境快照到後端
98
- sendEnvironmentSnapshot(lastPosition);
 
 
 
99
  }
100
 
101
- /**
102
- * 處理定位錯誤
103
- */
104
  function handlePositionError(error) {
105
  let errorMessage = '';
106
 
@@ -122,7 +124,6 @@ function handlePositionError(error) {
122
  console.warn('⚠️ 定位發生未知錯誤:', error);
123
  }
124
 
125
- // 即使定位失敗,也發送一個沒有位置的快照(包含時間等資訊)
126
  sendEnvironmentSnapshot({
127
  lat: null,
128
  lon: null,
@@ -131,31 +132,20 @@ function handlePositionError(error) {
131
  });
132
  }
133
 
134
- /**
135
- * 發送環境快照到後端
136
- * 欄位名稱需與後端 EnvironmentContextService 期望的一致
137
- */
138
  function sendEnvironmentSnapshot(positionData) {
139
  if (!wsManager || !wsManager.isConnected()) {
140
- console.warn('⚠️ WebSocket 未連線,跳過環境快照發送');
141
- return;
142
  }
143
 
144
- // 構建環境快照資料(欄位名稱對應後端 context_service.py)
145
  const snapshot = {
146
- // 位置資訊(後端期望的欄位名稱)
147
  lat: positionData.lat,
148
  lon: positionData.lon,
149
- accuracy_m: positionData.accuracy, // 後端期望 accuracy_m
150
- heading_deg: positionData.heading, // 後端期望 heading_deg
151
  speed: positionData.speed,
152
  timestamp: positionData.timestamp || Date.now(),
153
-
154
- // 時區與語系(後端期望的欄位名稱)
155
- tz: Intl.DateTimeFormat().resolvedOptions().timeZone, // 後端期望 tz
156
- locale: navigator.language, // 後端期望 locale
157
-
158
- // 裝置資訊(後端期望 device 物件)
159
  device: {
160
  user_agent: navigator.userAgent,
161
  platform: navigator.platform,
@@ -164,35 +154,20 @@ function sendEnvironmentSnapshot(positionData) {
164
  viewport_width: window.innerWidth,
165
  viewport_height: window.innerHeight
166
  },
167
-
168
- // 錯誤資訊(如果有)
169
  error: positionData.error || null
170
  };
171
 
172
- // 發送 WebSocket 訊息
173
- wsManager.send({
174
- type: 'env_snapshot',
175
- ...snapshot
176
- });
177
-
178
- console.log('📤 環境快照已發送:', {
179
- lat: snapshot.lat?.toFixed(6),
180
- lon: snapshot.lon?.toFixed(6),
181
- accuracy_m: snapshot.accuracy_m,
182
- tz: snapshot.tz
183
- });
184
  }
185
 
186
- /**
187
- * 手動觸發位置更新(用於用戶主動請求)
188
- */
189
  async function requestLocationUpdate() {
190
  if (!navigator.geolocation) {
191
  console.warn('⚠️ 此瀏覽器不支援定位功能');
192
  return null;
193
  }
194
 
195
- console.log('📍 手動請求位置更新...');
196
 
197
  try {
198
  const position = await new Promise((resolve, reject) => {
@@ -212,9 +187,6 @@ async function requestLocationUpdate() {
212
  }
213
  }
214
 
215
- /**
216
- * 取得最後已知位置
217
- */
218
  function getLastKnownPosition() {
219
  return lastPosition;
220
  }
 
 
1
 
 
 
 
 
 
 
 
2
 
3
  let watchId = null;
4
  let lastPosition = null;
5
+ let lastSentPosition = null; // 上次發送的位置
6
+ let lastSendTime = 0; // 上次發送時間
7
  let isTracking = false;
8
 
9
+ const MIN_SEND_INTERVAL = 60000; // 最小發送間隔:60 秒
10
+ const MIN_DISTANCE_CHANGE = 100; // 最小距離變化:100 米
11
+
12
  async function startLocationTracking() {
13
  if (isTracking) {
 
14
  return;
15
  }
16
 
 
19
  return;
20
  }
21
 
 
22
 
23
  try {
 
24
  const position = await new Promise((resolve, reject) => {
25
  navigator.geolocation.getCurrentPosition(resolve, reject, {
26
  enableHighAccuracy: false, // 不需要高精度(省電)
 
29
  });
30
  });
31
 
 
32
  handlePositionUpdate(position);
33
 
 
34
  watchId = navigator.geolocation.watchPosition(
35
  handlePositionUpdate,
36
  handlePositionError,
 
42
  );
43
 
44
  isTracking = true;
 
45
 
46
  } catch (error) {
47
  handlePositionError(error);
48
  }
49
  }
50
 
 
 
 
51
  function stopLocationTracking() {
52
  if (watchId !== null) {
53
  navigator.geolocation.clearWatch(watchId);
54
  watchId = null;
55
  isTracking = false;
 
56
  }
57
  }
58
 
59
+ function calculateDistance(lat1, lon1, lat2, lon2) {
60
+ const R = 6371000; // 地球半徑(米)
61
+ const dLat = (lat2 - lat1) * Math.PI / 180;
62
+ const dLon = (lon2 - lon1) * Math.PI / 180;
63
+ const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
64
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
65
+ Math.sin(dLon/2) * Math.sin(dLon/2);
66
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
67
+ return R * c;
68
+ }
69
+
70
+ function shouldSendUpdate(newPosition) {
71
+ const now = Date.now();
72
+
73
+ if (!lastSentPosition) return true;
74
+
75
+ const timeSinceLast = now - lastSendTime;
76
+ if (timeSinceLast < MIN_SEND_INTERVAL) return false;
77
+
78
+ const distance = calculateDistance(
79
+ lastSentPosition.lat, lastSentPosition.lon,
80
+ newPosition.lat, newPosition.lon
81
+ );
82
+
83
+ return distance >= MIN_DISTANCE_CHANGE;
84
+ }
85
+
86
  function handlePositionUpdate(position) {
87
  const { latitude, longitude, accuracy, heading, speed } = position.coords;
88
  const timestamp = position.timestamp;
89
 
 
 
 
 
 
 
90
  lastPosition = {
91
  lat: latitude,
92
  lon: longitude,
 
96
  timestamp: timestamp
97
  };
98
 
99
+ if (shouldSendUpdate(lastPosition)) {
100
+ sendEnvironmentSnapshot(lastPosition);
101
+ lastSentPosition = { ...lastPosition };
102
+ lastSendTime = Date.now();
103
+ }
104
  }
105
 
 
 
 
106
  function handlePositionError(error) {
107
  let errorMessage = '';
108
 
 
124
  console.warn('⚠️ 定位發生未知錯誤:', error);
125
  }
126
 
 
127
  sendEnvironmentSnapshot({
128
  lat: null,
129
  lon: null,
 
132
  });
133
  }
134
 
 
 
 
 
135
  function sendEnvironmentSnapshot(positionData) {
136
  if (!wsManager || !wsManager.isConnected()) {
137
+ return; // 靜默跳過
 
138
  }
139
 
 
140
  const snapshot = {
 
141
  lat: positionData.lat,
142
  lon: positionData.lon,
143
+ accuracy_m: positionData.accuracy,
144
+ heading_deg: positionData.heading,
145
  speed: positionData.speed,
146
  timestamp: positionData.timestamp || Date.now(),
147
+ tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
148
+ locale: navigator.language,
 
 
 
 
149
  device: {
150
  user_agent: navigator.userAgent,
151
  platform: navigator.platform,
 
154
  viewport_width: window.innerWidth,
155
  viewport_height: window.innerHeight
156
  },
 
 
157
  error: positionData.error || null
158
  };
159
 
160
+ wsManager.send({ type: 'env_snapshot', ...snapshot });
161
+ if (window.DEBUG_MODE) {
162
+ }
 
 
 
 
 
 
 
 
 
163
  }
164
 
 
 
 
165
  async function requestLocationUpdate() {
166
  if (!navigator.geolocation) {
167
  console.warn('⚠️ 此瀏覽器不支援定位功能');
168
  return null;
169
  }
170
 
 
171
 
172
  try {
173
  const position = await new Promise((resolve, reject) => {
 
187
  }
188
  }
189
 
 
 
 
190
  function getLastKnownPosition() {
191
  return lastPosition;
192
  }
static/frontend/js/speech.js CHANGED
@@ -1,7 +1,3 @@
1
- /**
2
- * 語音識別模組
3
- * 處理語音輸入和語音合成
4
- */
5
 
6
  class SpeechRecognition {
7
  constructor() {
@@ -13,7 +9,6 @@ class SpeechRecognition {
13
  this.onStart = null;
14
  this.onEnd = null;
15
 
16
- // 語音辨識上下文
17
  this.context = {
18
  userLocation: null,
19
  recentQueries: [],
@@ -24,7 +19,6 @@ class SpeechRecognition {
24
  }
25
 
26
  initRecognition() {
27
- // 檢查瀏覽器支援
28
  if ('webkitSpeechRecognition' in window) {
29
  this.recognition = new webkitSpeechRecognition();
30
  this.isSupported = true;
@@ -36,16 +30,13 @@ class SpeechRecognition {
36
  return;
37
  }
38
 
39
- // 設定語音識別參數
40
  this.recognition.continuous = false;
41
  this.recognition.interimResults = true;
42
  this.recognition.lang = 'zh-TW';
43
  this.recognition.maxAlternatives = 3; // 增加候選結果數量
44
 
45
- // 綁定事件
46
  this.recognition.onstart = () => {
47
  this.isListening = true;
48
- console.log('🎤 語音識別開始');
49
  if (this.onStart) this.onStart();
50
  };
51
 
@@ -62,9 +53,7 @@ class SpeechRecognition {
62
  }
63
  }
64
 
65
- console.log('🎤 語音識別原始結果:', finalTranscript || interimTranscript);
66
 
67
- // 使用語音增強功能
68
  let enhancedFinal = finalTranscript;
69
  let enhancedInterim = interimTranscript;
70
 
@@ -93,7 +82,6 @@ class SpeechRecognition {
93
 
94
  this.recognition.onend = () => {
95
  this.isListening = false;
96
- console.log('🎤 語音識別結束');
97
  if (this.onEnd) this.onEnd();
98
  };
99
  }
@@ -130,31 +118,19 @@ class SpeechRecognition {
130
  }
131
  }
132
 
133
- /**
134
- * 設定用戶位置上下文
135
- */
136
  setUserLocation(location) {
137
  this.context.userLocation = location;
138
- console.log('📍 設定用戶位置上下文:', location);
139
  }
140
 
141
- /**
142
- * 添加查詢歷史
143
- */
144
  addRecentQuery(query) {
145
  if (query && typeof query === 'string') {
146
  this.context.recentQueries.unshift(query);
147
- // 只保留最近 10 次查詢
148
  if (this.context.recentQueries.length > 10) {
149
  this.context.recentQueries = this.context.recentQueries.slice(0, 10);
150
  }
151
- console.log('📝 添加查詢歷史:', query);
152
  }
153
  }
154
 
155
- /**
156
- * 獲取當前上下文
157
- */
158
  getContext() {
159
  return {
160
  ...this.context,
@@ -162,20 +138,15 @@ class SpeechRecognition {
162
  };
163
  }
164
 
165
- /**
166
- * 清除上下文
167
- */
168
  clearContext() {
169
  this.context = {
170
  userLocation: null,
171
  recentQueries: [],
172
  currentSession: null
173
  };
174
- console.log('🗑️ 已清除語音辨識上下文');
175
  }
176
  }
177
 
178
- // 語音合成類
179
  class TextToSpeech {
180
  constructor() {
181
  this.synth = window.speechSynthesis;
@@ -190,27 +161,22 @@ class TextToSpeech {
190
  return false;
191
  }
192
 
193
- // 停止當前播放
194
  this.stop();
195
 
196
  const utterance = new SpeechSynthesisUtterance(text);
197
 
198
- // 設定參數
199
  utterance.lang = options.lang || 'zh-TW';
200
  utterance.rate = options.rate || 1.0;
201
  utterance.pitch = options.pitch || 1.0;
202
  utterance.volume = options.volume || 1.0;
203
 
204
- // 綁定事件
205
  utterance.onstart = () => {
206
  this.isSpeaking = true;
207
- console.log('🔊 語音合成開始');
208
  };
209
 
210
  utterance.onend = () => {
211
  this.isSpeaking = false;
212
  this.currentUtterance = null;
213
- console.log('🔊 語音合成結束');
214
  };
215
 
216
  utterance.onerror = (event) => {
@@ -245,6 +211,5 @@ class TextToSpeech {
245
  }
246
  }
247
 
248
- // 全域實例
249
  window.speechRecognition = new SpeechRecognition();
250
  window.textToSpeech = new TextToSpeech();
 
 
 
 
 
1
 
2
  class SpeechRecognition {
3
  constructor() {
 
9
  this.onStart = null;
10
  this.onEnd = null;
11
 
 
12
  this.context = {
13
  userLocation: null,
14
  recentQueries: [],
 
19
  }
20
 
21
  initRecognition() {
 
22
  if ('webkitSpeechRecognition' in window) {
23
  this.recognition = new webkitSpeechRecognition();
24
  this.isSupported = true;
 
30
  return;
31
  }
32
 
 
33
  this.recognition.continuous = false;
34
  this.recognition.interimResults = true;
35
  this.recognition.lang = 'zh-TW';
36
  this.recognition.maxAlternatives = 3; // 增加候選結果數量
37
 
 
38
  this.recognition.onstart = () => {
39
  this.isListening = true;
 
40
  if (this.onStart) this.onStart();
41
  };
42
 
 
53
  }
54
  }
55
 
 
56
 
 
57
  let enhancedFinal = finalTranscript;
58
  let enhancedInterim = interimTranscript;
59
 
 
82
 
83
  this.recognition.onend = () => {
84
  this.isListening = false;
 
85
  if (this.onEnd) this.onEnd();
86
  };
87
  }
 
118
  }
119
  }
120
 
 
 
 
121
  setUserLocation(location) {
122
  this.context.userLocation = location;
 
123
  }
124
 
 
 
 
125
  addRecentQuery(query) {
126
  if (query && typeof query === 'string') {
127
  this.context.recentQueries.unshift(query);
 
128
  if (this.context.recentQueries.length > 10) {
129
  this.context.recentQueries = this.context.recentQueries.slice(0, 10);
130
  }
 
131
  }
132
  }
133
 
 
 
 
134
  getContext() {
135
  return {
136
  ...this.context,
 
138
  };
139
  }
140
 
 
 
 
141
  clearContext() {
142
  this.context = {
143
  userLocation: null,
144
  recentQueries: [],
145
  currentSession: null
146
  };
 
147
  }
148
  }
149
 
 
150
  class TextToSpeech {
151
  constructor() {
152
  this.synth = window.speechSynthesis;
 
161
  return false;
162
  }
163
 
 
164
  this.stop();
165
 
166
  const utterance = new SpeechSynthesisUtterance(text);
167
 
 
168
  utterance.lang = options.lang || 'zh-TW';
169
  utterance.rate = options.rate || 1.0;
170
  utterance.pitch = options.pitch || 1.0;
171
  utterance.volume = options.volume || 1.0;
172
 
 
173
  utterance.onstart = () => {
174
  this.isSpeaking = true;
 
175
  };
176
 
177
  utterance.onend = () => {
178
  this.isSpeaking = false;
179
  this.currentUtterance = null;
 
180
  };
181
 
182
  utterance.onerror = (event) => {
 
211
  }
212
  }
213
 
 
214
  window.speechRecognition = new SpeechRecognition();
215
  window.textToSpeech = new TextToSpeech();
static/frontend/js/tools.js CHANGED
@@ -1,10 +1,8 @@
1
- // ========== 工具卡片管理(改良版:支援抽屜面板)==========
2
 
3
  const positions = ['pos-top-right', 'pos-top-left', 'pos-bottom-right', 'pos-bottom-left'];
4
  let usedPositions = [];
5
  const MAX_CARDS = 4;
6
 
7
- // 多語言標籤定義
8
  const LABELS = {
9
  zh: {
10
  temperature: '溫度', condition: '狀況', humidity: '濕度', wind_speed: '風速',
@@ -110,10 +108,8 @@ const LABELS = {
110
  }
111
  };
112
 
113
- // 當前語言(從用戶訊息自動檢測)
114
  let currentLanguage = 'zh';
115
 
116
- // 抽屜相關元素
117
  let toolDrawer = null;
118
  let toolDrawerToggle = null;
119
  let toolDrawerContent = null;
@@ -121,9 +117,6 @@ let toolDrawerOverlay = null;
121
  let toolDrawerClose = null;
122
  let isDrawerOpen = false;
123
 
124
- /**
125
- * 初始化工具抽屜
126
- */
127
  function initToolDrawer() {
128
  toolDrawer = document.getElementById('toolDrawer');
129
  toolDrawerToggle = document.getElementById('toolDrawerToggle');
@@ -136,35 +129,24 @@ function initToolDrawer() {
136
  return;
137
  }
138
 
139
- // 綁定切換按鈕事件
140
  toolDrawerToggle.addEventListener('click', toggleToolDrawer);
141
 
142
- // 綁定關閉按鈕事件
143
  if (toolDrawerClose) {
144
  toolDrawerClose.addEventListener('click', hideToolDrawer);
145
  }
146
 
147
- // 綁定遮罩層點擊關閉
148
  if (toolDrawerOverlay) {
149
  toolDrawerOverlay.addEventListener('click', hideToolDrawer);
150
  }
151
 
152
- console.log('✅ 工具抽屜已初始化');
153
  }
154
 
155
- /**
156
- * 顯示工具抽屜切換按鈕(有工具結果時調用)
157
- */
158
  function showToolDrawerToggle() {
159
  if (toolDrawerToggle) {
160
  toolDrawerToggle.classList.add('visible');
161
- console.log('📊 工具抽屜按鈕已顯示');
162
  }
163
  }
164
 
165
- /**
166
- * 隱藏工具抽屜切換按鈕
167
- */
168
  function hideToolDrawerToggle() {
169
  if (toolDrawerToggle) {
170
  toolDrawerToggle.classList.remove('visible');
@@ -172,9 +154,6 @@ function hideToolDrawerToggle() {
172
  }
173
  }
174
 
175
- /**
176
- * 切換工具抽屜開關
177
- */
178
  function toggleToolDrawer() {
179
  if (isDrawerOpen) {
180
  hideToolDrawer();
@@ -183,51 +162,34 @@ function toggleToolDrawer() {
183
  }
184
  }
185
 
186
- /**
187
- * 打開工具抽屜
188
- */
189
  function showToolDrawer() {
190
  if (toolDrawer) {
191
  toolDrawer.classList.add('open');
192
  toolDrawerToggle?.classList.add('open');
193
  toolDrawerOverlay?.classList.add('visible');
194
  isDrawerOpen = true;
195
- console.log('📂 工具抽屜已打開');
196
  }
197
  }
198
 
199
- /**
200
- * 關閉工具抽屜
201
- */
202
  function hideToolDrawer() {
203
  if (toolDrawer) {
204
  toolDrawer.classList.remove('open');
205
  toolDrawerToggle?.classList.remove('open');
206
  toolDrawerOverlay?.classList.remove('visible');
207
  isDrawerOpen = false;
208
- console.log('📁 工具抽屜已關閉');
209
  }
210
  }
211
 
212
- /**
213
- * 隱藏工具卡片(下一個請求或關懷模式時調用)
214
- */
215
  function hideToolCards() {
216
- // 隱藏抽屜
217
  hideToolDrawer();
218
- // 隱藏切換按鈕
219
  hideToolDrawerToggle();
220
- // 清空抽屜內容
221
  if (toolDrawerContent) {
222
  toolDrawerContent.innerHTML = '';
223
  }
224
- // 清空桌面端卡片容器
225
  clearAllCards();
226
- console.log('🗑️ 工具卡片已隱藏');
227
  }
228
 
229
  function getNextPosition() {
230
- // 如果卡片數量已達上限,不允許新增
231
  if (usedPositions.length >= MAX_CARDS) {
232
  console.warn('⚠️ 卡片數量已達上限(4張),請先清除現有卡片');
233
  return null;
@@ -245,7 +207,6 @@ function getNextPosition() {
245
  function addToolCard(type) {
246
  const position = getNextPosition();
247
 
248
- // 如果沒有可用位置,直接返回
249
  if (!position) {
250
  return;
251
  }
@@ -326,7 +287,6 @@ function clearAllCards() {
326
  usedPositions = [];
327
  }
328
 
329
- // 模擬工具調用事件監聽(延遲初始化)
330
  function initToolCardControls() {
331
  document.getElementById('simulate-weather').addEventListener('click', () => {
332
  clearAllCards();
@@ -350,11 +310,7 @@ function initToolCardControls() {
350
  });
351
  }
352
 
353
- // ========== MCP 工具 Metadata 同步 ==========
354
 
355
- /**
356
- * 從後端同步工具 metadata
357
- */
358
  async function syncToolMetadata() {
359
  try {
360
  const response = await fetch('/api/mcp/tools', {
@@ -366,12 +322,10 @@ async function syncToolMetadata() {
366
  if (response.ok) {
367
  const data = await response.json();
368
  if (data.success && data.tools) {
369
- // 將工具 metadata 儲存到全域變數(定義在 config.js)
370
  toolsMetadata = {};
371
  data.tools.forEach(tool => {
372
  toolsMetadata[tool.name] = tool;
373
  });
374
- console.log(`✅ 同步 ${data.count} 個 MCP 工具 metadata`);
375
  }
376
  }
377
  } catch (error) {
@@ -379,12 +333,8 @@ async function syncToolMetadata() {
379
  }
380
  }
381
 
382
- /**
383
- * 根據分類/工具名稱自動分配圖示
384
- */
385
  function getIconForTool(toolName, category) {
386
  const iconMap = {
387
- // 分類映射
388
  '健康': '❤️',
389
  '天氣': '🌤️',
390
  '新聞': '📰',
@@ -400,7 +350,6 @@ function getIconForTool(toolName, category) {
400
  '軌道運輸': '🚇',
401
  '地理定位': '📍',
402
 
403
- // 工具名稱映射
404
  'healthkit_query': '❤️',
405
  'weather_query': '🌤️',
406
  'news_query': '📰',
@@ -415,37 +364,26 @@ function getIconForTool(toolName, category) {
415
  'directions': '🗺️'
416
  };
417
 
418
- // 優先使用工具名稱匹配
419
  if (iconMap[toolName]) {
420
  return iconMap[toolName];
421
  }
422
 
423
- // 其次使用分類匹配
424
  if (category && iconMap[category]) {
425
  return iconMap[category];
426
  }
427
 
428
- // 預設圖示
429
  return '🔧';
430
  }
431
 
432
- /**
433
- * 動態顯示工具卡片(通用版本,支援所有 MCP 工具)
434
- * 優先渲染到抽屜面板(手機端),同時保留桌面端卡片
435
- */
436
  function displayToolCard(toolName, toolData) {
437
- // 清除舊卡片
438
  clearAllCards();
439
 
440
- // 獲取工具 metadata
441
  const toolMeta = toolsMetadata[toolName] || {};
442
  const category = toolMeta.category || '未知';
443
  const icon = getIconForTool(toolName, category);
444
 
445
- // 渲染卡片內容(處理後的結果,非 raw data)
446
  const contentHTML = renderCardContent(toolName, toolData);
447
 
448
- // 創建卡片元素
449
  const card = document.createElement('div');
450
  card.className = 'voice-tool-card';
451
  card.dataset.type = toolName;
@@ -458,140 +396,96 @@ function displayToolCard(toolName, toolData) {
458
  <div class="card-content" style="max-height: 300px; overflow-y: auto; overflow-x: hidden; padding-right: 8px;">${contentHTML}</div>
459
  `;
460
 
461
- // 渲染到抽屜面板
462
  if (toolDrawerContent) {
463
  toolDrawerContent.innerHTML = '';
464
  toolDrawerContent.appendChild(card.cloneNode(true));
465
- // 顯示抽屜切換按鈕
466
  showToolDrawerToggle();
467
- console.log(`📊 工具卡片已渲染到抽屜: ${toolName} (${category})`);
468
  }
469
 
470
- // 同時渲染到桌面端卡片容器(保留原有邏輯)
471
  const position = getNextPosition();
472
  if (position && cardsContainer) {
473
  card.classList.add(position);
474
  cardsContainer.appendChild(card);
475
- console.log(`🃏 工具卡片已渲染到桌面: ${toolName} (${category})`);
476
  }
477
  }
478
 
479
- /**
480
- * 根據工具數據結構自動渲染內容
481
- */
482
  function renderCardContent(toolName, toolData) {
483
- console.log('🔍 renderCardContent 被調用:', {toolName, toolData});
484
 
485
  if (!toolData) {
486
  console.warn('⚠️ toolData 為空');
487
  return '<p class="data-row">無數據</p>';
488
  }
489
 
490
- // 模式 1:health_data 陣列(直接或在 raw_data 中)
491
  const healthData = toolData.health_data || toolData.raw_data?.health_data;
492
  if (healthData && Array.isArray(healthData)) {
493
- console.log('✅ 匹配到模式 1: health_data');
494
  return renderHealthMetrics(healthData);
495
  }
496
 
497
- // 模式 2:articles 陣列(直接或在 raw_data 中)
498
  const articlesData = toolData.articles || toolData.raw_data?.articles;
499
  if (articlesData && Array.isArray(articlesData)) {
500
- console.log('✅ 匹配到模式 2: articles');
501
  return renderNewsList(articlesData);
502
  }
503
 
504
- // 模式 3:天氣數據(直接檢查,無論是否包在 raw_data 中)
505
  const weatherData = toolData.raw_data || toolData;
506
  if (weatherData.main && weatherData.weather) {
507
- console.log('✅ 匹配到模式 3: 天氣數據');
508
  return renderWeatherData(weatherData);
509
  }
510
 
511
- // 模式 4:公車到站資訊
512
  if (toolData.arrivals && Array.isArray(toolData.arrivals)) {
513
- console.log('✅ 匹配到模式 4: 公車到站資訊');
514
  return renderBusArrivals(toolData.arrivals, toolData.route_name);
515
  }
516
 
517
- // 模式 5:附近公車站點
518
  if (toolData.stops && Array.isArray(toolData.stops)) {
519
- console.log('✅ 匹配到模式 5: 附近公車站點');
520
  return renderNearbyStops(toolData.stops);
521
  }
522
 
523
- // 模式 6:匯率數據(直接或在 raw_data 中)
524
  const exchangeData = toolData.raw_data || toolData;
525
  if (exchangeData.rate !== undefined && exchangeData.from_currency !== undefined) {
526
- console.log('✅ 匹配到模式 6: 匯率數據');
527
  return renderExchangeRate(exchangeData);
528
  }
529
 
530
- // 模式 7:火車列車資訊
531
  if (toolData.trains && Array.isArray(toolData.trains)) {
532
- console.log('✅ 匹配到模式 7: 火車列車資訊');
533
  return renderTrainList(toolData.trains);
534
  }
535
 
536
- // 模式 8:YouBike 站點資訊(需要確認是 YouBike 工具)
537
  if (toolData.stations && Array.isArray(toolData.stations) &&
538
  (toolName === 'tdx_youbike' || toolData.stations[0]?.available_bikes !== undefined)) {
539
- console.log('✅ 匹配到模式 8: YouBike 站點資訊');
540
  return renderYouBikeStations(toolData.stations);
541
  }
542
 
543
- // 模式 8.5:火車站點資訊(tdx_train 的 stations)
544
  if (toolData.stations && Array.isArray(toolData.stations) && toolName === 'tdx_train') {
545
- console.log('✅ 匹配到模式 8.5: 火車站點資訊');
546
  return renderTrainStations(toolData.stations);
547
  }
548
 
549
- // 模式 9:地理反查資訊(reverse_geocode)
550
  if (toolData.display_name && toolData.lat && toolData.lon && toolName === 'reverse_geocode') {
551
- console.log('✅ 匹配到模式 9: 地理反查資訊');
552
  return renderReverseGeocode(toolData);
553
  }
554
 
555
- // 模式 10:導航路線(directions)
556
  if ((toolData.distance_m !== undefined || toolData.duration_s !== undefined) &&
557
  (toolName === 'directions' || toolData.polyline !== undefined)) {
558
- console.log('✅ 匹配到模式 10: 導航路線');
559
  return renderDirections(toolData);
560
  }
561
 
562
- // 模式 11:捷運到站資訊(tdx_metro arrivals)
563
  if (toolData.arrivals && Array.isArray(toolData.arrivals) && toolName === 'tdx_metro') {
564
- console.log('✅ 匹配到模式 11: 捷運到站資訊');
565
  return renderMetroArrivals(toolData.arrivals);
566
  }
567
 
568
- // 模式 12:捷運站點資訊(tdx_metro stations)
569
  if (toolData.stations && Array.isArray(toolData.stations) && toolName === 'tdx_metro') {
570
- console.log('✅ 匹配到模式 12: 捷運站點資訊');
571
  return renderMetroStations(toolData.stations);
572
  }
573
 
574
- // 模式 13:正向地理編碼(forward_geocode)
575
  if (toolData.lat && toolData.lon && toolData.display_name && toolName === 'forward_geocode') {
576
- console.log('✅ 匹配到模式 13: 正向地理編碼');
577
  return renderForwardGeocode(toolData);
578
  }
579
 
580
- // 模式 14:通用 raw_data 物件
581
  if (toolData.raw_data && typeof toolData.raw_data === 'object') {
582
- console.log('✅ 匹配到模式 14: 通用 raw_data');
583
  return renderKeyValuePairs(toolData.raw_data);
584
  }
585
 
586
- // Fallback:顯示 JSON
587
  console.warn('⚠️ 未匹配任何模式,使用 JSON fallback');
588
- console.log('📋 toolData 結構:', Object.keys(toolData));
589
  return renderJSONFallback(toolData);
590
  }
591
 
592
- /**
593
- * 渲染天氣數據
594
- */
595
  function renderWeatherData(data) {
596
  const main = data.main || {};
597
  const weather = data.weather?.[0] || {};
@@ -599,7 +493,6 @@ function renderWeatherData(data) {
599
  const sys = data.sys || {};
600
  const labels = LABELS[currentLanguage] || LABELS.zh;
601
 
602
- // 格式化時間
603
  const formatTime = (timestamp) => {
604
  if (!timestamp) return '--:--';
605
  const date = new Date(timestamp * 1000);
@@ -642,9 +535,6 @@ function renderWeatherData(data) {
642
  `;
643
  }
644
 
645
- /**
646
- * 渲染健康指標
647
- */
648
  function renderHealthMetrics(healthData) {
649
  const labels = LABELS[currentLanguage] || LABELS.zh;
650
 
@@ -660,7 +550,6 @@ function renderHealthMetrics(healthData) {
660
  sleep_analysis: '😴'
661
  };
662
 
663
- // 按指標類型分組
664
  const grouped = {};
665
  healthData.forEach(item => {
666
  const metric = item.metric || item.type;
@@ -672,7 +561,6 @@ function renderHealthMetrics(healthData) {
672
 
673
  let html = '<div class="health-metrics">';
674
 
675
- // 渲染每種指標
676
  Object.entries(grouped).forEach(([metric, items], index) => {
677
  const icon = metricIcons[metric] || '📊';
678
  const label = labels[metric] || metric;
@@ -680,7 +568,6 @@ function renderHealthMetrics(healthData) {
680
  const value = latestItem.value;
681
  const unit = latestItem.unit || '';
682
 
683
- // 格式化時間
684
  let timeStr = '';
685
  if (latestItem.timestamp) {
686
  try {
@@ -722,9 +609,6 @@ function renderHealthMetrics(healthData) {
722
  return html;
723
  }
724
 
725
- /**
726
- * 渲染新聞列表
727
- */
728
  function renderNewsList(articles) {
729
  const labels = LABELS[currentLanguage] || LABELS.zh;
730
  let html = '';
@@ -740,11 +624,7 @@ function renderNewsList(articles) {
740
  return html || `<p>${labels.no_news}</p>`;
741
  }
742
 
743
- /**
744
- * 渲染鍵值對(天氣等)
745
- */
746
  function renderKeyValuePairs(data) {
747
- // 使用當前語言的標籤
748
  const labels = LABELS[currentLanguage] || LABELS.zh;
749
  const keyMap = {
750
  city: labels.city,
@@ -764,7 +644,6 @@ function renderKeyValuePairs(data) {
764
  const label = keyMap[key] || key;
765
  let displayValue = value;
766
 
767
- // 特殊處理溫度
768
  if (key.includes('temp') && typeof value === 'number') {
769
  displayValue = `${value}°C`;
770
  }
@@ -780,12 +659,6 @@ function renderKeyValuePairs(data) {
780
  return html || '<p>無數據</p>';
781
  }
782
 
783
- /**
784
- * 渲染匯率資訊
785
- */
786
- /**
787
- * 渲染匯率信息
788
- */
789
  function renderExchangeRate(data) {
790
  const labels = LABELS[currentLanguage] || LABELS.zh;
791
  const currencySymbols = {
@@ -800,7 +673,6 @@ function renderExchangeRate(data) {
800
 
801
  let html = '';
802
 
803
- // 匯率
804
  if (data.rate !== undefined) {
805
  html += `
806
  <div class="data-row">
@@ -810,7 +682,6 @@ function renderExchangeRate(data) {
810
  `;
811
  }
812
 
813
- // 轉換金額
814
  if (data.amount && data.converted_amount !== undefined) {
815
  html += `
816
  <div class="data-row">
@@ -820,7 +691,6 @@ function renderExchangeRate(data) {
820
  `;
821
  }
822
 
823
- // 查詢時間
824
  if (data.raw_data?.metadata?.timestamp) {
825
  const time = new Date(data.raw_data.metadata.timestamp).toLocaleString('zh-TW');
826
  html += `
@@ -834,9 +704,6 @@ function renderExchangeRate(data) {
834
  return html || `<p>${labels.no_data}</p>`;
835
  }
836
 
837
- /**
838
- * 渲染火車列車資訊
839
- */
840
  function renderTrainList(trains) {
841
  const labels = LABELS[currentLanguage] || LABELS.zh;
842
 
@@ -884,9 +751,6 @@ function renderTrainList(trains) {
884
  return html;
885
  }
886
 
887
- /**
888
- * 渲染火車站點資訊
889
- */
890
  function renderTrainStations(stations) {
891
  const labels = LABELS[currentLanguage] || LABELS.zh;
892
 
@@ -927,9 +791,6 @@ function renderTrainStations(stations) {
927
  return html;
928
  }
929
 
930
- /**
931
- * 渲染 YouBike 站點資訊
932
- */
933
  function renderYouBikeStations(stations) {
934
  const labels = LABELS[currentLanguage] || LABELS.zh;
935
 
@@ -951,7 +812,6 @@ function renderYouBikeStations(stations) {
951
  const bikeUnit = currentLanguage === 'zh' ? '輛' : currentLanguage === 'en' ? '' : currentLanguage === 'ko' ? '대' : currentLanguage === 'ja' ? '台' : currentLanguage === 'id' ? '' : '';
952
  const spaceUnit = currentLanguage === 'zh' ? '個' : currentLanguage === 'en' ? '' : currentLanguage === 'ko' ? '개' : currentLanguage === 'ja' ? '個' : currentLanguage === 'id' ? '' : '';
953
 
954
- // 可借車輛狀態:0 = 紅色,1-3 = 橘色,>3 = 綠色
955
  let bikeStatusColor = '#e74c3c';
956
  let bikeStatusIcon = '🚫';
957
  if (availableBikes > 3) {
@@ -991,9 +851,6 @@ function renderYouBikeStations(stations) {
991
  return html;
992
  }
993
 
994
- /**
995
- * 渲染公車到站資訊
996
- */
997
  function renderBusArrivals(arrivals, routeName) {
998
  const labels = LABELS[currentLanguage] || LABELS.zh;
999
 
@@ -1003,7 +860,6 @@ function renderBusArrivals(arrivals, routeName) {
1003
 
1004
  let html = '';
1005
 
1006
- // 按站點分組
1007
  const stopGroups = {};
1008
  arrivals.forEach(arr => {
1009
  const stopName = arr.stop_name || labels.unknown;
@@ -1013,7 +869,6 @@ function renderBusArrivals(arrivals, routeName) {
1013
  stopGroups[stopName].push(arr);
1014
  });
1015
 
1016
- // 渲染每個站點
1017
  Object.entries(stopGroups).slice(0, 3).forEach(([stopName, stopArrivals], index) => {
1018
  const firstArr = stopArrivals[0];
1019
  const distance = firstArr.distance_m ? `${Math.round(firstArr.distance_m)}m` : '';
@@ -1043,9 +898,6 @@ function renderBusArrivals(arrivals, routeName) {
1043
  return html;
1044
  }
1045
 
1046
- /**
1047
- * 渲染地理反查資訊(reverse_geocode)
1048
- */
1049
  function renderReverseGeocode(data) {
1050
  const labels = LABELS[currentLanguage] || LABELS.zh;
1051
  const displayName = data.display_name || labels.unknown;
@@ -1058,7 +910,6 @@ function renderReverseGeocode(data) {
1058
  const lat = data.lat?.toFixed(6) || '';
1059
  const lon = data.lon?.toFixed(6) || '';
1060
 
1061
- // 組合詳細地址
1062
  let detailedAddress = [];
1063
  if (city) detailedAddress.push(city);
1064
  if (admin && admin !== city) detailedAddress.push(admin);
@@ -1068,7 +919,6 @@ function renderReverseGeocode(data) {
1068
 
1069
  const addressText = detailedAddress.length > 0 ? detailedAddress.join(', ') : displayName;
1070
 
1071
- // 生成 Google Maps 連結
1072
  const mapsUrl = `https://www.google.com/maps?q=${lat},${lon}`;
1073
 
1074
  return `
@@ -1106,9 +956,6 @@ function renderReverseGeocode(data) {
1106
  `;
1107
  }
1108
 
1109
- /**
1110
- * 渲染附近公車站點
1111
- */
1112
  function renderNearbyStops(stops) {
1113
  const labels = LABELS[currentLanguage] || LABELS.zh;
1114
 
@@ -1135,9 +982,6 @@ function renderNearbyStops(stops) {
1135
  return html;
1136
  }
1137
 
1138
- /**
1139
- * 渲染導航路線(directions)
1140
- */
1141
  function renderDirections(data) {
1142
  const labels = LABELS[currentLanguage] || LABELS.zh;
1143
  const originLabel = data.origin_label || labels.origin;
@@ -1145,7 +989,6 @@ function renderDirections(data) {
1145
  const distanceM = data.distance_m;
1146
  const durationS = data.duration_s;
1147
 
1148
- // 格式化距離
1149
  let distanceStr = '--';
1150
  if (distanceM !== undefined) {
1151
  if (distanceM >= 1000) {
@@ -1157,7 +1000,6 @@ function renderDirections(data) {
1157
  }
1158
  }
1159
 
1160
- // 格式化時間
1161
  let durationStr = '--';
1162
  if (durationS !== undefined) {
1163
  const minutes = Math.round(durationS / 60);
@@ -1173,7 +1015,6 @@ function renderDirections(data) {
1173
  }
1174
  }
1175
 
1176
- // 生成 Google Maps 連結(如果有座標)
1177
  let mapsLink = '';
1178
  if (data.origin_lat && data.origin_lon && data.dest_lat && data.dest_lon) {
1179
  const mapsUrl = `https://www.google.com/maps/dir/${data.origin_lat},${data.origin_lon}/${data.dest_lat},${data.dest_lon}`;
@@ -1207,9 +1048,6 @@ function renderDirections(data) {
1207
  `;
1208
  }
1209
 
1210
- /**
1211
- * 渲染捷運到站資訊(tdx_metro arrivals)
1212
- */
1213
  function renderMetroArrivals(arrivals) {
1214
  const labels = LABELS[currentLanguage] || LABELS.zh;
1215
 
@@ -1219,7 +1057,6 @@ function renderMetroArrivals(arrivals) {
1219
 
1220
  let html = '<div class="metro-arrivals">';
1221
 
1222
- // 按路線分組
1223
  const lineGroups = {};
1224
  arrivals.forEach(arr => {
1225
  const lineName = arr.line_name || labels.unknown;
@@ -1229,7 +1066,6 @@ function renderMetroArrivals(arrivals) {
1229
  lineGroups[lineName].push(arr);
1230
  });
1231
 
1232
- // 渲染每條路線
1233
  Object.entries(lineGroups).forEach(([lineName, lineArrivals], index) => {
1234
  html += `
1235
  <div class="metro-line" style="border-bottom: 1px solid #eee; padding: 12px 0; ${index === Object.keys(lineGroups).length - 1 ? 'border-bottom: none;' : ''}">
@@ -1267,9 +1103,6 @@ function renderMetroArrivals(arrivals) {
1267
  return html;
1268
  }
1269
 
1270
- /**
1271
- * 渲染捷運站點資訊(tdx_metro stations)
1272
- */
1273
  function renderMetroStations(stations) {
1274
  const labels = LABELS[currentLanguage] || LABELS.zh;
1275
 
@@ -1317,9 +1150,6 @@ function renderMetroStations(stations) {
1317
  return html;
1318
  }
1319
 
1320
- /**
1321
- * 渲染正向地理編碼(forward_geocode)
1322
- */
1323
  function renderForwardGeocode(data) {
1324
  const labels = LABELS[currentLanguage] || LABELS.zh;
1325
  const displayName = data.display_name || labels.unknown;
@@ -1329,7 +1159,6 @@ function renderForwardGeocode(data) {
1329
  const road = data.road || '';
1330
  const suburb = data.suburb || '';
1331
 
1332
- // 生成 Google Maps 連結
1333
  const mapsUrl = `https://www.google.com/maps?q=${lat},${lon}`;
1334
 
1335
  return `
@@ -1367,9 +1196,6 @@ function renderForwardGeocode(data) {
1367
  `;
1368
  }
1369
 
1370
- /**
1371
- * Fallback:顯示 JSON
1372
- */
1373
  function renderJSONFallback(data) {
1374
  return `<pre style="font-size: 0.85em; white-space: pre-wrap;">${JSON.stringify(data, null, 2)}</pre>`;
1375
  }
 
 
1
 
2
  const positions = ['pos-top-right', 'pos-top-left', 'pos-bottom-right', 'pos-bottom-left'];
3
  let usedPositions = [];
4
  const MAX_CARDS = 4;
5
 
 
6
  const LABELS = {
7
  zh: {
8
  temperature: '溫度', condition: '狀況', humidity: '濕度', wind_speed: '風速',
 
108
  }
109
  };
110
 
 
111
  let currentLanguage = 'zh';
112
 
 
113
  let toolDrawer = null;
114
  let toolDrawerToggle = null;
115
  let toolDrawerContent = null;
 
117
  let toolDrawerClose = null;
118
  let isDrawerOpen = false;
119
 
 
 
 
120
  function initToolDrawer() {
121
  toolDrawer = document.getElementById('toolDrawer');
122
  toolDrawerToggle = document.getElementById('toolDrawerToggle');
 
129
  return;
130
  }
131
 
 
132
  toolDrawerToggle.addEventListener('click', toggleToolDrawer);
133
 
 
134
  if (toolDrawerClose) {
135
  toolDrawerClose.addEventListener('click', hideToolDrawer);
136
  }
137
 
 
138
  if (toolDrawerOverlay) {
139
  toolDrawerOverlay.addEventListener('click', hideToolDrawer);
140
  }
141
 
 
142
  }
143
 
 
 
 
144
  function showToolDrawerToggle() {
145
  if (toolDrawerToggle) {
146
  toolDrawerToggle.classList.add('visible');
 
147
  }
148
  }
149
 
 
 
 
150
  function hideToolDrawerToggle() {
151
  if (toolDrawerToggle) {
152
  toolDrawerToggle.classList.remove('visible');
 
154
  }
155
  }
156
 
 
 
 
157
  function toggleToolDrawer() {
158
  if (isDrawerOpen) {
159
  hideToolDrawer();
 
162
  }
163
  }
164
 
 
 
 
165
  function showToolDrawer() {
166
  if (toolDrawer) {
167
  toolDrawer.classList.add('open');
168
  toolDrawerToggle?.classList.add('open');
169
  toolDrawerOverlay?.classList.add('visible');
170
  isDrawerOpen = true;
 
171
  }
172
  }
173
 
 
 
 
174
  function hideToolDrawer() {
175
  if (toolDrawer) {
176
  toolDrawer.classList.remove('open');
177
  toolDrawerToggle?.classList.remove('open');
178
  toolDrawerOverlay?.classList.remove('visible');
179
  isDrawerOpen = false;
 
180
  }
181
  }
182
 
 
 
 
183
  function hideToolCards() {
 
184
  hideToolDrawer();
 
185
  hideToolDrawerToggle();
 
186
  if (toolDrawerContent) {
187
  toolDrawerContent.innerHTML = '';
188
  }
 
189
  clearAllCards();
 
190
  }
191
 
192
  function getNextPosition() {
 
193
  if (usedPositions.length >= MAX_CARDS) {
194
  console.warn('⚠️ 卡片數量已達上限(4張),請先清除現有卡片');
195
  return null;
 
207
  function addToolCard(type) {
208
  const position = getNextPosition();
209
 
 
210
  if (!position) {
211
  return;
212
  }
 
287
  usedPositions = [];
288
  }
289
 
 
290
  function initToolCardControls() {
291
  document.getElementById('simulate-weather').addEventListener('click', () => {
292
  clearAllCards();
 
310
  });
311
  }
312
 
 
313
 
 
 
 
314
  async function syncToolMetadata() {
315
  try {
316
  const response = await fetch('/api/mcp/tools', {
 
322
  if (response.ok) {
323
  const data = await response.json();
324
  if (data.success && data.tools) {
 
325
  toolsMetadata = {};
326
  data.tools.forEach(tool => {
327
  toolsMetadata[tool.name] = tool;
328
  });
 
329
  }
330
  }
331
  } catch (error) {
 
333
  }
334
  }
335
 
 
 
 
336
  function getIconForTool(toolName, category) {
337
  const iconMap = {
 
338
  '健康': '❤️',
339
  '天氣': '🌤️',
340
  '新聞': '📰',
 
350
  '軌道運輸': '🚇',
351
  '地理定位': '📍',
352
 
 
353
  'healthkit_query': '❤️',
354
  'weather_query': '🌤️',
355
  'news_query': '📰',
 
364
  'directions': '🗺️'
365
  };
366
 
 
367
  if (iconMap[toolName]) {
368
  return iconMap[toolName];
369
  }
370
 
 
371
  if (category && iconMap[category]) {
372
  return iconMap[category];
373
  }
374
 
 
375
  return '🔧';
376
  }
377
 
 
 
 
 
378
  function displayToolCard(toolName, toolData) {
 
379
  clearAllCards();
380
 
 
381
  const toolMeta = toolsMetadata[toolName] || {};
382
  const category = toolMeta.category || '未知';
383
  const icon = getIconForTool(toolName, category);
384
 
 
385
  const contentHTML = renderCardContent(toolName, toolData);
386
 
 
387
  const card = document.createElement('div');
388
  card.className = 'voice-tool-card';
389
  card.dataset.type = toolName;
 
396
  <div class="card-content" style="max-height: 300px; overflow-y: auto; overflow-x: hidden; padding-right: 8px;">${contentHTML}</div>
397
  `;
398
 
 
399
  if (toolDrawerContent) {
400
  toolDrawerContent.innerHTML = '';
401
  toolDrawerContent.appendChild(card.cloneNode(true));
 
402
  showToolDrawerToggle();
 
403
  }
404
 
 
405
  const position = getNextPosition();
406
  if (position && cardsContainer) {
407
  card.classList.add(position);
408
  cardsContainer.appendChild(card);
 
409
  }
410
  }
411
 
 
 
 
412
  function renderCardContent(toolName, toolData) {
 
413
 
414
  if (!toolData) {
415
  console.warn('⚠️ toolData 為空');
416
  return '<p class="data-row">無數據</p>';
417
  }
418
 
 
419
  const healthData = toolData.health_data || toolData.raw_data?.health_data;
420
  if (healthData && Array.isArray(healthData)) {
 
421
  return renderHealthMetrics(healthData);
422
  }
423
 
 
424
  const articlesData = toolData.articles || toolData.raw_data?.articles;
425
  if (articlesData && Array.isArray(articlesData)) {
 
426
  return renderNewsList(articlesData);
427
  }
428
 
 
429
  const weatherData = toolData.raw_data || toolData;
430
  if (weatherData.main && weatherData.weather) {
 
431
  return renderWeatherData(weatherData);
432
  }
433
 
 
434
  if (toolData.arrivals && Array.isArray(toolData.arrivals)) {
 
435
  return renderBusArrivals(toolData.arrivals, toolData.route_name);
436
  }
437
 
 
438
  if (toolData.stops && Array.isArray(toolData.stops)) {
 
439
  return renderNearbyStops(toolData.stops);
440
  }
441
 
 
442
  const exchangeData = toolData.raw_data || toolData;
443
  if (exchangeData.rate !== undefined && exchangeData.from_currency !== undefined) {
 
444
  return renderExchangeRate(exchangeData);
445
  }
446
 
 
447
  if (toolData.trains && Array.isArray(toolData.trains)) {
 
448
  return renderTrainList(toolData.trains);
449
  }
450
 
 
451
  if (toolData.stations && Array.isArray(toolData.stations) &&
452
  (toolName === 'tdx_youbike' || toolData.stations[0]?.available_bikes !== undefined)) {
 
453
  return renderYouBikeStations(toolData.stations);
454
  }
455
 
 
456
  if (toolData.stations && Array.isArray(toolData.stations) && toolName === 'tdx_train') {
 
457
  return renderTrainStations(toolData.stations);
458
  }
459
 
 
460
  if (toolData.display_name && toolData.lat && toolData.lon && toolName === 'reverse_geocode') {
 
461
  return renderReverseGeocode(toolData);
462
  }
463
 
 
464
  if ((toolData.distance_m !== undefined || toolData.duration_s !== undefined) &&
465
  (toolName === 'directions' || toolData.polyline !== undefined)) {
 
466
  return renderDirections(toolData);
467
  }
468
 
 
469
  if (toolData.arrivals && Array.isArray(toolData.arrivals) && toolName === 'tdx_metro') {
 
470
  return renderMetroArrivals(toolData.arrivals);
471
  }
472
 
 
473
  if (toolData.stations && Array.isArray(toolData.stations) && toolName === 'tdx_metro') {
 
474
  return renderMetroStations(toolData.stations);
475
  }
476
 
 
477
  if (toolData.lat && toolData.lon && toolData.display_name && toolName === 'forward_geocode') {
 
478
  return renderForwardGeocode(toolData);
479
  }
480
 
 
481
  if (toolData.raw_data && typeof toolData.raw_data === 'object') {
 
482
  return renderKeyValuePairs(toolData.raw_data);
483
  }
484
 
 
485
  console.warn('⚠️ 未匹配任何模式,使用 JSON fallback');
 
486
  return renderJSONFallback(toolData);
487
  }
488
 
 
 
 
489
  function renderWeatherData(data) {
490
  const main = data.main || {};
491
  const weather = data.weather?.[0] || {};
 
493
  const sys = data.sys || {};
494
  const labels = LABELS[currentLanguage] || LABELS.zh;
495
 
 
496
  const formatTime = (timestamp) => {
497
  if (!timestamp) return '--:--';
498
  const date = new Date(timestamp * 1000);
 
535
  `;
536
  }
537
 
 
 
 
538
  function renderHealthMetrics(healthData) {
539
  const labels = LABELS[currentLanguage] || LABELS.zh;
540
 
 
550
  sleep_analysis: '😴'
551
  };
552
 
 
553
  const grouped = {};
554
  healthData.forEach(item => {
555
  const metric = item.metric || item.type;
 
561
 
562
  let html = '<div class="health-metrics">';
563
 
 
564
  Object.entries(grouped).forEach(([metric, items], index) => {
565
  const icon = metricIcons[metric] || '📊';
566
  const label = labels[metric] || metric;
 
568
  const value = latestItem.value;
569
  const unit = latestItem.unit || '';
570
 
 
571
  let timeStr = '';
572
  if (latestItem.timestamp) {
573
  try {
 
609
  return html;
610
  }
611
 
 
 
 
612
  function renderNewsList(articles) {
613
  const labels = LABELS[currentLanguage] || LABELS.zh;
614
  let html = '';
 
624
  return html || `<p>${labels.no_news}</p>`;
625
  }
626
 
 
 
 
627
  function renderKeyValuePairs(data) {
 
628
  const labels = LABELS[currentLanguage] || LABELS.zh;
629
  const keyMap = {
630
  city: labels.city,
 
644
  const label = keyMap[key] || key;
645
  let displayValue = value;
646
 
 
647
  if (key.includes('temp') && typeof value === 'number') {
648
  displayValue = `${value}°C`;
649
  }
 
659
  return html || '<p>無數據</p>';
660
  }
661
 
 
 
 
 
 
 
662
  function renderExchangeRate(data) {
663
  const labels = LABELS[currentLanguage] || LABELS.zh;
664
  const currencySymbols = {
 
673
 
674
  let html = '';
675
 
 
676
  if (data.rate !== undefined) {
677
  html += `
678
  <div class="data-row">
 
682
  `;
683
  }
684
 
 
685
  if (data.amount && data.converted_amount !== undefined) {
686
  html += `
687
  <div class="data-row">
 
691
  `;
692
  }
693
 
 
694
  if (data.raw_data?.metadata?.timestamp) {
695
  const time = new Date(data.raw_data.metadata.timestamp).toLocaleString('zh-TW');
696
  html += `
 
704
  return html || `<p>${labels.no_data}</p>`;
705
  }
706
 
 
 
 
707
  function renderTrainList(trains) {
708
  const labels = LABELS[currentLanguage] || LABELS.zh;
709
 
 
751
  return html;
752
  }
753
 
 
 
 
754
  function renderTrainStations(stations) {
755
  const labels = LABELS[currentLanguage] || LABELS.zh;
756
 
 
791
  return html;
792
  }
793
 
 
 
 
794
  function renderYouBikeStations(stations) {
795
  const labels = LABELS[currentLanguage] || LABELS.zh;
796
 
 
812
  const bikeUnit = currentLanguage === 'zh' ? '輛' : currentLanguage === 'en' ? '' : currentLanguage === 'ko' ? '대' : currentLanguage === 'ja' ? '台' : currentLanguage === 'id' ? '' : '';
813
  const spaceUnit = currentLanguage === 'zh' ? '個' : currentLanguage === 'en' ? '' : currentLanguage === 'ko' ? '개' : currentLanguage === 'ja' ? '個' : currentLanguage === 'id' ? '' : '';
814
 
 
815
  let bikeStatusColor = '#e74c3c';
816
  let bikeStatusIcon = '🚫';
817
  if (availableBikes > 3) {
 
851
  return html;
852
  }
853
 
 
 
 
854
  function renderBusArrivals(arrivals, routeName) {
855
  const labels = LABELS[currentLanguage] || LABELS.zh;
856
 
 
860
 
861
  let html = '';
862
 
 
863
  const stopGroups = {};
864
  arrivals.forEach(arr => {
865
  const stopName = arr.stop_name || labels.unknown;
 
869
  stopGroups[stopName].push(arr);
870
  });
871
 
 
872
  Object.entries(stopGroups).slice(0, 3).forEach(([stopName, stopArrivals], index) => {
873
  const firstArr = stopArrivals[0];
874
  const distance = firstArr.distance_m ? `${Math.round(firstArr.distance_m)}m` : '';
 
898
  return html;
899
  }
900
 
 
 
 
901
  function renderReverseGeocode(data) {
902
  const labels = LABELS[currentLanguage] || LABELS.zh;
903
  const displayName = data.display_name || labels.unknown;
 
910
  const lat = data.lat?.toFixed(6) || '';
911
  const lon = data.lon?.toFixed(6) || '';
912
 
 
913
  let detailedAddress = [];
914
  if (city) detailedAddress.push(city);
915
  if (admin && admin !== city) detailedAddress.push(admin);
 
919
 
920
  const addressText = detailedAddress.length > 0 ? detailedAddress.join(', ') : displayName;
921
 
 
922
  const mapsUrl = `https://www.google.com/maps?q=${lat},${lon}`;
923
 
924
  return `
 
956
  `;
957
  }
958
 
 
 
 
959
  function renderNearbyStops(stops) {
960
  const labels = LABELS[currentLanguage] || LABELS.zh;
961
 
 
982
  return html;
983
  }
984
 
 
 
 
985
  function renderDirections(data) {
986
  const labels = LABELS[currentLanguage] || LABELS.zh;
987
  const originLabel = data.origin_label || labels.origin;
 
989
  const distanceM = data.distance_m;
990
  const durationS = data.duration_s;
991
 
 
992
  let distanceStr = '--';
993
  if (distanceM !== undefined) {
994
  if (distanceM >= 1000) {
 
1000
  }
1001
  }
1002
 
 
1003
  let durationStr = '--';
1004
  if (durationS !== undefined) {
1005
  const minutes = Math.round(durationS / 60);
 
1015
  }
1016
  }
1017
 
 
1018
  let mapsLink = '';
1019
  if (data.origin_lat && data.origin_lon && data.dest_lat && data.dest_lon) {
1020
  const mapsUrl = `https://www.google.com/maps/dir/${data.origin_lat},${data.origin_lon}/${data.dest_lat},${data.dest_lon}`;
 
1048
  `;
1049
  }
1050
 
 
 
 
1051
  function renderMetroArrivals(arrivals) {
1052
  const labels = LABELS[currentLanguage] || LABELS.zh;
1053
 
 
1057
 
1058
  let html = '<div class="metro-arrivals">';
1059
 
 
1060
  const lineGroups = {};
1061
  arrivals.forEach(arr => {
1062
  const lineName = arr.line_name || labels.unknown;
 
1066
  lineGroups[lineName].push(arr);
1067
  });
1068
 
 
1069
  Object.entries(lineGroups).forEach(([lineName, lineArrivals], index) => {
1070
  html += `
1071
  <div class="metro-line" style="border-bottom: 1px solid #eee; padding: 12px 0; ${index === Object.keys(lineGroups).length - 1 ? 'border-bottom: none;' : ''}">
 
1103
  return html;
1104
  }
1105
 
 
 
 
1106
  function renderMetroStations(stations) {
1107
  const labels = LABELS[currentLanguage] || LABELS.zh;
1108
 
 
1150
  return html;
1151
  }
1152
 
 
 
 
1153
  function renderForwardGeocode(data) {
1154
  const labels = LABELS[currentLanguage] || LABELS.zh;
1155
  const displayName = data.display_name || labels.unknown;
 
1159
  const road = data.road || '';
1160
  const suburb = data.suburb || '';
1161
 
 
1162
  const mapsUrl = `https://www.google.com/maps?q=${lat},${lon}`;
1163
 
1164
  return `
 
1196
  `;
1197
  }
1198
 
 
 
 
1199
  function renderJSONFallback(data) {
1200
  return `<pre style="font-size: 0.85em; white-space: pre-wrap;">${JSON.stringify(data, null, 2)}</pre>`;
1201
  }
static/frontend/js/tts.js CHANGED
@@ -1,28 +1,18 @@
1
- // ========== TTS 核心變數 ==========
2
  let currentAudio = null; // 當前播放的音頻對象
3
  let isPlaying = false; // 是否正在播放
4
  let audioContext = null; // 預先建立的 AudioContext(繞過自動播放限制)
5
  let userGestureReceived = false; // 是否已收到用戶手勢
6
 
7
- console.log('✅ TTS 模組已載入');
8
 
9
- // ========== 用戶手勢處理(解鎖自動播放)==========
10
 
11
- /**
12
- * 在用戶手勢時初始化 AudioContext
13
- * 應該在錄音開始時調用此函數
14
- */
15
  function unlockAudioPlayback() {
16
  if (userGestureReceived) {
17
- console.log('⚠️ 音頻播放已解鎖,跳過');
18
  return;
19
  }
20
 
21
  try {
22
- // 建立 AudioContext(需要用戶手勢才能啟動)
23
  audioContext = new (window.AudioContext || window.webkitAudioContext)();
24
 
25
- // 播放一個靜音音頻來"解鎖"自動播放權限
26
  const buffer = audioContext.createBuffer(1, 1, 22050);
27
  const source = audioContext.createBufferSource();
28
  source.buffer = buffer;
@@ -30,29 +20,17 @@ function unlockAudioPlayback() {
30
  source.start(0);
31
 
32
  userGestureReceived = true;
33
- console.log('✅ 音頻播放已解鎖(用戶手勢已授權)');
34
  } catch (error) {
35
  console.warn('⚠️ 無法解鎖音頻播放:', error);
36
  }
37
  }
38
 
39
- // ========== 語音播放函數(異步方式)==========
40
 
41
- /**
42
- * 播放文字語音(異步方式)
43
- * 獲取完整音頻後立即播放,前端可同時顯示打字效果
44
- *
45
- * @param {string} text - 要播放的文字
46
- * @returns {Promise<void>}
47
- */
48
  async function speakText(text) {
49
- // 停止之前的語音
50
  stopSpeaking();
51
 
52
  try {
53
- console.log('🔊 呼叫 TTS API...');
54
 
55
- // 呼叫後端 TTS API
56
  const response = await fetch('/api/tts', {
57
  method: 'POST',
58
  headers: {
@@ -72,16 +50,13 @@ async function speakText(text) {
72
  return;
73
  }
74
 
75
- // 取得音頻數據(MP3)
76
  const audioBlob = await response.blob();
77
  const audioUrl = URL.createObjectURL(audioBlob);
78
 
79
- // 建立 Audio 元素並播放
80
  currentAudio = new Audio(audioUrl);
81
  isPlaying = true;
82
 
83
  currentAudio.onended = () => {
84
- console.log('✅ 語音播放完成');
85
  isPlaying = false;
86
  URL.revokeObjectURL(audioUrl);
87
  };
@@ -92,33 +67,26 @@ async function speakText(text) {
92
  URL.revokeObjectURL(audioUrl);
93
  };
94
 
95
- // 嘗試播放(如果之前有用戶手勢,應該可以成功)
96
  try {
97
  const playPromise = currentAudio.play();
98
 
99
  if (playPromise !== undefined) {
100
  await playPromise;
101
- console.log('▶️ 開始播放語音');
102
  }
103
  } catch (playError) {
104
- // 處理瀏覽器自動播放策略限制
105
  if (playError.name === 'NotAllowedError') {
106
  console.warn('⚠️ 自動播放被阻止(瀏覽器政策)');
107
  console.warn('💡 解決方案:等待用戶下次點擊任意處播放');
108
 
109
- // 保持 Audio 元素,等待用戶點擊
110
  isPlaying = false;
111
 
112
- // 添加全域點擊監聽器(一次性)
113
  const playOnUserClick = async (e) => {
114
- // 避免干擾其他點擊事件
115
  if (e.target.closest('.mic-button') || e.target.closest('button')) {
116
  return;
117
  }
118
 
119
  try {
120
  await currentAudio.play();
121
- console.log('✅ 用戶點擊後自動播放成功');
122
  isPlaying = true;
123
  document.removeEventListener('click', playOnUserClick);
124
  } catch (retryError) {
@@ -127,18 +95,15 @@ async function speakText(text) {
127
  }
128
  };
129
 
130
- // 監聽下次點擊(保留 5 秒)
131
  document.addEventListener('click', playOnUserClick, { once: false });
132
  setTimeout(() => {
133
  document.removeEventListener('click', playOnUserClick);
134
  if (!isPlaying) {
135
  URL.revokeObjectURL(audioUrl);
136
- console.log('⏱️ 超時未播放,釋放音頻資源');
137
  }
138
  }, 5000);
139
 
140
  } else {
141
- // 其他播放錯誤
142
  console.error('❌ 音頻播放失敗:', playError);
143
  isPlaying = false;
144
  URL.revokeObjectURL(audioUrl);
@@ -152,14 +117,10 @@ async function speakText(text) {
152
  }
153
  }
154
 
155
- /**
156
- * 停止當前語音播放
157
- */
158
  function stopSpeaking() {
159
  if (currentAudio && isPlaying) {
160
  currentAudio.pause();
161
  currentAudio.currentTime = 0;
162
  isPlaying = false;
163
- console.log('⏹️ 停止語音播放');
164
  }
165
  }
 
 
1
  let currentAudio = null; // 當前播放的音頻對象
2
  let isPlaying = false; // 是否正在播放
3
  let audioContext = null; // 預先建立的 AudioContext(繞過自動播放限制)
4
  let userGestureReceived = false; // 是否已收到用戶手勢
5
 
 
6
 
 
7
 
 
 
 
 
8
  function unlockAudioPlayback() {
9
  if (userGestureReceived) {
 
10
  return;
11
  }
12
 
13
  try {
 
14
  audioContext = new (window.AudioContext || window.webkitAudioContext)();
15
 
 
16
  const buffer = audioContext.createBuffer(1, 1, 22050);
17
  const source = audioContext.createBufferSource();
18
  source.buffer = buffer;
 
20
  source.start(0);
21
 
22
  userGestureReceived = true;
 
23
  } catch (error) {
24
  console.warn('⚠️ 無法解鎖音頻播放:', error);
25
  }
26
  }
27
 
 
28
 
 
 
 
 
 
 
 
29
  async function speakText(text) {
 
30
  stopSpeaking();
31
 
32
  try {
 
33
 
 
34
  const response = await fetch('/api/tts', {
35
  method: 'POST',
36
  headers: {
 
50
  return;
51
  }
52
 
 
53
  const audioBlob = await response.blob();
54
  const audioUrl = URL.createObjectURL(audioBlob);
55
 
 
56
  currentAudio = new Audio(audioUrl);
57
  isPlaying = true;
58
 
59
  currentAudio.onended = () => {
 
60
  isPlaying = false;
61
  URL.revokeObjectURL(audioUrl);
62
  };
 
67
  URL.revokeObjectURL(audioUrl);
68
  };
69
 
 
70
  try {
71
  const playPromise = currentAudio.play();
72
 
73
  if (playPromise !== undefined) {
74
  await playPromise;
 
75
  }
76
  } catch (playError) {
 
77
  if (playError.name === 'NotAllowedError') {
78
  console.warn('⚠️ 自動播放被阻止(瀏覽器政策)');
79
  console.warn('💡 解決方案:等待用戶下次點擊任意處播放');
80
 
 
81
  isPlaying = false;
82
 
 
83
  const playOnUserClick = async (e) => {
 
84
  if (e.target.closest('.mic-button') || e.target.closest('button')) {
85
  return;
86
  }
87
 
88
  try {
89
  await currentAudio.play();
 
90
  isPlaying = true;
91
  document.removeEventListener('click', playOnUserClick);
92
  } catch (retryError) {
 
95
  }
96
  };
97
 
 
98
  document.addEventListener('click', playOnUserClick, { once: false });
99
  setTimeout(() => {
100
  document.removeEventListener('click', playOnUserClick);
101
  if (!isPlaying) {
102
  URL.revokeObjectURL(audioUrl);
 
103
  }
104
  }, 5000);
105
 
106
  } else {
 
107
  console.error('❌ 音頻播放失敗:', playError);
108
  isPlaying = false;
109
  URL.revokeObjectURL(audioUrl);
 
117
  }
118
  }
119
 
 
 
 
120
  function stopSpeaking() {
121
  if (currentAudio && isPlaying) {
122
  currentAudio.pause();
123
  currentAudio.currentTime = 0;
124
  isPlaying = false;
 
125
  }
126
  }
static/frontend/js/ui.js CHANGED
@@ -1,13 +1,4 @@
1
- // ========== 打字機效果函數 ==========
2
-
3
- /**
4
- * 異步打字機效果(文字與語音並行)
5
- * 立即開始語音播放,同時顯示打字效果
6
- *
7
- * @param {string} text - 要顯示的完整文字
8
- * @param {number} speed - 打字速度(毫秒/字元)
9
- * @param {boolean} enableTTS - 是否啟用語音播放
10
- */
11
  function typewriterEffect(text, speed = 50, enableTTS = true) {
12
  agentOutput.textContent = '';
13
  agentOutput.classList.add('active');
@@ -15,17 +6,14 @@ function typewriterEffect(text, speed = 50, enableTTS = true) {
15
 
16
  let index = 0;
17
 
18
- // 清除之前的打字動畫
19
  if (typingInterval) {
20
  clearInterval(typingInterval);
21
  }
22
 
23
- // 立即開始語音播放(異步並行,不等待打字完成)
24
  if (enableTTS && typeof speakText === 'function') {
25
  speakText(text); // 語音與打字效果並行
26
  }
27
 
28
- // 打字效果
29
  typingInterval = setInterval(() => {
30
  if (index < text.length) {
31
  agentOutput.textContent += text[index];
@@ -46,7 +34,6 @@ function hideAgentOutput() {
46
  agentOutput.textContent = '';
47
  }
48
 
49
- // ========== 情緒切換 ==========
50
 
51
  function initEmotionSelector() {
52
  document.getElementById('emotion-select').addEventListener('change', (e) => {
@@ -56,7 +43,6 @@ function initEmotionSelector() {
56
  });
57
  }
58
 
59
- // ========== 字幕切換 ==========
60
 
61
  function initTranscriptControls() {
62
  document.getElementById('transcript-provisional').addEventListener('click', () => {
@@ -70,7 +56,6 @@ function initTranscriptControls() {
70
  });
71
  }
72
 
73
- // ========== 登入按鈕 ==========
74
 
75
  function initLoginButton() {
76
  const googleLoginBtn = document.getElementById('googleLoginBtn');
@@ -79,39 +64,30 @@ function initLoginButton() {
79
  }
80
  }
81
 
82
- // ========== 登出按鈕 ==========
83
 
84
  function initLogoutButton() {
85
  const logoutBtn = document.getElementById('logoutBtn');
86
  if (logoutBtn) {
87
  logoutBtn.addEventListener('click', handleLogout);
88
- console.log('✅ 登出按鈕已初始化');
89
  }
90
  }
91
 
92
  function handleLogout() {
93
- console.log('🚪 執行登出...');
94
 
95
- // 清除 JWT token
96
  localStorage.removeItem('jwt_token');
97
 
98
- // 停止 WebSocket 連接
99
  if (typeof ws !== 'undefined' && ws) {
100
  ws.close();
101
  }
102
 
103
- // 停止語音播放
104
  if (typeof stopSpeaking === 'function') {
105
  stopSpeaking();
106
  }
107
 
108
- console.log('✅ 登出成功,導向登入頁面');
109
 
110
- // 導向登入頁面
111
  window.location.href = '/login/';
112
  }
113
 
114
- // ========== 輸入模式切換(語音 ↔ 文字)==========
115
 
116
  let isTextInputMode = false; // 當前是否為文字輸入模式
117
  let textInputElement = null; // 文字輸入框元素
@@ -120,7 +96,6 @@ function initChatIcon() {
120
  const chatIcon = document.getElementById('chatIcon');
121
  if (chatIcon) {
122
  chatIcon.addEventListener('click', toggleInputMode);
123
- console.log('✅ 輸入模式切換按鈕已初始化');
124
  }
125
  }
126
 
@@ -134,47 +109,35 @@ function toggleInputMode() {
134
  }
135
 
136
  if (isTextInputMode) {
137
- // 切換到文字輸入模式
138
- console.log('⌨️ 切換到文字輸入模式');
139
 
140
- // 保存原始內容
141
  const originalContent = transcript.textContent;
142
 
143
- // 清空並添加 text-input-mode class
144
  transcript.className = 'voice-transcript text-input-mode';
145
  transcript.innerHTML = '';
146
 
147
- // 創建 textarea
148
  textInputElement = document.createElement('textarea');
149
  textInputElement.placeholder = '請輸入訊息...';
150
  textInputElement.id = 'text-input-box';
151
 
152
- // 監聽 Enter 鍵送出(Shift+Enter 換行)
153
  textInputElement.addEventListener('keydown', handleTextInput);
154
 
155
  transcript.appendChild(textInputElement);
156
 
157
- // 自動聚焦
158
  setTimeout(() => textInputElement.focus(), 100);
159
 
160
  } else {
161
- // 切換回語音模式
162
- console.log('🎤 切換到語音模式');
163
 
164
- // 移除 textarea
165
  if (textInputElement) {
166
  textInputElement.removeEventListener('keydown', handleTextInput);
167
  textInputElement = null;
168
  }
169
 
170
- // 恢復原始樣式
171
  transcript.className = 'voice-transcript provisional';
172
  transcript.textContent = '請說話...';
173
  }
174
  }
175
 
176
  function handleTextInput(event) {
177
- // Enter 送出(Shift+Enter 換行)
178
  if (event.key === 'Enter' && !event.shiftKey) {
179
  event.preventDefault();
180
 
@@ -184,23 +147,17 @@ function handleTextInput(event) {
184
  return;
185
  }
186
 
187
- console.log('📤 送出文字訊息:', text);
188
 
189
- // 送出到 WebSocket
190
  if (typeof wsManager !== 'undefined' && wsManager) {
191
- // 取得當前對話 ID(如果沒有,後端會自動建立新對話)
192
  const chatId = window.currentChatId || null;
193
  wsManager.sendUserMessage(text, chatId);
194
 
195
- // 清空輸入框
196
  textInputElement.value = '';
197
 
198
- // 切換到思考狀態
199
  if (typeof setState === 'function') {
200
  setState('thinking');
201
  }
202
 
203
- // 切換回語音模式
204
  toggleInputMode();
205
  } else {
206
  console.error('❌ WebSocket 未初始化');
 
1
+
 
 
 
 
 
 
 
 
 
2
  function typewriterEffect(text, speed = 50, enableTTS = true) {
3
  agentOutput.textContent = '';
4
  agentOutput.classList.add('active');
 
6
 
7
  let index = 0;
8
 
 
9
  if (typingInterval) {
10
  clearInterval(typingInterval);
11
  }
12
 
 
13
  if (enableTTS && typeof speakText === 'function') {
14
  speakText(text); // 語音與打字效果並行
15
  }
16
 
 
17
  typingInterval = setInterval(() => {
18
  if (index < text.length) {
19
  agentOutput.textContent += text[index];
 
34
  agentOutput.textContent = '';
35
  }
36
 
 
37
 
38
  function initEmotionSelector() {
39
  document.getElementById('emotion-select').addEventListener('change', (e) => {
 
43
  });
44
  }
45
 
 
46
 
47
  function initTranscriptControls() {
48
  document.getElementById('transcript-provisional').addEventListener('click', () => {
 
56
  });
57
  }
58
 
 
59
 
60
  function initLoginButton() {
61
  const googleLoginBtn = document.getElementById('googleLoginBtn');
 
64
  }
65
  }
66
 
 
67
 
68
  function initLogoutButton() {
69
  const logoutBtn = document.getElementById('logoutBtn');
70
  if (logoutBtn) {
71
  logoutBtn.addEventListener('click', handleLogout);
 
72
  }
73
  }
74
 
75
  function handleLogout() {
 
76
 
 
77
  localStorage.removeItem('jwt_token');
78
 
 
79
  if (typeof ws !== 'undefined' && ws) {
80
  ws.close();
81
  }
82
 
 
83
  if (typeof stopSpeaking === 'function') {
84
  stopSpeaking();
85
  }
86
 
 
87
 
 
88
  window.location.href = '/login/';
89
  }
90
 
 
91
 
92
  let isTextInputMode = false; // 當前是否為文字輸入模式
93
  let textInputElement = null; // 文字輸入框元素
 
96
  const chatIcon = document.getElementById('chatIcon');
97
  if (chatIcon) {
98
  chatIcon.addEventListener('click', toggleInputMode);
 
99
  }
100
  }
101
 
 
109
  }
110
 
111
  if (isTextInputMode) {
 
 
112
 
 
113
  const originalContent = transcript.textContent;
114
 
 
115
  transcript.className = 'voice-transcript text-input-mode';
116
  transcript.innerHTML = '';
117
 
 
118
  textInputElement = document.createElement('textarea');
119
  textInputElement.placeholder = '請輸入訊息...';
120
  textInputElement.id = 'text-input-box';
121
 
 
122
  textInputElement.addEventListener('keydown', handleTextInput);
123
 
124
  transcript.appendChild(textInputElement);
125
 
 
126
  setTimeout(() => textInputElement.focus(), 100);
127
 
128
  } else {
 
 
129
 
 
130
  if (textInputElement) {
131
  textInputElement.removeEventListener('keydown', handleTextInput);
132
  textInputElement = null;
133
  }
134
 
 
135
  transcript.className = 'voice-transcript provisional';
136
  transcript.textContent = '請說話...';
137
  }
138
  }
139
 
140
  function handleTextInput(event) {
 
141
  if (event.key === 'Enter' && !event.shiftKey) {
142
  event.preventDefault();
143
 
 
147
  return;
148
  }
149
 
 
150
 
 
151
  if (typeof wsManager !== 'undefined' && wsManager) {
 
152
  const chatId = window.currentChatId || null;
153
  wsManager.sendUserMessage(text, chatId);
154
 
 
155
  textInputElement.value = '';
156
 
 
157
  if (typeof setState === 'function') {
158
  setState('thinking');
159
  }
160
 
 
161
  toggleInputMode();
162
  } else {
163
  console.error('❌ WebSocket 未初始化');
static/frontend/js/websocket-old.js DELETED
@@ -1,560 +0,0 @@
1
- /**
2
- * Bloom Ware WebSocket 通訊管理模組
3
- * 處理 WebSocket 連接、訊息收發、重連機制、語音功能
4
- */
5
-
6
- import { safeStorage, safeExecute, getCurrentUserId } from './utils.js';
7
-
8
- // ========== WebSocket 連接管理 ==========
9
-
10
- export class WebSocketManager {
11
- constructor(authManager) {
12
- this.auth = authManager;
13
- this.ws = null;
14
- this.isConnecting = false;
15
- this.reconnectAttempts = 0;
16
- this.maxReconnectAttempts = 5;
17
- this.reconnectDelay = 2000;
18
-
19
- // 狀態管理回調
20
- this.onlineCallbacks = [];
21
- this.messageCallbacks = [];
22
-
23
- // 綁定方法上下文
24
- this.connect = this.connect.bind(this);
25
- this.handleOpen = this.handleOpen.bind(this);
26
- this.handleMessage = this.handleMessage.bind(this);
27
- this.handleClose = this.handleClose.bind(this);
28
- this.handleError = this.handleError.bind(this);
29
- }
30
-
31
- // 生成 WebSocket URL
32
- getWsUrl() {
33
- // 使用認證管理器獲取JWT token
34
- let jwtToken = this.auth.getJwtToken();
35
-
36
- console.log('🔍 WebSocket URL生成 - token檢查:', {
37
- hasToken: !!jwtToken,
38
- isAuthenticated: this.auth.isAuthenticated()
39
- });
40
-
41
- // 檢查 token 是否存在
42
- if (!jwtToken) {
43
- console.error('❌ WebSocket 連線失敗:沒有 JWT token');
44
- throw new Error('Missing JWT token');
45
- }
46
-
47
- // 檢查 token 是否過期(使用更寬鬆的檢查,避免剛登入就被判定過期)
48
- const isExpired = this.auth.isTokenExpired();
49
- if (isExpired) {
50
- console.error('❌ WebSocket 連線失敗:JWT token 已過期');
51
- throw new Error('JWT token expired');
52
- }
53
-
54
- // 根據當前頁面協議確定WebSocket協議
55
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
56
- const host = window.location.host;
57
-
58
- // 構建WebSocket URL
59
- const url = new URL(`${protocol}//${host}/ws`);
60
- url.searchParams.set('token', jwtToken);
61
-
62
- console.log('✅ WebSocket URL已生成');
63
- return url.toString();
64
- }
65
-
66
- // 建立 WebSocket 連接
67
- async connect() {
68
- if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
69
- return;
70
- }
71
-
72
- try {
73
- this.isConnecting = true;
74
- const wsUrl = this.getWsUrl();
75
-
76
- this.ws = new WebSocket(wsUrl);
77
- this.notifyOnlineState(false);
78
-
79
- this.ws.addEventListener('open', this.handleOpen);
80
- this.ws.addEventListener('message', this.handleMessage);
81
- this.ws.addEventListener('close', this.handleClose);
82
- this.ws.addEventListener('error', this.handleError);
83
-
84
- } catch (error) {
85
- console.error('WebSocket 連接失敗:', error);
86
- this.isConnecting = false;
87
- this.scheduleReconnect();
88
- }
89
- }
90
-
91
- // 處理 WebSocket 開啟事件
92
- async handleOpen() {
93
- console.log('WebSocket 連接已建立');
94
- this.isConnecting = false;
95
- this.reconnectAttempts = 0;
96
- this.notifyOnlineState(true);
97
-
98
- // 若已有目前對話,告知後端綁定 chat_id
99
- const cid = window.currentChatId;
100
- if (cid) {
101
- this.send({ type: 'chat_focus', chat_id: cid });
102
- }
103
-
104
- // 如果用戶已登入但沒有當前對話,檢查是否需要創建初始對話
105
- if (this.auth.user && !window.currentChatId) {
106
- setTimeout(async () => {
107
- await this.handleInitialChatSetup();
108
- }, 500);
109
- }
110
- }
111
-
112
- // 處理初始對話設置
113
- async handleInitialChatSetup() {
114
- try {
115
- const chats = await this.getUserChats(this.auth.user.id);
116
-
117
- if (!chats || chats.length === 0) {
118
- // 用戶沒有任何對話,創建初始對話
119
- await this.createInitialChat(this.auth.user);
120
- } else {
121
- // 用戶有對話,使用最新的對話
122
- const latestChat = chats[0];
123
- window.currentChatId = latestChat.chat_id;
124
- }
125
-
126
- // 刷新對話清單
127
- if (window.refreshChats) {
128
- window.refreshChats();
129
- }
130
- } catch (error) {
131
- console.error('WebSocket連接後檢查對話失敗:', error);
132
- }
133
- }
134
-
135
- // 處理 WebSocket 訊息
136
- handleMessage(event) {
137
- try {
138
- const data = JSON.parse(event.data);
139
-
140
- // 通知所有訊息回調
141
- this.messageCallbacks.forEach(callback => {
142
- safeExecute(() => callback(data), null, 'WebSocket 訊息回調');
143
- });
144
-
145
- // 處理不同類型的訊息
146
- switch (data.type) {
147
- case 'bot_message':
148
- this.handleBotMessage(data);
149
- break;
150
- case 'chat_history':
151
- this.handleChatHistory(data);
152
- break;
153
- case 'voice_login_status':
154
- this.handleVoiceLoginStatus(data);
155
- break;
156
- case 'voice_login_result':
157
- this.handleVoiceLoginResult(data);
158
- break;
159
- case 'new_chat_created':
160
- this.handleNewChatCreated(data);
161
- break;
162
- case 'error':
163
- this.handleError(data);
164
- break;
165
- case 'system':
166
- case 'message':
167
- // 這些訊息類型直接由 app.js ���回調處理
168
- // 不需要額外處理,已經在上面的 forEach 中處理了
169
- break;
170
- default:
171
- console.log('收到未知類型的訊息:', data.type);
172
- }
173
- } catch (error) {
174
- console.error('解析 WebSocket 訊息失敗:', error);
175
- }
176
- }
177
-
178
- // 處理機器人訊息
179
- handleBotMessage(data) {
180
- if (window.addMessage) {
181
- window.addMessage('assistant', data.message || '');
182
- }
183
- }
184
-
185
- // 處理聊天歷史
186
- handleChatHistory(data) {
187
- if (Array.isArray(data.messages) && window.addMessage) {
188
- data.messages.forEach(m => {
189
- window.addMessage(
190
- m.role === 'assistant' ? 'assistant' : 'user',
191
- m.content || ''
192
- );
193
- });
194
- }
195
- }
196
-
197
- // 處理語音登入狀態
198
- handleVoiceLoginStatus(data) {
199
- const statusEl = document.getElementById('voice-login-status');
200
- if (statusEl) {
201
- statusEl.textContent = '錄製中…';
202
- }
203
- }
204
-
205
- // 處理語音登入結果
206
- async handleVoiceLoginResult(data) {
207
- const statusEl = document.getElementById('voice-login-status');
208
- const success = !!data.success;
209
-
210
- if (success && data.user) {
211
- // 語音登入成功
212
- if (window.handleAuthenticatedUser) {
213
- window.handleAuthenticatedUser(data.user, data.access_token);
214
- }
215
-
216
- await this.handleVoiceLoginSuccess(data);
217
- } else {
218
- // 語音登入失敗
219
- const reason = data.error || '未知錯誤';
220
- console.error('語音登入失敗:', reason);
221
-
222
- if (window.UIStateManager?.showError) {
223
- window.UIStateManager.showError(`語音登入失敗:${reason}`);
224
- }
225
- }
226
-
227
- if (statusEl) {
228
- statusEl.textContent = success ? '完成' : '失敗';
229
- }
230
- }
231
-
232
- // 處理語音登入成功
233
- async handleVoiceLoginSuccess(data) {
234
- try {
235
- const userId = data.user.id;
236
- const chats = await this.getUserChats(userId);
237
-
238
- if (chats && chats.length > 0) {
239
- // 使用現有對話
240
- const latestChat = chats[0];
241
- window.currentChatId = latestChat.chat_id;
242
-
243
- this.clearChatAndShowWelcome(data.welcome);
244
- await this.saveWelcomeMessage(data.welcome, latestChat.chat_id);
245
-
246
- if (window.refreshChats) {
247
- window.refreshChats();
248
- }
249
- } else {
250
- // 創建新對話
251
- await this.createVoiceChatSession(userId, data.welcome);
252
- }
253
- } catch (error) {
254
- console.error('語音登入處理對話失敗:', error);
255
- this.clearChatAndShowWelcome(data.welcome);
256
- }
257
- }
258
-
259
- // 創建語音聊天會話
260
- async createVoiceChatSession(userId, welcome) {
261
- const now = new Date();
262
- const title = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
263
-
264
- const response = await fetch('/api/chats', {
265
- method: 'POST',
266
- headers: { 'Content-Type': 'application/json' },
267
- body: JSON.stringify({ user_id: userId, title })
268
- });
269
-
270
- const result = await response.json();
271
- if (result && result.chat_id) {
272
- window.currentChatId = result.chat_id;
273
- if (window.refreshChats) {
274
- window.refreshChats();
275
- }
276
- }
277
-
278
- this.clearChatAndShowWelcome(welcome);
279
- await this.saveWelcomeMessage(welcome, window.currentChatId);
280
- }
281
-
282
- // 清除聊天並顯示歡迎訊息
283
- clearChatAndShowWelcome(welcome) {
284
- if (window.clearChat) {
285
- window.clearChat();
286
- }
287
- if (welcome && window.addSystem) {
288
- window.addSystem(String(welcome));
289
- }
290
- }
291
-
292
- // 保存歡迎訊息
293
- async saveWelcomeMessage(welcome, chatId) {
294
- if (!welcome || !chatId) return;
295
-
296
- try {
297
- await fetch(`/api/chats/${encodeURIComponent(chatId)}/messages`, {
298
- method: 'POST',
299
- headers: { 'Content-Type': 'application/json' },
300
- body: JSON.stringify({
301
- sender: 'assistant',
302
- content: String(welcome)
303
- })
304
- });
305
- } catch (error) {
306
- console.warn('保存歡迎訊息失敗:', error);
307
- }
308
- }
309
-
310
- // 處理新對話創建
311
- handleNewChatCreated(data) {
312
- window.currentChatId = data.chat_id;
313
- if (window.addSystem) {
314
- window.addSystem(`已創建新對話:${data.title || '新對話'}`);
315
- }
316
- if (window.refreshChats) {
317
- window.refreshChats();
318
- }
319
- }
320
-
321
- // 處理 WebSocket 關閉事件
322
- handleClose() {
323
- console.log('WebSocket 連接已關閉');
324
- this.notifyOnlineState(false);
325
- this.isConnecting = false;
326
- this.scheduleReconnect();
327
- }
328
-
329
- // 處理 WebSocket 錯誤事件
330
- handleError(error) {
331
- console.error('WebSocket 連接錯誤:', error);
332
- this.notifyOnlineState(false);
333
- this.isConnecting = false;
334
- }
335
-
336
- // 排程重連
337
- scheduleReconnect() {
338
- if (this.reconnectAttempts >= this.maxReconnectAttempts) {
339
- console.error('WebSocket 重連次數已達上限');
340
- return;
341
- }
342
-
343
- this.reconnectAttempts++;
344
- const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
345
-
346
- setTimeout(() => {
347
- console.log(`WebSocket 重連嘗試 ${this.reconnectAttempts}/${this.maxReconnectAttempts}`);
348
- this.connect();
349
- }, delay);
350
- }
351
-
352
- // 發送訊息(通用)
353
- send(data) {
354
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
355
- this.ws.send(JSON.stringify(data));
356
- return true;
357
- }
358
- return false;
359
- }
360
-
361
- // 發送用戶輸入
362
- sendUserMessage(text, chatId) {
363
- if (!text || !this.isConnected()) return false;
364
-
365
- // 檢查認證狀態
366
- if (!this.auth.isAuthenticated()) {
367
- if (window.addSystem) {
368
- window.addSystem('❌ 請先使用 Google 登入');
369
- }
370
- return false;
371
- }
372
-
373
- // 檢查對話ID
374
- if (!chatId) {
375
- if (window.addSystem) {
376
- window.addSystem('❌ 請先選擇或創建一個對話');
377
- }
378
- return false;
379
- }
380
-
381
- const payload = {
382
- type: 'user_message',
383
- message: text,
384
- user_id: getCurrentUserId(),
385
- chat_id: chatId
386
- };
387
-
388
- return this.send(payload);
389
- }
390
-
391
- // 檢查連接狀態
392
- isConnected() {
393
- return this.ws && this.ws.readyState === WebSocket.OPEN;
394
- }
395
-
396
- // 斷開連接
397
- disconnect() {
398
- if (this.ws) {
399
- this.ws.removeEventListener('open', this.handleOpen);
400
- this.ws.removeEventListener('message', this.handleMessage);
401
- this.ws.removeEventListener('close', this.handleClose);
402
- this.ws.removeEventListener('error', this.handleError);
403
-
404
- this.ws.close();
405
- this.ws = null;
406
- }
407
- this.notifyOnlineState(false);
408
- }
409
-
410
- // 添加在線狀態回調
411
- onOnlineStateChange(callback) {
412
- this.onlineCallbacks.push(callback);
413
- }
414
-
415
- // 添加訊息回調
416
- onMessage(callback) {
417
- this.messageCallbacks.push(callback);
418
- }
419
-
420
- // 通知在線狀態變化
421
- notifyOnlineState(isOnline) {
422
- this.onlineCallbacks.forEach(callback => {
423
- safeExecute(() => callback(isOnline), null, '在線狀態回調');
424
- });
425
-
426
- // 更新DOM狀態指示器
427
- if (window.setOnline) {
428
- window.setOnline(isOnline);
429
- }
430
- }
431
-
432
- // 獲取用戶對話列表
433
- async getUserChats(userId) {
434
- try {
435
- const response = await fetch(`/api/chats/${userId}`);
436
- const data = await response.json();
437
- return data.success ? data.chats : [];
438
- } catch (error) {
439
- console.error('獲取用戶對話失敗:', error);
440
- return [];
441
- }
442
- }
443
-
444
- // 創建初始對話
445
- async createInitialChat(user) {
446
- if (window.createInitialChat) {
447
- return window.createInitialChat(user);
448
- }
449
- }
450
- }
451
-
452
- // ========== 語音登入管理 ==========
453
-
454
- export class VoiceLoginManager {
455
- constructor(wsManager) {
456
- this.ws = wsManager;
457
- this.isRecording = false;
458
- }
459
-
460
- // 開始語音登入
461
- async startVoiceLogin() {
462
- if (this.isRecording) return;
463
-
464
- try {
465
- // 確保 WebSocket 連接
466
- if (!this.ws.isConnected()) {
467
- await this.ws.connect();
468
- await new Promise(resolve => setTimeout(resolve, 300));
469
- }
470
-
471
- // 啟動錄音
472
- const stream = await navigator.mediaDevices.getUserMedia({
473
- audio: { channelCount: 1, sampleRate: 48000 },
474
- video: false
475
- });
476
-
477
- const audioCtx = new (window.AudioContext || window.webkitAudioContext)({
478
- sampleRate: 16000
479
- });
480
- const source = audioCtx.createMediaStreamSource(stream);
481
- const processor = audioCtx.createScriptProcessor(4096, 1, 1);
482
-
483
- source.connect(processor);
484
- processor.connect(audioCtx.destination);
485
-
486
- this.isRecording = true;
487
- this.updateStatus('準備錄製…');
488
- this.ws.send({ type: 'audio_start', sample_rate: 16000 });
489
-
490
- let collectedMs = 0;
491
- const targetMs = 4000; // 4 秒錄音
492
-
493
- processor.onaudioprocess = (e) => {
494
- try {
495
- const input = e.inputBuffer.getChannelData(0);
496
-
497
- // float32 -> int16 PCM,小端
498
- const pcm16 = new Int16Array(input.length);
499
- for (let i = 0; i < input.length; i++) {
500
- let s = Math.max(-1, Math.min(1, input[i]));
501
- pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
502
- }
503
-
504
- const bytes = new Uint8Array(pcm16.buffer);
505
- const b64 = btoa(String.fromCharCode(...bytes));
506
-
507
- this.ws.send({ type: 'audio_chunk', pcm16_base64: b64 });
508
-
509
- collectedMs += (input.length / 16000) * 1000;
510
- this.updateStatus(`錄製中… ${(collectedMs/1000).toFixed(1)}s / 4.0s`);
511
-
512
- if (collectedMs >= targetMs) {
513
- // 停止錄音
514
- this.stopRecording(processor, source, stream);
515
- }
516
- } catch (error) {
517
- console.error('音頻處理錯誤:', error);
518
- }
519
- };
520
-
521
- } catch (error) {
522
- console.error('語音登入啟動失敗:', error);
523
- this.updateStatus('無法取得麥克風權限');
524
-
525
- if (window.UIStateManager?.showError) {
526
- window.UIStateManager.showError('無法啟動語音登入:麥克風不可用');
527
- }
528
- }
529
- }
530
-
531
- // 停止錄音
532
- stopRecording(processor, source, stream) {
533
- processor.disconnect();
534
-
535
- try {
536
- source.disconnect();
537
- } catch(e) {
538
- console.warn('斷開音頻源失敗:', e);
539
- }
540
-
541
- try {
542
- stream.getTracks().forEach(track => track.stop());
543
- } catch(e) {
544
- console.warn('停止音頻軌道失敗:', e);
545
- }
546
-
547
- this.ws.send({ type: 'audio_stop' });
548
- this.isRecording = false;
549
- }
550
-
551
- // 更新狀態顯示
552
- updateStatus(message) {
553
- const statusEl = document.getElementById('voice-login-status');
554
- if (statusEl) {
555
- statusEl.textContent = message;
556
- }
557
- }
558
- }
559
-
560
- console.log('✅ Bloom Ware WebSocket 模組已載入');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/frontend/js/websocket.js CHANGED
@@ -1,9 +1,4 @@
1
- /**
2
- * Bloom Ware WebSocket 通訊管理模組(完整版)
3
- * 處理 WebSocket 連接、訊息收發、重連機制
4
- */
5
 
6
- // ========== WebSocket 連接管理 ==========
7
 
8
  class WebSocketManager {
9
  constructor(wsUrl) {
@@ -14,18 +9,15 @@ class WebSocketManager {
14
  this.maxReconnectAttempts = 5;
15
  this.reconnectDelay = 2000;
16
 
17
- // 狀態管理回調
18
  this.onlineCallbacks = [];
19
  this.messageCallbacks = [];
20
 
21
- // 音訊錄製相關
22
  this.audioContext = null;
23
  this.audioStream = null;
24
  this.audioProcessor = null;
25
  this.audioSource = null;
26
  this.isRecording = false;
27
 
28
- // 綁定方法上下文
29
  this.connect = this.connect.bind(this);
30
  this.handleOpen = this.handleOpen.bind(this);
31
  this.handleMessage = this.handleMessage.bind(this);
@@ -35,16 +27,13 @@ class WebSocketManager {
35
  this.stopRecording = this.stopRecording.bind(this);
36
  }
37
 
38
- // 建立 WebSocket 連接
39
  async connect() {
40
  if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
41
- console.log('⚠️ WebSocket 已連接或正在連接中');
42
  return;
43
  }
44
 
45
  try {
46
  this.isConnecting = true;
47
- console.log('🔌 開始建立 WebSocket 連接:', this.wsUrl);
48
 
49
  this.ws = new WebSocket(this.wsUrl);
50
  this.notifyOnlineState(false);
@@ -61,35 +50,33 @@ class WebSocketManager {
61
  }
62
  }
63
 
64
- // 處理 WebSocket 開啟事件
65
  handleOpen() {
66
- console.log('✅ WebSocket 連接已建立');
67
  this.isConnecting = false;
68
  this.reconnectAttempts = 0;
69
  this.notifyOnlineState(true);
70
 
71
- // 若已有目前對話,告知後端綁定 chat_id
72
  const cid = window.currentChatId;
73
  if (cid) {
74
  this.send({ type: 'chat_focus', chat_id: cid });
75
  }
76
 
77
- // 啟動位置追蹤(WebSocket 連線後)
78
  if (typeof startLocationTracking === 'function') {
79
  startLocationTracking();
80
- console.log('📍 位置追蹤已啟動');
81
  } else {
82
  console.warn('⚠️ startLocationTracking 函數未定義');
83
  }
84
  }
85
 
86
- // 處理 WebSocket 訊息
87
  handleMessage(event) {
88
  try {
89
  const data = JSON.parse(event.data);
90
- console.log('📩 收到 WebSocket 訊息:', data.type);
91
 
92
- // 通知所有訊息回調
 
 
 
 
 
93
  this.messageCallbacks.forEach(callback => {
94
  try {
95
  callback(data);
@@ -103,15 +90,10 @@ class WebSocketManager {
103
  }
104
  }
105
 
106
- // 處理 WebSocket 關閉事件
107
  handleClose(event) {
108
- console.log('🔌 WebSocket 連接已關閉', event);
109
  this.notifyOnlineState(false);
110
  this.isConnecting = false;
111
 
112
- // 檢查是否為認證失敗(code 1008 或 1006 表示異常關閉)
113
- // code 1006: 連接異常關閉(通常是握手失敗)
114
- // code 1008: 違反政策(認證失敗)
115
  const isAuthError = event.code === 1008 ||
116
  event.code === 1006 ||
117
  event.reason?.includes('認證') ||
@@ -120,30 +102,23 @@ class WebSocketManager {
120
 
121
  if (isAuthError) {
122
  console.error('❌ 認證失敗,清除 token 並跳轉到登入頁面');
123
- // 清除過期的 token
124
  localStorage.removeItem('jwt_token');
125
- // 跳轉到登入頁
126
  setTimeout(() => {
127
  window.location.href = '/login/';
128
  }, 500);
129
  return;
130
  }
131
 
132
- // 其他情況才嘗試重連
133
  this.scheduleReconnect();
134
  }
135
 
136
- // 處理 WebSocket 錯誤事件
137
  handleError(error) {
138
  console.error('❌ WebSocket 連接錯誤:', error);
139
  this.notifyOnlineState(false);
140
  this.isConnecting = false;
141
 
142
- // WebSocket 錯誤通常會伴隨 close 事件,但為了安全起見也檢查 token
143
- // 如果連接失敗,可能是認證問題
144
  setTimeout(() => {
145
  if (this.reconnectAttempts > 0) {
146
- // 已經嘗試重連,檢查 token 是否有效
147
  const token = localStorage.getItem('jwt_token');
148
  if (token) {
149
  try {
@@ -165,9 +140,7 @@ class WebSocketManager {
165
  }, 1000);
166
  }
167
 
168
- // 排程重連
169
  scheduleReconnect() {
170
- // 如果重連次數超過 3 次,檢查是否為認證問題
171
  if (this.reconnectAttempts >= 3) {
172
  console.warn('⚠️ 多次重連失敗,檢查 token 有效性...');
173
 
@@ -188,7 +161,6 @@ class WebSocketManager {
188
  }
189
  }
190
 
191
- // 如果重連次數達到上限,清除 token 並跳轉
192
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
193
  console.error('❌ WebSocket 重連次數已達上限,可能是認證問題,清除 token 並跳轉登入頁');
194
  localStorage.removeItem('jwt_token');
@@ -200,33 +172,30 @@ class WebSocketManager {
200
  this.reconnectAttempts++;
201
  const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
202
 
203
- console.log(`🔄 WebSocket 將在 ${delay}ms 後重連(第 ${this.reconnectAttempts}/${this.maxReconnectAttempts} 次)`);
204
 
205
  setTimeout(() => {
206
- console.log(`🔄 WebSocket 重連嘗試 ${this.reconnectAttempts}/${this.maxReconnectAttempts}`);
207
  this.connect();
208
  }, delay);
209
  }
210
 
211
- // 發送訊息(通用)
212
  send(data) {
213
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
214
  this.ws.send(JSON.stringify(data));
215
- console.log('📤 發送 WebSocket 訊息:', data.type);
 
 
216
  return true;
217
  }
218
- console.warn('⚠️ WebSocket 未連接,無法發送訊息');
219
  return false;
220
  }
221
 
222
- // 發送用戶輸入
223
  sendUserMessage(text, chatId) {
224
  if (!text || !this.isConnected()) {
225
  console.warn('⚠️ WebSocket 未連接或訊息為空');
226
  return false;
227
  }
228
 
229
- // 檢查對話ID
230
  if (!chatId) {
231
  console.warn('⚠️ 缺少 chat_id');
232
  return false;
@@ -241,12 +210,10 @@ class WebSocketManager {
241
  return this.send(payload);
242
  }
243
 
244
- // 檢查連接狀態
245
  isConnected() {
246
  return this.ws && this.ws.readyState === WebSocket.OPEN;
247
  }
248
 
249
- // 斷開連接
250
  disconnect() {
251
  if (this.ws) {
252
  this.ws.removeEventListener('open', this.handleOpen);
@@ -260,19 +227,15 @@ class WebSocketManager {
260
  this.notifyOnlineState(false);
261
  }
262
 
263
- // 添加在線狀態回調
264
  onOnlineStateChange(callback) {
265
  this.onlineCallbacks.push(callback);
266
  }
267
 
268
- // 添加訊息回調
269
  onMessage(callback) {
270
  this.messageCallbacks.push(callback);
271
  }
272
 
273
- // 通知在線狀態變化
274
  notifyOnlineState(isOnline) {
275
- console.log(`🌐 WebSocket 狀態: ${isOnline ? '已連線' : '斷線'}`);
276
 
277
  this.onlineCallbacks.forEach(callback => {
278
  try {
@@ -283,11 +246,7 @@ class WebSocketManager {
283
  });
284
  }
285
 
286
- // ========== 音訊錄製功能 ==========
287
 
288
- /**
289
- * 開始錄音(用於對話模式)
290
- */
291
  async startRecording() {
292
  if (this.isRecording) {
293
  console.warn('⚠️ 已經在錄音中');
@@ -300,14 +259,11 @@ class WebSocketManager {
300
  }
301
 
302
  try {
303
- console.log('🎙️ 開始錄音...');
304
 
305
- // 🔓 解鎖音頻播放(利用用戶點擊麥克風的手勢)
306
  if (typeof unlockAudioPlayback === 'function') {
307
  unlockAudioPlayback();
308
  }
309
 
310
- // 請求麥克風權限
311
  this.audioStream = await navigator.mediaDevices.getUserMedia({
312
  audio: {
313
  channelCount: 1,
@@ -318,20 +274,16 @@ class WebSocketManager {
318
  }
319
  });
320
 
321
- // 創建音訊上下文
322
  this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
323
  sampleRate: 16000
324
  });
325
 
326
- // 創建音訊處理節點
327
  this.audioSource = this.audioContext.createMediaStreamSource(this.audioStream);
328
  this.audioProcessor = this.audioContext.createScriptProcessor(4096, 1, 1);
329
 
330
- // 連接音訊節點
331
  this.audioSource.connect(this.audioProcessor);
332
  this.audioProcessor.connect(this.audioContext.destination);
333
 
334
- // 發送開始錄音信號(使用自動語言檢測)
335
  this.send({
336
  type: 'audio_start',
337
  sample_rate: 16000,
@@ -339,29 +291,24 @@ class WebSocketManager {
339
  language: 'auto' // 自動檢測語言(支援:zh/en/id/ja/vi)
340
  });
341
 
342
- console.log('🌐 使用自動語言檢測');
343
 
344
  this.isRecording = true;
345
 
346
- // 處理音訊數據
347
  this.audioProcessor.onaudioprocess = (e) => {
348
  if (!this.isRecording) return;
349
 
350
  try {
351
  const inputData = e.inputBuffer.getChannelData(0);
352
 
353
- // Float32 轉 Int16 PCM
354
  const pcm16 = new Int16Array(inputData.length);
355
  for (let i = 0; i < inputData.length; i++) {
356
  let sample = Math.max(-1, Math.min(1, inputData[i]));
357
  pcm16[i] = sample < 0 ? sample * 0x8000 : sample * 0x7FFF;
358
  }
359
 
360
- // 轉為 Uint8Array 並 Base64 編碼
361
  const bytes = new Uint8Array(pcm16.buffer);
362
  const b64 = btoa(String.fromCharCode(...bytes));
363
 
364
- // 發送音訊塊
365
  this.send({
366
  type: 'audio_chunk',
367
  pcm16_base64: b64
@@ -372,13 +319,11 @@ class WebSocketManager {
372
  }
373
  };
374
 
375
- console.log('✅ 錄音已開始');
376
  return true;
377
 
378
  } catch (error) {
379
  console.error('❌ 開始錄音失敗:', error);
380
 
381
- // 顯示錯誤提示
382
  if (error.name === 'NotAllowedError') {
383
  if (typeof showErrorNotification === 'function') {
384
  showErrorNotification('需要麥克風權限才能使用語音功能');
@@ -390,24 +335,18 @@ class WebSocketManager {
390
  }
391
  }
392
 
393
- /**
394
- * 停止錄音
395
- */
396
  stopRecording() {
397
  if (!this.isRecording) {
398
  console.warn('⚠️ 目前沒有在錄音');
399
  return;
400
  }
401
 
402
- console.log('🛑 停止錄音...');
403
 
404
- // 停止音訊處理
405
  if (this.audioProcessor) {
406
  this.audioProcessor.disconnect();
407
  this.audioProcessor = null;
408
  }
409
 
410
- // 斷開音訊源
411
  if (this.audioSource) {
412
  try {
413
  this.audioSource.disconnect();
@@ -417,92 +356,61 @@ class WebSocketManager {
417
  this.audioSource = null;
418
  }
419
 
420
- // 停止麥克風軌道
421
  if (this.audioStream) {
422
  this.audioStream.getTracks().forEach(track => track.stop());
423
  this.audioStream = null;
424
  }
425
 
426
- // 關閉音訊上下文
427
  if (this.audioContext) {
428
  this.audioContext.close();
429
  this.audioContext = null;
430
  }
431
 
432
- // 發送停止錄音信號
433
  this.send({
434
  type: 'audio_stop',
435
  mode: 'realtime_chat' // 即時轉錄模式
436
  });
437
 
438
  this.isRecording = false;
439
- console.log('✅ 錄音已停止');
440
  }
441
  }
442
 
443
- /**
444
- * 初始化 WebSocket 連接(由 app.js 呼叫)
445
- * @param {string} token - JWT token
446
- */
447
  function initializeWebSocket(token) {
448
- console.log('🚀 初始化 WebSocket 連接...');
449
-
450
- // 根據當前頁面協議確定WebSocket協議
451
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
452
  const host = window.location.host;
453
 
454
- // 讀取語音登入的情緒資料(如果有)
455
  const voiceEmotion = localStorage.getItem('voice_login_emotion');
456
-
457
- // 構建WebSocket URL(包含情緒參數)
458
  let wsUrl = `${protocol}//${host}/ws?token=${token}`;
459
  if (voiceEmotion) {
460
  wsUrl += `&emotion=${encodeURIComponent(voiceEmotion)}`;
461
- console.log('😊 檢測到語音登入情緒,將傳遞給歡迎詞:', voiceEmotion);
462
- // 使用後立即清除,避免重複使用
463
  localStorage.removeItem('voice_login_emotion');
464
-
465
- // 立即套用情緒主題到 UI(不等待後端回應)
466
  if (typeof applyEmotion === 'function') {
467
  applyEmotion(voiceEmotion);
468
- console.log('✅ 語音登入情緒主題已套用:', voiceEmotion);
469
  }
470
  }
471
 
472
- // 創建 WebSocket 管理器
473
  wsManager = new WebSocketManager(wsUrl);
474
 
475
- // 監聽訊息(原本在 js/websocket.js 中的邏輯)
476
  wsManager.onMessage((data) => {
477
- console.log('📩 收到 WebSocket 訊息:', data);
478
-
479
  switch(data.type) {
480
  case 'system':
481
- // 系統訊息(歡迎詞、連線成功)
482
- console.log('🔔 系統訊息:', data.message);
483
-
484
- // 提取並保存 chat_id(後端在歡迎訊息中會發送)
485
  if (data.chat_id) {
486
  window.currentChatId = data.chat_id;
487
- console.log('✅ Chat ID 已設定(來自 system 訊息):', window.currentChatId);
488
  }
489
-
490
  if (data.message) {
491
  setState('speaking', {
492
  outputText: data.message,
493
- enableTTS: false, // 歡迎詞不使用 TTS
494
- persistent: true // 持續顯示直到用戶發起請求
495
  });
496
- // 系統訊息不自動轉回 idle,保持顯示狀態
497
- // 用戶發起請求時會自動清除
498
  }
499
  break;
500
 
501
  case 'typing':
502
- // 思考中提示(新請求開始,隱藏工具卡片)
503
  if (data.message === 'thinking') {
504
  setState('thinking');
505
- // 隱藏上一次的工具卡片
506
  if (typeof hideToolCards === 'function') {
507
  hideToolCards();
508
  }
@@ -510,45 +418,31 @@ function initializeWebSocket(token) {
510
  break;
511
 
512
  case 'bot_message':
513
- // AI 回應完成 - 使用異步方式(文字與語音並行)
514
- console.log('💬 AI 回應:', data.message);
515
- console.log('🔧 工具資訊:', {
516
- tool_name: data.tool_name,
517
- tool_data: data.tool_data,
518
- has_tool_data: !!data.tool_data,
519
- tool_data_keys: data.tool_data ? Object.keys(data.tool_data) : null
520
- });
521
-
522
- // 同時啟動:文字打字效果 + 語音播放
523
  setState('speaking', {
524
  outputText: data.message,
525
- enableTTS: true // 啟用語音(異步並行)
526
  });
527
 
528
- // 如果有工具資料,顯示對應卡片
529
  if (data.tool_name && data.tool_data) {
530
- console.log('📊 準備顯示工具卡片:', data.tool_name);
531
  displayToolCard(data.tool_name, data.tool_data);
532
- } else {
533
- console.log('⚠️ 無工具資料,不顯示卡片');
534
  }
535
-
536
- // 不自動返回 idle,保持回應顯示
537
- // 用戶下次點擊麥克風時會自動開始新的請求
538
- console.log('✅ AI 回應已顯示(保持狀態直到下次請求)');
539
  break;
540
 
541
  case 'stt_partial':
542
- // STT 臨時結果(用戶還在說話)
543
- console.log('🎙️ STT 臨時結果:', data.text);
544
  transcript.textContent = data.text;
545
  transcript.className = 'voice-transcript provisional';
546
  break;
547
 
548
  case 'stt_delta':
549
- // STT 即時增量結果(OpenAI Realtime API)
550
- console.log('⚡ STT Delta:', data.text);
551
- // 累積顯示轉錄文字(逐字顯示效果)
552
  if (!window.realtimeTranscript) {
553
  window.realtimeTranscript = '';
554
  }
@@ -558,100 +452,63 @@ function initializeWebSocket(token) {
558
  break;
559
 
560
  case 'stt_final':
561
- // STT 最終結果(用戶停止說話)
562
- console.log('✅ STT 最終結果:', data.text);
563
  transcript.textContent = data.text;
564
  transcript.className = 'voice-transcript final';
565
-
566
- // 清空即時轉錄累積文字
567
  window.realtimeTranscript = '';
568
 
569
- // 應用情緒主題(如果有的話)
570
  if (data.emotion && typeof applyEmotion === 'function') {
571
- console.log('😊 應用情緒主題:', data.emotion);
572
  applyEmotion(data.emotion);
573
  }
574
  break;
575
 
576
  case 'realtime_stt_status':
577
- // OpenAI Realtime API 連線狀態
578
- console.log('🔌 Realtime STT 狀態:', data.status, data.message);
579
  if (data.status === 'connected') {
580
- console.log('✅ 即時轉錄已啟動');
581
- // 清空之前的累積文字
582
  window.realtimeTranscript = '';
583
- } else if (data.status === 'disconnected') {
584
- console.log('🔌 即時轉錄已結束');
585
  }
586
  break;
587
 
588
  case 'chat_ready':
589
- // 後端發送當前 chat_id
590
  window.currentChatId = data.chat_id;
591
- console.log('✅ Chat ID 已設定:', window.currentChatId);
592
  break;
593
 
594
  case 'error':
595
- // 錯誤訊息
596
  console.error('❌ 後端錯誤:', data.message);
597
  setState('idle');
598
  showErrorNotification(data.message);
599
  break;
600
 
601
  case 'voice_login_result':
602
- // 語音登入結果
603
  handleVoiceLoginResult(data);
604
  break;
605
 
606
  case 'voice_login_status':
607
- // 語音登入狀態更新
608
- console.log('🎙️ 語音登入狀態:', data.message);
609
  break;
610
 
611
  case 'emotion_detected':
612
- // 情緒檢測結果
613
- console.log('😊 檢測到情緒:', data.emotion, '關懷模式:', data.care_mode);
614
-
615
- // 應用情緒主題(使用 agent.js 的 applyEmotion 函數)
616
  if (data.emotion && typeof applyEmotion === 'function') {
617
  applyEmotion(data.emotion);
618
- console.log('✅ 情緒主題已套用:', data.emotion);
619
- } else {
620
- console.warn('⚠️ applyEmotion 函數未定義或情緒值無效');
621
  }
622
-
623
- // 如果啟用關懷模式,隱藏工具卡片
624
- if (data.care_mode) {
625
- console.log('💙 關懷模式已啟動,隱藏工具卡片');
626
- if (typeof hideToolCards === 'function') {
627
- hideToolCards();
628
- }
629
  }
630
  break;
631
 
632
  case 'audio_emotion_detected':
633
- // 音頻情緒檢測結果(新增)
634
- console.log('🎭 檢測到音頻情緒:', data.emotion, '置信度:', data.confidence, 'source:', data.source);
635
-
636
- // 如果置信度 >= 0.5,應用情緒主題
637
- if (data.emotion && data.confidence >= 0.5 && typeof applyEmotion === 'function') {
638
  applyEmotion(data.emotion);
639
- console.log('✅ 音頻情緒主題已套用:', data.emotion, `(置信度: ${data.confidence.toFixed(2)})`);
640
- } else if (data.confidence < 0.5) {
641
- console.log('⚠️ 音頻情緒置信度過低,不套用主題:', data.confidence.toFixed(2));
642
- } else {
643
- console.warn('⚠️ applyEmotion 函數未定義或情緒值無效');
644
  }
645
  break;
646
 
 
 
 
647
  default:
648
- console.log('🔍 未處理的訊息類型:', data.type);
 
649
  }
650
  });
651
 
652
- // 監聽連線狀態變化
653
  wsManager.onOnlineStateChange((isOnline) => {
654
- console.log(`🌐 WebSocket 狀態: ${isOnline ? '已連線' : '斷線'}`);
655
  if (!isOnline) {
656
  setState('disconnected');
657
  } else if (currentState === 'disconnected') {
@@ -659,25 +516,17 @@ function initializeWebSocket(token) {
659
  }
660
  });
661
 
662
- // 開始連接
663
  wsManager.connect();
664
-
665
- console.log('✅ WebSocket 管理器已初始化');
666
  }
667
 
668
- /**
669
- * 處理語音登入結果
670
- */
671
  function handleVoiceLoginResult(data) {
672
  if (data.success && data.user) {
673
  currentUserId = data.user.id;
674
 
675
- // 套用情緒主題
676
  if (data.emotion && data.emotion.label) {
677
  applyEmotion(data.emotion.label);
678
  }
679
 
680
- // 顯示歡迎詞
681
  if (data.welcome) {
682
  setState('speaking', {
683
  outputText: data.welcome,
@@ -686,11 +535,9 @@ function handleVoiceLoginResult(data) {
686
  setTimeout(() => setState('idle'), 5000);
687
  }
688
 
689
- console.log('✅ 語音登入成功:', data.user.name);
690
  } else {
691
  console.warn('⚠️ 語音登入失敗:', data.error);
692
  showErrorNotification(`語音登入失敗: ${data.error || '未知錯誤'}`);
693
  }
694
  }
695
 
696
- console.log('✅ WebSocket 模組已載入(完整版)');
 
 
 
 
 
1
 
 
2
 
3
  class WebSocketManager {
4
  constructor(wsUrl) {
 
9
  this.maxReconnectAttempts = 5;
10
  this.reconnectDelay = 2000;
11
 
 
12
  this.onlineCallbacks = [];
13
  this.messageCallbacks = [];
14
 
 
15
  this.audioContext = null;
16
  this.audioStream = null;
17
  this.audioProcessor = null;
18
  this.audioSource = null;
19
  this.isRecording = false;
20
 
 
21
  this.connect = this.connect.bind(this);
22
  this.handleOpen = this.handleOpen.bind(this);
23
  this.handleMessage = this.handleMessage.bind(this);
 
27
  this.stopRecording = this.stopRecording.bind(this);
28
  }
29
 
 
30
  async connect() {
31
  if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
 
32
  return;
33
  }
34
 
35
  try {
36
  this.isConnecting = true;
 
37
 
38
  this.ws = new WebSocket(this.wsUrl);
39
  this.notifyOnlineState(false);
 
50
  }
51
  }
52
 
 
53
  handleOpen() {
 
54
  this.isConnecting = false;
55
  this.reconnectAttempts = 0;
56
  this.notifyOnlineState(true);
57
 
 
58
  const cid = window.currentChatId;
59
  if (cid) {
60
  this.send({ type: 'chat_focus', chat_id: cid });
61
  }
62
 
 
63
  if (typeof startLocationTracking === 'function') {
64
  startLocationTracking();
 
65
  } else {
66
  console.warn('⚠️ startLocationTracking 函數未定義');
67
  }
68
  }
69
 
 
70
  handleMessage(event) {
71
  try {
72
  const data = JSON.parse(event.data);
 
73
 
74
+ const silentTypes = ['env_ack', 'typing', 'stt_partial', 'stt_delta'];
75
+ if (!silentTypes.includes(data.type)) {
76
+ if (window.DEBUG_MODE) {
77
+ }
78
+ }
79
+
80
  this.messageCallbacks.forEach(callback => {
81
  try {
82
  callback(data);
 
90
  }
91
  }
92
 
 
93
  handleClose(event) {
 
94
  this.notifyOnlineState(false);
95
  this.isConnecting = false;
96
 
 
 
 
97
  const isAuthError = event.code === 1008 ||
98
  event.code === 1006 ||
99
  event.reason?.includes('認證') ||
 
102
 
103
  if (isAuthError) {
104
  console.error('❌ 認證失敗,清除 token 並跳轉到登入頁面');
 
105
  localStorage.removeItem('jwt_token');
 
106
  setTimeout(() => {
107
  window.location.href = '/login/';
108
  }, 500);
109
  return;
110
  }
111
 
 
112
  this.scheduleReconnect();
113
  }
114
 
 
115
  handleError(error) {
116
  console.error('❌ WebSocket 連接錯誤:', error);
117
  this.notifyOnlineState(false);
118
  this.isConnecting = false;
119
 
 
 
120
  setTimeout(() => {
121
  if (this.reconnectAttempts > 0) {
 
122
  const token = localStorage.getItem('jwt_token');
123
  if (token) {
124
  try {
 
140
  }, 1000);
141
  }
142
 
 
143
  scheduleReconnect() {
 
144
  if (this.reconnectAttempts >= 3) {
145
  console.warn('⚠️ 多次重連失敗,檢查 token 有效性...');
146
 
 
161
  }
162
  }
163
 
 
164
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
165
  console.error('❌ WebSocket 重連次數已達上限,可能是認證問題,清除 token 並跳轉登入頁');
166
  localStorage.removeItem('jwt_token');
 
172
  this.reconnectAttempts++;
173
  const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
174
 
 
175
 
176
  setTimeout(() => {
 
177
  this.connect();
178
  }, delay);
179
  }
180
 
 
181
  send(data) {
182
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
183
  this.ws.send(JSON.stringify(data));
184
+ const silentTypes = ['audio_chunk', 'env_snapshot'];
185
+ if (window.DEBUG_MODE && !silentTypes.includes(data.type)) {
186
+ }
187
  return true;
188
  }
189
+ console.warn('⚠️ WebSocket 未連接');
190
  return false;
191
  }
192
 
 
193
  sendUserMessage(text, chatId) {
194
  if (!text || !this.isConnected()) {
195
  console.warn('⚠️ WebSocket 未連接或訊息為空');
196
  return false;
197
  }
198
 
 
199
  if (!chatId) {
200
  console.warn('⚠️ 缺少 chat_id');
201
  return false;
 
210
  return this.send(payload);
211
  }
212
 
 
213
  isConnected() {
214
  return this.ws && this.ws.readyState === WebSocket.OPEN;
215
  }
216
 
 
217
  disconnect() {
218
  if (this.ws) {
219
  this.ws.removeEventListener('open', this.handleOpen);
 
227
  this.notifyOnlineState(false);
228
  }
229
 
 
230
  onOnlineStateChange(callback) {
231
  this.onlineCallbacks.push(callback);
232
  }
233
 
 
234
  onMessage(callback) {
235
  this.messageCallbacks.push(callback);
236
  }
237
 
 
238
  notifyOnlineState(isOnline) {
 
239
 
240
  this.onlineCallbacks.forEach(callback => {
241
  try {
 
246
  });
247
  }
248
 
 
249
 
 
 
 
250
  async startRecording() {
251
  if (this.isRecording) {
252
  console.warn('⚠️ 已經在錄音中');
 
259
  }
260
 
261
  try {
 
262
 
 
263
  if (typeof unlockAudioPlayback === 'function') {
264
  unlockAudioPlayback();
265
  }
266
 
 
267
  this.audioStream = await navigator.mediaDevices.getUserMedia({
268
  audio: {
269
  channelCount: 1,
 
274
  }
275
  });
276
 
 
277
  this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
278
  sampleRate: 16000
279
  });
280
 
 
281
  this.audioSource = this.audioContext.createMediaStreamSource(this.audioStream);
282
  this.audioProcessor = this.audioContext.createScriptProcessor(4096, 1, 1);
283
 
 
284
  this.audioSource.connect(this.audioProcessor);
285
  this.audioProcessor.connect(this.audioContext.destination);
286
 
 
287
  this.send({
288
  type: 'audio_start',
289
  sample_rate: 16000,
 
291
  language: 'auto' // 自動檢測語言(支援:zh/en/id/ja/vi)
292
  });
293
 
 
294
 
295
  this.isRecording = true;
296
 
 
297
  this.audioProcessor.onaudioprocess = (e) => {
298
  if (!this.isRecording) return;
299
 
300
  try {
301
  const inputData = e.inputBuffer.getChannelData(0);
302
 
 
303
  const pcm16 = new Int16Array(inputData.length);
304
  for (let i = 0; i < inputData.length; i++) {
305
  let sample = Math.max(-1, Math.min(1, inputData[i]));
306
  pcm16[i] = sample < 0 ? sample * 0x8000 : sample * 0x7FFF;
307
  }
308
 
 
309
  const bytes = new Uint8Array(pcm16.buffer);
310
  const b64 = btoa(String.fromCharCode(...bytes));
311
 
 
312
  this.send({
313
  type: 'audio_chunk',
314
  pcm16_base64: b64
 
319
  }
320
  };
321
 
 
322
  return true;
323
 
324
  } catch (error) {
325
  console.error('❌ 開始錄音失敗:', error);
326
 
 
327
  if (error.name === 'NotAllowedError') {
328
  if (typeof showErrorNotification === 'function') {
329
  showErrorNotification('需要麥克風權限才能使用語音功能');
 
335
  }
336
  }
337
 
 
 
 
338
  stopRecording() {
339
  if (!this.isRecording) {
340
  console.warn('⚠️ 目前沒有在錄音');
341
  return;
342
  }
343
 
 
344
 
 
345
  if (this.audioProcessor) {
346
  this.audioProcessor.disconnect();
347
  this.audioProcessor = null;
348
  }
349
 
 
350
  if (this.audioSource) {
351
  try {
352
  this.audioSource.disconnect();
 
356
  this.audioSource = null;
357
  }
358
 
 
359
  if (this.audioStream) {
360
  this.audioStream.getTracks().forEach(track => track.stop());
361
  this.audioStream = null;
362
  }
363
 
 
364
  if (this.audioContext) {
365
  this.audioContext.close();
366
  this.audioContext = null;
367
  }
368
 
 
369
  this.send({
370
  type: 'audio_stop',
371
  mode: 'realtime_chat' // 即時轉錄模式
372
  });
373
 
374
  this.isRecording = false;
 
375
  }
376
  }
377
 
 
 
 
 
378
  function initializeWebSocket(token) {
 
 
 
379
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
380
  const host = window.location.host;
381
 
 
382
  const voiceEmotion = localStorage.getItem('voice_login_emotion');
383
+
 
384
  let wsUrl = `${protocol}//${host}/ws?token=${token}`;
385
  if (voiceEmotion) {
386
  wsUrl += `&emotion=${encodeURIComponent(voiceEmotion)}`;
 
 
387
  localStorage.removeItem('voice_login_emotion');
388
+
 
389
  if (typeof applyEmotion === 'function') {
390
  applyEmotion(voiceEmotion);
 
391
  }
392
  }
393
 
 
394
  wsManager = new WebSocketManager(wsUrl);
395
 
 
396
  wsManager.onMessage((data) => {
 
 
397
  switch(data.type) {
398
  case 'system':
 
 
 
 
399
  if (data.chat_id) {
400
  window.currentChatId = data.chat_id;
 
401
  }
 
402
  if (data.message) {
403
  setState('speaking', {
404
  outputText: data.message,
405
+ enableTTS: false,
406
+ persistent: true
407
  });
 
 
408
  }
409
  break;
410
 
411
  case 'typing':
 
412
  if (data.message === 'thinking') {
413
  setState('thinking');
 
414
  if (typeof hideToolCards === 'function') {
415
  hideToolCards();
416
  }
 
418
  break;
419
 
420
  case 'bot_message':
421
+
422
+ if (data.emotion && typeof applyEmotion === 'function') {
423
+ applyEmotion(data.emotion);
424
+ }
425
+
426
+ if (data.care_mode && typeof hideToolCards === 'function') {
427
+ hideToolCards();
428
+ }
429
+
 
430
  setState('speaking', {
431
  outputText: data.message,
432
+ enableTTS: true
433
  });
434
 
 
435
  if (data.tool_name && data.tool_data) {
 
436
  displayToolCard(data.tool_name, data.tool_data);
 
 
437
  }
 
 
 
 
438
  break;
439
 
440
  case 'stt_partial':
 
 
441
  transcript.textContent = data.text;
442
  transcript.className = 'voice-transcript provisional';
443
  break;
444
 
445
  case 'stt_delta':
 
 
 
446
  if (!window.realtimeTranscript) {
447
  window.realtimeTranscript = '';
448
  }
 
452
  break;
453
 
454
  case 'stt_final':
 
 
455
  transcript.textContent = data.text;
456
  transcript.className = 'voice-transcript final';
 
 
457
  window.realtimeTranscript = '';
458
 
 
459
  if (data.emotion && typeof applyEmotion === 'function') {
 
460
  applyEmotion(data.emotion);
461
  }
462
  break;
463
 
464
  case 'realtime_stt_status':
 
 
465
  if (data.status === 'connected') {
 
 
466
  window.realtimeTranscript = '';
 
 
467
  }
468
  break;
469
 
470
  case 'chat_ready':
 
471
  window.currentChatId = data.chat_id;
 
472
  break;
473
 
474
  case 'error':
 
475
  console.error('❌ 後端錯誤:', data.message);
476
  setState('idle');
477
  showErrorNotification(data.message);
478
  break;
479
 
480
  case 'voice_login_result':
 
481
  handleVoiceLoginResult(data);
482
  break;
483
 
484
  case 'voice_login_status':
 
 
485
  break;
486
 
487
  case 'emotion_detected':
 
 
 
 
488
  if (data.emotion && typeof applyEmotion === 'function') {
489
  applyEmotion(data.emotion);
 
 
 
490
  }
491
+ if (data.care_mode && typeof hideToolCards === 'function') {
492
+ hideToolCards();
 
 
 
 
 
493
  }
494
  break;
495
 
496
  case 'audio_emotion_detected':
497
+ if (data.emotion && data.confidence >= 0.35 && typeof applyEmotion === 'function') {
 
 
 
 
498
  applyEmotion(data.emotion);
 
 
 
 
 
499
  }
500
  break;
501
 
502
+ case 'env_ack':
503
+ break;
504
+
505
  default:
506
+ if (window.DEBUG_MODE) {
507
+ }
508
  }
509
  });
510
 
 
511
  wsManager.onOnlineStateChange((isOnline) => {
 
512
  if (!isOnline) {
513
  setState('disconnected');
514
  } else if (currentState === 'disconnected') {
 
516
  }
517
  });
518
 
 
519
  wsManager.connect();
 
 
520
  }
521
 
 
 
 
522
  function handleVoiceLoginResult(data) {
523
  if (data.success && data.user) {
524
  currentUserId = data.user.id;
525
 
 
526
  if (data.emotion && data.emotion.label) {
527
  applyEmotion(data.emotion.label);
528
  }
529
 
 
530
  if (data.welcome) {
531
  setState('speaking', {
532
  outputText: data.welcome,
 
535
  setTimeout(() => setState('idle'), 5000);
536
  }
537
 
 
538
  } else {
539
  console.warn('⚠️ 語音登入失敗:', data.error);
540
  showErrorNotification(`語音登入失敗: ${data.error || '未知錯誤'}`);
541
  }
542
  }
543