XiaoBai1221 commited on
Commit
91f3927
·
1 Parent(s): feabc9f
app.py CHANGED
@@ -757,19 +757,24 @@ async def websocket_endpoint_with_jwt(
757
  """VAD 偵測到語音段結束"""
758
  logger.debug(f"🎤 VAD Committed: {item_id}")
759
 
 
 
 
 
760
  # 連線到 OpenAI Realtime API
761
  success = await realtime_stt.connect(
762
  on_transcript_delta=on_transcript_delta,
763
  on_transcript_done=on_transcript_done,
764
  on_vad_committed=on_vad_committed,
765
  model="gpt-4o-mini-transcribe",
766
- language="zh"
767
  )
768
 
769
  if success:
770
- # 儲存 Realtime STT 實例到 client info
771
  client_info = manager.get_client_info(user_id) or {}
772
  client_info["realtime_stt"] = realtime_stt
 
773
  manager.set_client_info(user_id, client_info)
774
 
775
  await websocket.send_json({
@@ -814,6 +819,13 @@ async def websocket_endpoint_with_jwt(
814
  audio_bytes = base64.b64decode(b64)
815
  await realtime_stt.send_audio_chunk(audio_bytes)
816
  logger.debug(f"🎤 轉發音頻到 OpenAI: {len(audio_bytes)} bytes")
 
 
 
 
 
 
 
817
  except Exception as e:
818
  logger.error(f"❌ 轉發音頻失敗: {e}")
819
 
@@ -1028,6 +1040,7 @@ async def websocket_endpoint_with_jwt(
1028
  client_info = manager.get_client_info(user_id) or {}
1029
  realtime_stt = client_info.get("realtime_stt")
1030
  transcription = client_info.get("realtime_transcript", "")
 
1031
 
1032
  if realtime_stt:
1033
  logger.info(f"🔌 關閉即時轉錄連線,用戶 {user_id}")
@@ -1051,6 +1064,38 @@ async def websocket_endpoint_with_jwt(
1051
  if transcription:
1052
  logger.info(f"🤖 處理即時轉錄結果: {transcription}")
1053
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1054
  # 通知前端開始思考
1055
  await websocket.send_json({"type": "typing", "message": "thinking"})
1056
 
@@ -1078,28 +1123,42 @@ async def websocket_endpoint_with_jwt(
1078
  # 保存用戶訊息
1079
  await save_message_to_db(user_id, chat_id, "user", transcription)
1080
 
 
 
 
1081
  # 處理對話(透過 handle_message,自動處理 pipeline)
1082
  response = await handle_message(
1083
  transcription,
1084
  user_id,
1085
  chat_id,
1086
- [] # messages 參數(會自動從數據庫載入)
 
 
1087
  )
1088
 
1089
  # 發送回應
 
 
 
1090
  if isinstance(response, PipelineResult):
1091
  message_text = response.text
 
 
 
1092
 
1093
  await websocket.send_json({
1094
  "type": "bot_message",
1095
  "message": message_text,
1096
  "timestamp": time.time(),
1097
  "tool_name": None,
1098
- "tool_data": None
 
 
1099
  })
1100
  elif isinstance(response, dict):
1101
  tool_name = response.get('tool_name')
1102
  tool_data = response.get('tool_data')
 
1103
  message_text = response.get('message', response.get('content', ''))
1104
 
1105
  await websocket.send_json({
@@ -1107,14 +1166,16 @@ async def websocket_endpoint_with_jwt(
1107
  "message": message_text,
1108
  "timestamp": time.time(),
1109
  "tool_name": tool_name,
1110
- "tool_data": tool_data
 
1111
  })
1112
  else:
1113
  # 字串回應
1114
  await websocket.send_json({
1115
  "type": "bot_message",
1116
  "message": str(response),
1117
- "timestamp": time.time()
 
1118
  })
1119
 
1120
  await _process_realtime_chat()
@@ -1128,170 +1189,6 @@ async def websocket_endpoint_with_jwt(
1128
  "message": f"關閉即時轉錄失敗: {str(e)}"
1129
  })
1130
 
1131
- elif mode == "chat":
1132
- # === 新的對話模式:並行執行 STT + 情緒辨識 ===
1133
- try:
1134
- import asyncio as _async_lib
1135
- from services.stt_service import transcribe_audio
1136
- from services.voice_login import VoiceAuthService
1137
-
1138
- # 獲取音頻數據(從 _buffers 中)
1139
- audio_data = None
1140
- emotion_result = None
1141
-
1142
- if hasattr(app.state, "voice_auth") and app.state.voice_auth:
1143
- voice_service = app.state.voice_auth
1144
-
1145
- # 獲取音頻數據(從 _buffers 取得完整音頻)
1146
- if user_id in voice_service._buffers:
1147
- audio_data = bytes(voice_service._buffers[user_id])
1148
- sample_rate = voice_service._sr_overrides.get(user_id, 16000)
1149
-
1150
- # 並行執行 STT 和情緒辨識
1151
- stt_task = transcribe_audio(audio_data, language="zh")
1152
- emotion_task = _async_lib.to_thread(
1153
- voice_service._infer_emotion_from_bytes,
1154
- audio_data,
1155
- sample_rate
1156
- )
1157
-
1158
- stt_result, emotion_result = await _async_lib.gather(
1159
- stt_task, emotion_task, return_exceptions=True
1160
- )
1161
-
1162
- # 清理 session
1163
- voice_service.clear_session(user_id)
1164
-
1165
- # 檢查結果
1166
- if isinstance(stt_result, Exception):
1167
- logger.error(f"❌ STT 失敗: {stt_result}")
1168
- await websocket.send_json({
1169
- "type": "error",
1170
- "message": f"語音轉文字失敗: {str(stt_result)}"
1171
- })
1172
- continue
1173
-
1174
- if not stt_result.get("success"):
1175
- await websocket.send_json({
1176
- "type": "error",
1177
- "message": stt_result.get("error", "STT 失敗")
1178
- })
1179
- continue
1180
-
1181
- # 提取轉錄文字和情緒標籤
1182
- transcription = stt_result.get("text", "")
1183
- emotion_label = emotion_result.get("label", "neutral") if emotion_result and not isinstance(emotion_result, Exception) else "neutral"
1184
-
1185
- logger.info(f"🎙️ STT: {transcription}")
1186
- logger.info(f"😊 情緒: {emotion_label}")
1187
-
1188
- # 發送 STT 最終結果給前端(讓用戶看到轉錄文字)
1189
- await websocket.send_json({
1190
- "type": "stt_final",
1191
- "text": transcription,
1192
- "emotion": emotion_label
1193
- })
1194
-
1195
- # 將轉錄文字和情緒標籤一起發送給 Agent
1196
- # 構造包含情緒資訊的訊息
1197
- enhanced_message = {
1198
- "text": transcription,
1199
- "emotion": emotion_label
1200
- }
1201
-
1202
- # 通知前端開始思考
1203
- await websocket.send_json({"type": "typing", "message": "thinking"})
1204
-
1205
- # 異步處理對話邏輯(複用現有的 chat 流程)
1206
- async def _process_voice_chat():
1207
- chat_id = message_data.get("chat_id")
1208
-
1209
- # 如果沒有 chat_id,創建新對話
1210
- if not chat_id:
1211
- try:
1212
- user_chats_result = await get_user_chats(user_id)
1213
- if user_chats_result["success"] and user_chats_result["chats"]:
1214
- latest_chat = user_chats_result["chats"][0]
1215
- chat_id = latest_chat["chat_id"]
1216
- else:
1217
- chat_title = f"語音對話 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
1218
- chat_result = await create_chat(user_id, chat_title)
1219
- if chat_result["success"]:
1220
- chat_id = chat_result["chat"]["chat_id"]
1221
- except Exception as e:
1222
- logger.error(f"創建對話失敗: {e}")
1223
- await websocket.send_json({"type": "error", "message": "無法創建對話"})
1224
- return
1225
-
1226
- # 保存用戶訊息(包含情緒標籤在訊息內容中)
1227
- # 注意: 目前 save_message_to_db 不支持 metadata 參數
1228
- # 情緒資訊已透過 enhanced_transcription 包含在訊息中
1229
- await save_message_to_db(user_id, chat_id, "user", transcription)
1230
-
1231
- # 將情緒資訊嵌入用戶訊息 (讓 AI 知道用戶的情緒狀態)
1232
- enhanced_transcription = transcription
1233
- if emotion_label and emotion_label != "neutral":
1234
- # 在訊息前添加情緒標籤提示
1235
- emotion_hints = {
1236
- "happy": "開心",
1237
- "sad": "悲傷",
1238
- "angry": "憤怒",
1239
- "fear": "恐懼",
1240
- "surprise": "驚訝"
1241
- }
1242
- emotion_cn = emotion_hints.get(emotion_label, emotion_label)
1243
- enhanced_transcription = f"[用戶情緒: {emotion_cn}] {transcription}"
1244
-
1245
- # 處理對話(透過 handle_message,自動處理 pipeline)
1246
- response = await handle_message(
1247
- enhanced_transcription,
1248
- user_id,
1249
- chat_id,
1250
- [] # messages 參數(會自動從數據庫載入)
1251
- )
1252
-
1253
- # 發送回應
1254
- if isinstance(response, PipelineResult):
1255
- message_text = response.text
1256
-
1257
- await websocket.send_json({
1258
- "type": "bot_message",
1259
- "message": message_text,
1260
- "timestamp": time.time(),
1261
- "tool_name": None,
1262
- "tool_data": None
1263
- })
1264
-
1265
- # 保存 Agent 回應(已在 handle_message 中保存)
1266
- elif isinstance(response, dict):
1267
- tool_name = response.get('tool_name')
1268
- tool_data = response.get('tool_data')
1269
- message_text = response.get('message', response.get('content', ''))
1270
-
1271
- await websocket.send_json({
1272
- "type": "bot_message",
1273
- "message": message_text,
1274
- "timestamp": time.time(),
1275
- "tool_name": tool_name,
1276
- "tool_data": tool_data
1277
- })
1278
-
1279
- # 保存 Agent 回應(已在 handle_message 中保存)
1280
- else:
1281
- # 字串回應
1282
- await websocket.send_json({
1283
- "type": "bot_message",
1284
- "message": str(response),
1285
- "timestamp": time.time()
1286
- })
1287
- await _process_voice_chat()
1288
-
1289
- except Exception as e:
1290
- logger.error(f"語音對話流程失敗: {e}")
1291
- await websocket.send_json({
1292
- "type": "error",
1293
- "message": f"語音對話處理失敗: {str(e)}"
1294
- })
1295
 
1296
  except json.JSONDecodeError:
1297
  await manager.send_message("消息格式錯誤,無法解析", user_id, "error")
@@ -1310,8 +1207,8 @@ async def websocket_endpoint_with_jwt(
1310
  # -----------------------------
1311
  # 消息處理與AI
1312
  # -----------------------------
1313
- async def handle_message(user_message, user_id, chat_id, messages, request_id: str = None):
1314
- logger.info(f"📥 handle_message: 收到訊息='{user_message}', user_id={user_id}")
1315
 
1316
  # 指令優先,避免進入管線造成不必要延遲
1317
  if user_message and user_message.startswith("/"):
@@ -1342,7 +1239,7 @@ async def handle_message(user_message, user_id, chat_id, messages, request_id: s
1342
  logger.info(f"🔧 Pipeline: 功能處理結果='{result}'")
1343
  return result
1344
 
1345
- async def _ai(messages_in, cid, model, rid, chat_id, use_care_mode=False, care_emotion=None, emotion_label=None):
1346
  env_context = {}
1347
  env_service = getattr(app.state, 'env_service', None)
1348
  if env_service:
@@ -1360,6 +1257,9 @@ async def handle_message(user_message, user_id, chat_id, messages, request_id: s
1360
  except Exception as e:
1361
  logger.debug(f"無法取得用戶名稱,使用預設值: {e}")
1362
 
 
 
 
1363
  # 兼容:如果傳入字串,視為 user_message;如果傳入 list,視為 messages
1364
  if isinstance(messages_in, str):
1365
  return await ai_service.generate_response_for_user(
@@ -1373,6 +1273,7 @@ async def handle_message(user_message, user_id, chat_id, messages, request_id: s
1373
  user_name=user_name,
1374
  emotion_label=emotion_label,
1375
  env_context=env_context,
 
1376
  )
1377
  else:
1378
  return await ai_service.generate_response_for_user(
@@ -1386,6 +1287,7 @@ async def handle_message(user_message, user_id, chat_id, messages, request_id: s
1386
  user_name=user_name,
1387
  emotion_label=emotion_label,
1388
  env_context=env_context,
 
1389
  )
1390
 
1391
  model = settings.OPENAI_MODEL
@@ -1400,8 +1302,8 @@ async def handle_message(user_message, user_id, chat_id, messages, request_id: s
1400
  feature_timeout=30.0, # 功能處理超時 (15 → 30,新聞摘要生成需要更長時間)
1401
  ai_timeout=20.0, # AI回應超時 (30 → 20)
1402
  )
1403
- logger.info(f"⚙️ 準備調用 ChatPipeline.process,user_message='{user_message}'")
1404
- res: PipelineResult = await pipeline.process(user_message, user_id=user_id, chat_id=chat_id, request_id=request_id)
1405
  logger.info(f"⚙️ ChatPipeline.process 完成,結果='{res.text}', is_fallback={res.is_fallback}, reason={res.reason}")
1406
 
1407
  # 檢查是否有工具元數據
 
757
  """VAD 偵測到語音段結束"""
758
  logger.debug(f"🎤 VAD Committed: {item_id}")
759
 
760
+ # 從前端獲取語言設定(支援:zh, en, id, ja, vi,或 auto 自動檢測)
761
+ language = message_data.get("language", "auto")
762
+ logger.info(f"🌐 語言設定: {language}")
763
+
764
  # 連線到 OpenAI Realtime API
765
  success = await realtime_stt.connect(
766
  on_transcript_delta=on_transcript_delta,
767
  on_transcript_done=on_transcript_done,
768
  on_vad_committed=on_vad_committed,
769
  model="gpt-4o-mini-transcribe",
770
+ language=language
771
  )
772
 
773
  if success:
774
+ # 儲存 Realtime STT 實例和語言設定到 client info
775
  client_info = manager.get_client_info(user_id) or {}
776
  client_info["realtime_stt"] = realtime_stt
777
+ client_info["language"] = language # 儲存語言設定
778
  manager.set_client_info(user_id, client_info)
779
 
780
  await websocket.send_json({
 
819
  audio_bytes = base64.b64decode(b64)
820
  await realtime_stt.send_audio_chunk(audio_bytes)
821
  logger.debug(f"🎤 轉發音頻到 OpenAI: {len(audio_bytes)} bytes")
822
+
823
+ # 同時儲存到本地緩衝(用於音頻情緒辨識)
824
+ audio_buffer = client_info.get("audio_buffer", b"")
825
+ audio_buffer += audio_bytes
826
+ client_info["audio_buffer"] = audio_buffer
827
+ manager.set_client_info(user_id, client_info)
828
+
829
  except Exception as e:
830
  logger.error(f"❌ 轉發音頻失敗: {e}")
831
 
 
1040
  client_info = manager.get_client_info(user_id) or {}
1041
  realtime_stt = client_info.get("realtime_stt")
1042
  transcription = client_info.get("realtime_transcript", "")
1043
+ audio_buffer = client_info.get("audio_buffer", b"")
1044
 
1045
  if realtime_stt:
1046
  logger.info(f"🔌 關閉即時轉錄連線,用戶 {user_id}")
 
1064
  if transcription:
1065
  logger.info(f"🤖 處理即時轉錄結果: {transcription}")
1066
 
1067
+ # 音頻情緒辨識(新增)
1068
+ audio_emotion = None
1069
+ if audio_buffer:
1070
+ try:
1071
+ from services.audio_emotion_service import audio_emotion_service
1072
+ logger.info(f"🎭 開始音頻情緒辨識,音頻大小: {len(audio_buffer)} bytes")
1073
+ audio_emotion = await audio_emotion_service.predict_from_bytes(audio_buffer)
1074
+
1075
+ if audio_emotion.get("success"):
1076
+ emotion_label = audio_emotion.get("emotion")
1077
+ confidence = audio_emotion.get("confidence", 0.0)
1078
+ logger.info(f"✅ 音頻情緒: {emotion_label} (置信度: {confidence:.4f})")
1079
+
1080
+ # 發送音頻情緒給前端
1081
+ await websocket.send_json({
1082
+ "type": "audio_emotion_detected",
1083
+ "emotion": emotion_label,
1084
+ "confidence": confidence,
1085
+ "all_emotions": audio_emotion.get("all_emotions", {}),
1086
+ "source": "audio"
1087
+ })
1088
+ else:
1089
+ logger.warning(f"⚠️ 音頻情緒辨識失敗: {audio_emotion.get('error')}")
1090
+ audio_emotion = None
1091
+ except Exception as e:
1092
+ logger.error(f"❌ 音頻情緒辨識異常: {e}")
1093
+ audio_emotion = None
1094
+ finally:
1095
+ # 清理音頻緩衝
1096
+ client_info.pop("audio_buffer", None)
1097
+ manager.set_client_info(user_id, client_info)
1098
+
1099
  # 通知前端開始思考
1100
  await websocket.send_json({"type": "typing", "message": "thinking"})
1101
 
 
1123
  # 保存用戶訊息
1124
  await save_message_to_db(user_id, chat_id, "user", transcription)
1125
 
1126
+ # 取得語言設定
1127
+ language = client_info.get("language", "auto")
1128
+
1129
  # 處理對話(透過 handle_message,自動處理 pipeline)
1130
  response = await handle_message(
1131
  transcription,
1132
  user_id,
1133
  chat_id,
1134
+ [], # messages 參數(會自動從數據庫載入)
1135
+ audio_emotion=audio_emotion, # 傳遞音頻情緒
1136
+ language=language # 傳遞語言設定(新增)
1137
  )
1138
 
1139
  # 發送回應
1140
+ # 從 PipelineResult 提取情緒
1141
+ emotion = None
1142
+ care_mode = False
1143
  if isinstance(response, PipelineResult):
1144
  message_text = response.text
1145
+ if response.meta:
1146
+ emotion = response.meta.get('emotion')
1147
+ care_mode = response.meta.get('care_mode', False)
1148
 
1149
  await websocket.send_json({
1150
  "type": "bot_message",
1151
  "message": message_text,
1152
  "timestamp": time.time(),
1153
  "tool_name": None,
1154
+ "tool_data": None,
1155
+ "emotion": emotion,
1156
+ "care_mode": care_mode
1157
  })
1158
  elif isinstance(response, dict):
1159
  tool_name = response.get('tool_name')
1160
  tool_data = response.get('tool_data')
1161
+ emotion = response.get('emotion')
1162
  message_text = response.get('message', response.get('content', ''))
1163
 
1164
  await websocket.send_json({
 
1166
  "message": message_text,
1167
  "timestamp": time.time(),
1168
  "tool_name": tool_name,
1169
+ "tool_data": tool_data,
1170
+ "emotion": emotion
1171
  })
1172
  else:
1173
  # 字串回應
1174
  await websocket.send_json({
1175
  "type": "bot_message",
1176
  "message": str(response),
1177
+ "timestamp": time.time(),
1178
+ "emotion": None
1179
  })
1180
 
1181
  await _process_realtime_chat()
 
1189
  "message": f"關閉即時轉錄失敗: {str(e)}"
1190
  })
1191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1192
 
1193
  except json.JSONDecodeError:
1194
  await manager.send_message("消息格式錯誤,無法解析", user_id, "error")
 
1207
  # -----------------------------
1208
  # 消息處理與AI
1209
  # -----------------------------
1210
+ async def handle_message(user_message, user_id, chat_id, messages, request_id: str = None, audio_emotion: dict = None, language: str = None):
1211
+ logger.info(f"📥 handle_message: 收到訊息='{user_message}', user_id={user_id}, audio_emotion={audio_emotion}, language={language}")
1212
 
1213
  # 指令優先,避免進入管線造成不必要延遲
1214
  if user_message and user_message.startswith("/"):
 
1239
  logger.info(f"🔧 Pipeline: 功能處理結果='{result}'")
1240
  return result
1241
 
1242
+ async def _ai(messages_in, cid, model, rid, chat_id, use_care_mode=False, care_emotion=None, emotion_label=None, language=None):
1243
  env_context = {}
1244
  env_service = getattr(app.state, 'env_service', None)
1245
  if env_service:
 
1257
  except Exception as e:
1258
  logger.debug(f"無法取得用戶名稱,使用預設值: {e}")
1259
 
1260
+ # 使用傳入的 language 參數(優先)或閉包捕獲的外部變數
1261
+ lang = language if language is not None else globals().get('language', 'zh')
1262
+
1263
  # 兼容:如果傳入字串,視為 user_message;如果傳入 list,視為 messages
1264
  if isinstance(messages_in, str):
1265
  return await ai_service.generate_response_for_user(
 
1273
  user_name=user_name,
1274
  emotion_label=emotion_label,
1275
  env_context=env_context,
1276
+ language=lang,
1277
  )
1278
  else:
1279
  return await ai_service.generate_response_for_user(
 
1287
  user_name=user_name,
1288
  emotion_label=emotion_label,
1289
  env_context=env_context,
1290
+ language=lang,
1291
  )
1292
 
1293
  model = settings.OPENAI_MODEL
 
1302
  feature_timeout=30.0, # 功能處理超時 (15 → 30,新聞摘要生成需要更長時間)
1303
  ai_timeout=20.0, # AI回應超時 (30 → 20)
1304
  )
1305
+ logger.info(f"⚙️ 準備調用 ChatPipeline.process,user_message='{user_message}', audio_emotion={audio_emotion}, language={language}")
1306
+ res: PipelineResult = await pipeline.process(user_message, user_id=user_id, chat_id=chat_id, request_id=request_id, audio_emotion=audio_emotion, language=language)
1307
  logger.info(f"⚙️ ChatPipeline.process 完成,結果='{res.text}', is_fallback={res.is_fallback}, reason={res.reason}")
1308
 
1309
  # 檢查是否有工具元數據
core/intent_detector.py CHANGED
@@ -159,23 +159,47 @@ class IntentDetector:
159
 
160
  注意:不再描述每個工具,工具定義由 tools 參數傳遞
161
  """
162
- return """你是一個智能助手,根據用戶需求選擇合適的工具。
163
 
164
- 規則
165
- 1. 如果用戶需求用工具解決,選擇最適合的工具
166
- 2. 如果是一般天或問候選擇任何工具
167
  3. 工具參數盡量從用戶消息中提取,無法確定的使用合理預設值
168
 
169
- 特殊處理:
170
- - 天氣查詢:城市名稱使用英文(台北→Taipei, 東京→Tokyo)
171
- - 匯率查詢:貨幣使用 ISO 4217 代碼(美元→USD, 台幣→TWD)
172
- - 公車查詢:route_name 必須是路線號碼(如 307、紅30),不是目的地名稱
173
- - 火車查詢:「往XX」表示 destination_station,不是 origin_station
174
- - 位置查詢:「我在哪」使用 reverse_geocode,不需要參數
175
- - YouBike 查詢:任何提到 YouBike/Ubike/微笑單車 的請求使用 tdx_youbike
176
 
177
- 情緒判斷
178
- 根據用戶消息的語氣判斷情緒,在回應中包含 emotion 欄位
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  - neutral: 平靜、中性
180
  - happy: 開心、興奮
181
  - sad: 難過、沮喪
 
159
 
160
  注意:不再描述每個工具,工具定義由 tools 參數傳遞
161
  """
162
+ return """你是一個多語言智能助手,根據用戶需求選擇合適的工具。支援中文、英文、日文、印尼文、越南文。
163
 
164
+ 【核心規則
165
+ 1. 用戶詢問任何可用工具解決的需求時必須選擇對應工具
166
+ 2. 只有純粹的閒問候、情感表達才不選擇工具
167
  3. 工具參數盡量從用戶消息中提取,無法確定的使用合理預設值
168
 
169
+ 【多語言意圖識別】
170
+ 無論用戶使用什麼語言,都要識別以下意圖並選擇對應工具:
 
 
 
 
 
171
 
172
+ 天氣查詢(weather_query)
173
+ - 中文天氣、氣溫、會下雨嗎、今天熱嗎
174
+ - 英文:weather, temperature, rain, hot today, forecast
175
+ - 日文:天気、気温、雨、暑い
176
+ - 印尼文:cuaca, suhu, hujan
177
+ - 越南文:thời tiết, nhiệt độ, mưa
178
+
179
+ 匯率查詢(exchange_rate):
180
+ - 中文:匯率、換算、多少錢
181
+ - 英文:exchange rate, convert, currency
182
+ - 日文:為替、両替
183
+ - 印尼文:kurs, tukar
184
+ - 越南文:tỷ giá, đổi tiền
185
+
186
+ 新聞查詢(news_search):
187
+ - 中文:新聞、頭條、最新消息
188
+ - 英文:news, headlines, latest
189
+ - 日文:ニュース、最新
190
+ - 印尼文:berita, terbaru
191
+ - 越南文:tin tức, mới nhất
192
+
193
+ 【參數處理】
194
+ - 天氣查詢:城市名稱使用英文(台北→Taipei, 東京→Tokyo, Jakarta, Hanoi)
195
+ - 匯率查詢:貨幣使用 ISO 4217 代碼(USD, TWD, JPY, IDR, VND)
196
+ - 公車查詢:route_name 必須是路線號碼(如 307、紅30)
197
+ - 火車查詢:「往XX」表示 destination_station
198
+ - 位置查詢:「我在哪」「where am I」使用 reverse_geocode
199
+ - YouBike 查詢:YouBike/Ubike/微笑單車 使用 tdx_youbike
200
+
201
+ 【情緒判斷】
202
+ 根據用戶消息的語氣判斷情緒:
203
  - neutral: 平靜、中性
204
  - happy: 開心、興奮
205
  - sad: 難過、沮喪
core/pipeline.py CHANGED
@@ -47,6 +47,230 @@ class ChatPipeline:
47
  self._feature_timeout = feature_timeout
48
  self._ai_timeout = ai_timeout
49
  self._model = model
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
  async def _with_timeout(self, coro: Awaitable[Any], timeout: float, reason: str) -> Any:
52
  try:
@@ -72,10 +296,17 @@ class ChatPipeline:
72
  user_id: Optional[str] = None,
73
  chat_id: Optional[str] = None,
74
  request_id: Optional[str] = None,
 
 
75
  ) -> PipelineResult:
76
  if not user_message or not user_message.strip():
77
  return PipelineResult(text="我沒有收到您的消息,請重新輸入。", is_fallback=True, reason="empty")
78
 
 
 
 
 
 
79
  # 0) 先進行意圖偵測以提取情緒(需要在關懷模式檢查前執行)
80
  detect_res = await self._with_timeout(
81
  self._intent_detector(user_message), self._detect_timeout, reason="detect"
@@ -84,10 +315,25 @@ class ChatPipeline:
84
  return detect_res
85
  has_feature, intent_data = detect_res
86
 
87
- # 提取情緒
88
- emotion = intent_data.get("emotion", "neutral") if intent_data else "neutral"
89
- emotion_value = emotion or "neutral"
90
- logger.info(f"😊 用戶情緒: {emotion}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
  # 1) 檢查是否在關懷模式
93
  if user_id and EmotionCareManager.is_in_care_mode(user_id, chat_id):
@@ -169,6 +415,14 @@ class ChatPipeline:
169
  tool_data = feat_res.get('tool_data')
170
  if not text:
171
  return PipelineResult(text="抱歉,功能處理沒有產出結果。", is_fallback=True, reason="feature-empty")
 
 
 
 
 
 
 
 
172
  # 返回帶有工具元數據的結果(包含情緒)
173
  meta_dict = {}
174
  if tool_name:
@@ -187,6 +441,11 @@ class ChatPipeline:
187
  text = str(feat_res or "").strip()
188
  if not text:
189
  return PipelineResult(text="抱歉,功能處理沒有產出結果。", is_fallback=True, reason="feature-empty")
 
 
 
 
 
190
  return PipelineResult(
191
  text=text,
192
  is_fallback=False,
@@ -203,6 +462,7 @@ class ChatPipeline:
203
  request_id,
204
  chat_id,
205
  emotion_label=emotion_value,
 
206
  ),
207
  self._ai_timeout,
208
  reason="ai",
 
47
  self._feature_timeout = feature_timeout
48
  self._ai_timeout = ai_timeout
49
  self._model = model
50
+
51
+ # 語言名稱映射
52
+ self._language_names = {
53
+ "zh": "繁體中文",
54
+ "en": "English",
55
+ "ja": "日本語",
56
+ "ko": "한국어",
57
+ "id": "Bahasa Indonesia",
58
+ "vi": "Tiếng Việt",
59
+ }
60
+
61
+ def _detect_language(self, text: str) -> str:
62
+ """
63
+ 簡單的語言檢測(基於字符範圍)
64
+
65
+ Args:
66
+ text: 輸入文字
67
+
68
+ Returns:
69
+ 語言代碼(zh, en, ja, ko, id, vi)
70
+ """
71
+ if not text:
72
+ return "zh"
73
+
74
+ # 統計各語言字符數量
75
+ korean_count = 0
76
+ japanese_count = 0
77
+ chinese_count = 0
78
+ latin_count = 0
79
+ vietnamese_count = 0
80
+
81
+ vietnamese_chars = set("àáảãạăằắẳẵặâầấẩẫậèéẻẽẹêềếểễệìíỉĩịòóỏõọôồốổỗộơờớởỡợùúủũụưừứửữựỳýỷỹỵđ")
82
+
83
+ for char in text:
84
+ code = ord(char)
85
+ # 韓文
86
+ if 0xAC00 <= code <= 0xD7AF or 0x1100 <= code <= 0x11FF:
87
+ korean_count += 1
88
+ # 日文假名
89
+ elif 0x3040 <= code <= 0x309F or 0x30A0 <= code <= 0x30FF:
90
+ japanese_count += 1
91
+ # 中文
92
+ elif 0x4E00 <= code <= 0x9FFF:
93
+ chinese_count += 1
94
+ # 拉丁字母
95
+ elif 0x0041 <= code <= 0x007A:
96
+ latin_count += 1
97
+ # 越南文特殊字符
98
+ if char.lower() in vietnamese_chars:
99
+ vietnamese_count += 1
100
+
101
+ # 判斷主要語言
102
+ if korean_count > 0:
103
+ return "ko"
104
+ if japanese_count > chinese_count and japanese_count > 0:
105
+ return "ja"
106
+ if vietnamese_count > 0:
107
+ return "vi"
108
+ if chinese_count > latin_count and chinese_count > 0:
109
+ return "zh"
110
+ if latin_count > 0:
111
+ # 可能是英文或印尼文,預設英文
112
+ return "en"
113
+
114
+ return "zh"
115
+
116
+ async def _translate_tool_data(self, tool_data: Dict[str, Any], target_language: str) -> Dict[str, Any]:
117
+ """
118
+ 翻譯工具卡片中的文字欄位
119
+
120
+ Args:
121
+ tool_data: 工具資料字典
122
+ target_language: 目標語言代碼
123
+
124
+ Returns:
125
+ 翻譯後的工具資料
126
+ """
127
+ if not tool_data or target_language == "zh":
128
+ return tool_data
129
+
130
+ try:
131
+ import copy
132
+ translated_data = copy.deepcopy(tool_data)
133
+
134
+ # 需要翻譯的欄位名稱(天氣、新聞等工具的顯示欄位)
135
+ translatable_keys = {
136
+ "description", "main", "name", "title", "summary",
137
+ "content", "message", "text", "label", "status"
138
+ }
139
+
140
+ # 收集需要翻譯的文字欄位
141
+ texts_to_translate = []
142
+ text_paths = [] # 記錄路徑以便回填
143
+
144
+ def collect_texts(obj, path="", parent_key=""):
145
+ """遞迴收集需要翻譯的文字"""
146
+ if isinstance(obj, dict):
147
+ for key, value in obj.items():
148
+ new_path = f"{path}.{key}" if path else key
149
+ # 跳過純技術欄位
150
+ if key in ("id", "url", "link", "lat", "lon", "timestamp", "code", "icon", "base", "cod"):
151
+ continue
152
+ collect_texts(value, new_path, key)
153
+ elif isinstance(obj, list):
154
+ for i, item in enumerate(obj):
155
+ collect_texts(item, f"{path}[{i}]", parent_key)
156
+ elif isinstance(obj, str) and len(obj) > 1:
157
+ # 翻譯條件:
158
+ # 1. 欄位名稱在可翻譯列表中
159
+ # 2. 或字串包含中文
160
+ # 3. 或字串是純英文描述(非數字、非代碼)
161
+ should_translate = (
162
+ parent_key.lower() in translatable_keys or
163
+ any('\u4e00' <= c <= '\u9fff' for c in obj) or
164
+ (obj.isalpha() or ' ' in obj) and len(obj) > 2
165
+ )
166
+ if should_translate:
167
+ texts_to_translate.append(obj)
168
+ text_paths.append(path)
169
+
170
+ collect_texts(translated_data)
171
+
172
+ if not texts_to_translate:
173
+ return tool_data
174
+
175
+ # 批量翻譯
176
+ import services.ai_service as ai_service
177
+ lang_name = self._language_names.get(target_language, target_language)
178
+
179
+ combined_text = "\n---\n".join(texts_to_translate)
180
+ messages = [
181
+ {
182
+ "role": "system",
183
+ "content": f"將以下內容翻譯成 {lang_name},保持格式和表情符號。每段用 '---' 分隔,輸出也用 '---' 分隔。只輸出翻譯結果。"
184
+ },
185
+ {"role": "user", "content": combined_text}
186
+ ]
187
+
188
+ translated = await ai_service.generate_response_async(
189
+ messages=messages,
190
+ model="gpt-5-nano",
191
+ reasoning_effort="minimal",
192
+ max_tokens=800, # 工具卡片翻譯:實際輸出限制 800 tokens
193
+ )
194
+
195
+ if translated:
196
+ translated_parts = translated.strip().split("---")
197
+ translated_parts = [p.strip() for p in translated_parts if p.strip()]
198
+
199
+ # 回填翻譯結果
200
+ def set_value(obj, path, value):
201
+ """根據路徑設置值"""
202
+ parts = path.replace("]", "").replace("[", ".").split(".")
203
+ for part in parts[:-1]:
204
+ if part.isdigit():
205
+ obj = obj[int(part)]
206
+ else:
207
+ obj = obj[part]
208
+ last = parts[-1]
209
+ if last.isdigit():
210
+ obj[int(last)] = value
211
+ else:
212
+ obj[last] = value
213
+
214
+ for i, path in enumerate(text_paths):
215
+ if i < len(translated_parts):
216
+ try:
217
+ set_value(translated_data, path, translated_parts[i])
218
+ except Exception:
219
+ pass
220
+
221
+ logger.info(f"🌐 工具卡片已翻譯: {len(texts_to_translate)} 個欄位")
222
+ return translated_data
223
+
224
+ except Exception as e:
225
+ logger.warning(f"⚠️ 工具卡片翻譯失敗: {e}")
226
+ return tool_data
227
+
228
+ async def _translate_tool_response(self, text: str, target_language: str) -> str:
229
+ """
230
+ 翻譯工具回應到目標語言
231
+
232
+ Args:
233
+ text: 原始文字(中文)
234
+ target_language: 目標語言代碼(en, ja, ko, id, vi)
235
+
236
+ Returns:
237
+ 翻譯後的文字
238
+ """
239
+ if not text or target_language == "zh":
240
+ return text
241
+
242
+ try:
243
+ import services.ai_service as ai_service
244
+
245
+ lang_name = self._language_names.get(target_language, target_language)
246
+
247
+ messages = [
248
+ {
249
+ "role": "system",
250
+ "content": f"你是一個翻譯助手。將以下內容翻譯成 {lang_name},保持格式、表情符號和數字不變。只輸出翻譯結果,不要加任何解釋。"
251
+ },
252
+ {
253
+ "role": "user",
254
+ "content": text
255
+ }
256
+ ]
257
+
258
+ translated = await ai_service.generate_response_async(
259
+ messages=messages,
260
+ model="gpt-5-nano",
261
+ reasoning_effort="minimal",
262
+ max_tokens=500, # 工具回應翻譯:實際輸出限制 500 tokens
263
+ )
264
+
265
+ if translated and translated.strip():
266
+ logger.info(f"🌐 工具回應已翻譯: {target_language}")
267
+ return translated.strip()
268
+
269
+ return text
270
+
271
+ except Exception as e:
272
+ logger.warning(f"⚠️ 翻譯失敗,使用原文: {e}")
273
+ return text
274
 
275
  async def _with_timeout(self, coro: Awaitable[Any], timeout: float, reason: str) -> Any:
276
  try:
 
296
  user_id: Optional[str] = None,
297
  chat_id: Optional[str] = None,
298
  request_id: Optional[str] = None,
299
+ audio_emotion: Optional[Dict[str, Any]] = None,
300
+ language: Optional[str] = None,
301
  ) -> PipelineResult:
302
  if not user_message or not user_message.strip():
303
  return PipelineResult(text="我沒有收到您的消息,請重新輸入。", is_fallback=True, reason="empty")
304
 
305
+ # 自動檢測語言(如果沒有傳入)
306
+ if not language:
307
+ language = self._detect_language(user_message)
308
+ logger.info(f"🌐 自動檢測語言: {language}")
309
+
310
  # 0) 先進行意圖偵測以提取情緒(需要在關懷模式檢查前執行)
311
  detect_res = await self._with_timeout(
312
  self._intent_detector(user_message), self._detect_timeout, reason="detect"
 
315
  return detect_res
316
  has_feature, intent_data = detect_res
317
 
318
+ # 提取情緒(雙軌制:音頻情緒優先,文字情緒輔助)
319
+ text_emotion = intent_data.get("emotion", "neutral") if intent_data else "neutral"
320
+
321
+ # 情緒融合邏輯
322
+ if audio_emotion and audio_emotion.get("success"):
323
+ audio_emotion_label = audio_emotion.get("emotion", "neutral")
324
+ audio_confidence = audio_emotion.get("confidence", 0.0)
325
+
326
+ # 優先使用音頻情緒(置信度 >= 0.5)
327
+ if audio_confidence >= 0.5:
328
+ emotion_value = audio_emotion_label
329
+ logger.info(f"🎭 使用音頻情緒: {emotion_value} (置信度: {audio_confidence:.4f})")
330
+ logger.info(f"📝 文字情緒: {text_emotion} (輔助)")
331
+ else:
332
+ emotion_value = text_emotion
333
+ logger.info(f"📝 使用文字情緒: {emotion_value} (音頻置信度過低: {audio_confidence:.4f})")
334
+ else:
335
+ emotion_value = text_emotion
336
+ logger.info(f"📝 使用文字情緒: {emotion_value} (無音頻情緒)")
337
 
338
  # 1) 檢查是否在關懷模式
339
  if user_id and EmotionCareManager.is_in_care_mode(user_id, chat_id):
 
415
  tool_data = feat_res.get('tool_data')
416
  if not text:
417
  return PipelineResult(text="抱歉,功能處理沒有產出結果。", is_fallback=True, reason="feature-empty")
418
+
419
+ # 如果語言不是中文,翻譯工具回應和工具卡片
420
+ if language and language != "zh":
421
+ text = await self._translate_tool_response(text, language)
422
+ # 翻譯工具卡片中的文字欄位
423
+ if tool_data:
424
+ tool_data = await self._translate_tool_data(tool_data, language)
425
+
426
  # 返回帶有工具元數據的結果(包含情緒)
427
  meta_dict = {}
428
  if tool_name:
 
441
  text = str(feat_res or "").strip()
442
  if not text:
443
  return PipelineResult(text="抱歉,功能處理沒有產出結果。", is_fallback=True, reason="feature-empty")
444
+
445
+ # 如果語言不是中文,翻譯工具回應
446
+ if language and language != "zh":
447
+ text = await self._translate_tool_response(text, language)
448
+
449
  return PipelineResult(
450
  text=text,
451
  is_fallback=False,
 
462
  request_id,
463
  chat_id,
464
  emotion_label=emotion_value,
465
+ language=language,
466
  ),
467
  self._ai_timeout,
468
  reason="ai",
features/mcp/agent_bridge.py CHANGED
@@ -218,8 +218,8 @@ class MCPAgentBridge:
218
  # 將 MCP Server 的工具註冊到 tool_registry
219
  self._sync_tools_to_registry()
220
 
221
- # 2025 最佳實踐:啟動時預熱熱門查詢快取
222
- await self._preheat_cache()
223
 
224
  def _sync_tools_to_registry(self) -> int:
225
  """
@@ -632,17 +632,24 @@ class MCPAgentBridge:
632
  注意:不再描述每個工具,工具定義由 tools 參數傳遞
633
  只處理特殊規則和情緒判斷
634
  """
635
- return """你是一個智能助手,根據用戶需求選擇合適的工具。
636
 
637
- 規則:
638
- 1. 如果用戶需求可以用工具解決,選擇最適合的工具
639
- 2. 如果是一般聊天或問候,不要選擇任何工具
640
- 3. 工具參數盡量從用戶消息中提取,無法確定的使用合理預設值
 
 
 
 
 
 
 
 
641
 
642
  【重要】語言使用規範:
643
  - 調用工具時:所有參數必須使用英文(城市名、國家名、貨幣代碼等)
644
- - 回覆用戶時必須使繁體中文
645
- - 範例:用戶說「台北天氣」→ 參數 {"city": "Taipei"},回覆「台北目前...」
646
 
647
  參數語言轉換規則:
648
  - 城市名稱:台北→Taipei, 新北→NewTaipei, 桃園→Taoyuan, 台中→Taichung, 台南→Tainan, 高雄→Kaohsiung, 新竹→Hsinchu
@@ -1244,35 +1251,4 @@ YouBike 查詢(重要!參數提取規則):
1244
  # 保持與舊 FeatureRouter 相同的邏輯
1245
  return response
1246
 
1247
- async def _preheat_cache(self):
1248
- """
1249
- 快取預熱(2025 最佳實踐)
1250
-
1251
- 啟動時預先載入熱門查詢的意圖檢測結果,減少冷啟動延遲
1252
- 預期提升首次查詢命中率 40-60%
1253
- """
1254
- logger.info("🔥 開始快取預熱...")
1255
-
1256
- # 定義熱門查詢(根據使用統計調整)
1257
- hot_queries = [
1258
- "台北天氣",
1259
- "天氣如何",
1260
- "美元匯率",
1261
- "今日新聞",
1262
- "科技新聞",
1263
- "我的心率",
1264
- "今天步數",
1265
- ]
1266
 
1267
- preheated_count = 0
1268
- for query in hot_queries:
1269
- try:
1270
- # 預先執行意圖檢測,寫入快取
1271
- await self.detect_intent(query)
1272
- preheated_count += 1
1273
- logger.debug(f"✓ 預熱快取: '{query}'")
1274
- except Exception as e:
1275
- logger.warning(f"⚠️ 預熱快取失敗 '{query}': {e}")
1276
-
1277
- logger.info(f"🔥 快取預熱完成,成功預載 {preheated_count}/{len(hot_queries)} 條熱門查詢")
1278
- logger.info(f"💾 當前快取大小: {len(self._intent_cache)} 條")
 
218
  # 將 MCP Server 的工具註冊到 tool_registry
219
  self._sync_tools_to_registry()
220
 
221
+ # 快取預熱已移除:啟動時連續調用 7 次 GPT API 增加延遲和成本
222
+ # 實際使用中快取會自然累積,無需預熱
223
 
224
  def _sync_tools_to_registry(self) -> int:
225
  """
 
632
  注意:不再描述每個工具,工具定義由 tools 參數傳遞
633
  只處理特殊規則和情緒判斷
634
  """
635
+ return """You are an intelligent assistant that selects appropriate tools based on user needs.
636
 
637
+ Rules:
638
+ 1. If the user's request can be solved with a tool, select the most appropriate tool
639
+ 2. Only skip tool selection for pure greetings (hi, hello) or meta questions (what can you do)
640
+ 3. Extract tool parameters from user message, use reasonable defaults if uncertain
641
+ 4. User may speak in ANY language (Chinese, English, Korean, Japanese, Indonesian, Vietnamese, etc.) - always try to match their intent to available tools
642
+
643
+ 【IMPORTANT】Weather/News/Exchange queries in ANY language should trigger tools:
644
+ - "How is the weather today?" → weather_query
645
+ - "오늘 날씨 어때?" → weather_query
646
+ - "今天天氣如何?" → weather_query
647
+ - "What's the USD to JPY rate?" → exchange_query
648
+ - "最新新聞" / "latest news" → news_query
649
 
650
  【重要】語言使用規範:
651
  - 調用工具時:所有參數必須使用英文(城市名、國家名、貨幣代碼等)
652
+ - 範例:用戶說「台北天氣」或 "Taipei weather" → 參數 {"city": "Taipei"}
 
653
 
654
  參數語言轉換規則:
655
  - 城市名稱:台北→Taipei, 新北→NewTaipei, 桃園→Taoyuan, 台中→Taichung, 台南→Tainan, 高雄→Kaohsiung, 新竹→Hsinchu
 
1251
  # 保持與舊 FeatureRouter 相同的邏輯
1252
  return response
1253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1254
 
 
 
 
 
 
 
 
 
 
 
 
 
features/mcp/tools/directions_tool.py CHANGED
@@ -22,7 +22,7 @@ ORS_API_KEY = os.getenv("OPENROUTESERVICE_API_KEY", "")
22
 
23
  class DirectionsTool(MCPTool):
24
  NAME = "directions"
25
- DESCRIPTION = "規劃兩點之間的路線(walk/drive/cycle),返回距離、時間與 polyline"
26
  CATEGORY = "地理定位"
27
  TAGS = ["route", "navigation", "directions"]
28
  KEYWORDS = ["導航", "路線", "怎麼去", "怎麼走", "規劃", "開車", "步行", "騎車"]
 
22
 
23
  class DirectionsTool(MCPTool):
24
  NAME = "directions"
25
+ DESCRIPTION = "Plan routes between two points (walk/drive/cycle), returns distance, duration, and polyline"
26
  CATEGORY = "地理定位"
27
  TAGS = ["route", "navigation", "directions"]
28
  KEYWORDS = ["導航", "路線", "怎麼去", "怎麼走", "規劃", "開車", "步行", "騎車"]
features/mcp/tools/exchange_tool.py CHANGED
@@ -28,7 +28,7 @@ class ExchangeTool(MCPTool):
28
  """匯率查詢 MCP 工具"""
29
 
30
  NAME = "exchange_query"
31
- DESCRIPTION = "查詢即時匯率(支援主要貨幣兌換)"
32
  CATEGORY = "生活資訊"
33
  TAGS = ["exchange", "匯率", "貨幣"]
34
  KEYWORDS = ["匯率", "美元", "台幣", "exchange", "USD", "TWD", "貨幣", "換算"]
 
28
  """匯率查詢 MCP 工具"""
29
 
30
  NAME = "exchange_query"
31
+ DESCRIPTION = "Query real-time exchange rates between major currencies"
32
  CATEGORY = "生活資訊"
33
  TAGS = ["exchange", "匯率", "貨幣"]
34
  KEYWORDS = ["匯率", "美元", "台幣", "exchange", "USD", "TWD", "貨幣", "換算"]
features/mcp/tools/geocode_tool.py CHANGED
@@ -17,7 +17,7 @@ logger = logging.getLogger("mcp.tools.geocode")
17
 
18
  class ReverseGeocodeTool(MCPTool):
19
  NAME = "reverse_geocode"
20
- DESCRIPTION = "以經緯度反查城市/行政區(優先使用快取)"
21
  CATEGORY = "地理定位"
22
  TAGS = ["geocode", "reverse", "city"]
23
  KEYWORDS = ["座標", "經緯度", "反查", "地址", "我在哪"]
 
17
 
18
  class ReverseGeocodeTool(MCPTool):
19
  NAME = "reverse_geocode"
20
+ DESCRIPTION = "Convert coordinates (latitude/longitude) to city/district names (uses cache when available)"
21
  CATEGORY = "地理定位"
22
  TAGS = ["geocode", "reverse", "city"]
23
  KEYWORDS = ["座標", "經緯度", "反查", "地址", "我在哪"]
features/mcp/tools/geocoding_tool.py CHANGED
@@ -17,7 +17,7 @@ logger = logging.getLogger("mcp.tools.geocoding")
17
 
18
  class ForwardGeocodeTool(MCPTool):
19
  NAME = "forward_geocode"
20
- DESCRIPTION = "將地點名稱(如「銘傳大學」「桃園火車站」)轉換為經緯度座標"
21
  CATEGORY = "地理定位"
22
  TAGS = ["geocode", "forward", "地點", "座標"]
23
  KEYWORDS = ["地點", "位置", "座標", "在哪裡", "地址查詢"]
 
17
 
18
  class ForwardGeocodeTool(MCPTool):
19
  NAME = "forward_geocode"
20
+ DESCRIPTION = "Convert place names (e.g., 'Ming Chuan University', 'Taoyuan Train Station') to coordinates (latitude/longitude)"
21
  CATEGORY = "地理定位"
22
  TAGS = ["geocode", "forward", "地點", "座標"]
23
  KEYWORDS = ["地點", "位置", "座標", "在哪裡", "地址查詢"]
features/mcp/tools/healthkit_tool.py CHANGED
@@ -20,7 +20,7 @@ class HealthKitTool(MCPTool):
20
  """HealthKit 健康數據查詢工具 - 從數據庫讀取"""
21
 
22
  NAME = "healthkit_query"
23
- DESCRIPTION = "查詢用戶的健康數據,包括心率、步數、血氧、呼吸頻率等(數據由iOS設備自動同步到Firestore"
24
  CATEGORY = "健康數據"
25
  TAGS = ["health", "fitness", "database", "firestore"]
26
  KEYWORDS = ["健康", "心率", "步數", "血氧", "睡眠", "health", "運動", "卡路里", "呼吸"]
 
20
  """HealthKit 健康數據查詢工具 - 從數據庫讀取"""
21
 
22
  NAME = "healthkit_query"
23
+ DESCRIPTION = "Query user's health data including heart rate, steps, oxygen level, respiratory rate, etc. (data synced from iOS devices to Firestore)"
24
  CATEGORY = "健康數據"
25
  TAGS = ["health", "fitness", "database", "firestore"]
26
  KEYWORDS = ["健康", "心率", "步數", "血氧", "睡眠", "health", "運動", "卡路里", "呼吸"]
features/mcp/tools/news_tool.py CHANGED
@@ -31,7 +31,7 @@ class NewsTool(MCPTool):
31
  """新聞查詢 MCP 工具 - 使用 NewsData.io(更好的台灣與繁中新聞支援)"""
32
 
33
  NAME = "news_query"
34
- DESCRIPTION = "查詢最新新聞(可指定類別、語言、數量)"
35
  CATEGORY = "生活資訊"
36
  TAGS = ["news", "新聞", "資訊"]
37
  KEYWORDS = ["新聞", "消息", "報導", "news", "頭條", "時事"]
 
31
  """新聞查詢 MCP 工具 - 使用 NewsData.io(更好的台灣與繁中新聞支援)"""
32
 
33
  NAME = "news_query"
34
+ DESCRIPTION = "Query latest news articles (can specify category, language, and quantity)"
35
  CATEGORY = "生活資訊"
36
  TAGS = ["news", "新聞", "資訊"]
37
  KEYWORDS = ["新聞", "消息", "報導", "news", "頭條", "時事"]
features/mcp/tools/tdx_bus_arrival.py CHANGED
@@ -24,7 +24,7 @@ class TDXBusArrivalTool(MCPTool):
24
  """TDX 公車即時到站查詢"""
25
 
26
  NAME = "tdx_bus_arrival"
27
- DESCRIPTION = "查詢公車即時到站時間。適用於:1) 已知路線號碼(如307、紅30)的到站查詢;2) 查詢附近公車站。不適用於路線規劃(如「從A到B的公車」應用directions)。"
28
  CATEGORY = "道路運輸"
29
  TAGS = ["tdx", "公車", "即時到站", "公共運輸"]
30
  KEYWORDS = ["公車", "巴士", "bus", "到站", "即時", "幾分鐘", "公車站", "等公車", "路線號碼"]
 
24
  """TDX 公車即時到站查詢"""
25
 
26
  NAME = "tdx_bus_arrival"
27
+ DESCRIPTION = "Query real-time bus arrival times. Use for: 1) Known route numbers (e.g., 307, Red 30); 2) Nearby bus stops. Not for route planning (use 'directions' instead)."
28
  CATEGORY = "道路運輸"
29
  TAGS = ["tdx", "公車", "即時到站", "公共運輸"]
30
  KEYWORDS = ["公車", "巴士", "bus", "到站", "即時", "幾分鐘", "公車站", "等公車", "路線號碼"]
features/mcp/tools/tdx_metro.py CHANGED
@@ -17,7 +17,7 @@ class TDXMetroTool(MCPTool):
17
  """TDX 捷運即時到站查詢"""
18
 
19
  NAME = "tdx_metro"
20
- DESCRIPTION = "查詢捷運即時到站、最近車站(台北/高雄/桃園/台中捷運)"
21
  CATEGORY = "軌道運輸"
22
  TAGS = ["tdx", "捷運", "MRT", "即時到站"]
23
  KEYWORDS = ["捷運", "MRT", "地鐵", "metro", "到站"]
 
17
  """TDX 捷運即時到站查詢"""
18
 
19
  NAME = "tdx_metro"
20
+ DESCRIPTION = "Query metro/MRT real-time arrivals and nearest stations (Taipei/Kaohsiung/Taoyuan/Taichung metro systems)"
21
  CATEGORY = "軌道運輸"
22
  TAGS = ["tdx", "捷運", "MRT", "即時到站"]
23
  KEYWORDS = ["捷運", "MRT", "地鐵", "metro", "到站"]
features/mcp/tools/tdx_parking.py CHANGED
@@ -17,7 +17,7 @@ class TDXParkingTool(MCPTool):
17
  """TDX 停車場與充電站查詢"""
18
 
19
  NAME = "tdx_parking"
20
- DESCRIPTION = "查詢附近停車場、即時剩餘車位、收費標準、充電站資訊"
21
  CATEGORY = "停車與充電"
22
  TAGS = ["tdx", "停車", "充電站", "電動車"]
23
  KEYWORDS = ["停車", "停車場", "充電", "充電站", "車位", "電動車"]
 
17
  """TDX 停車場與充電站查詢"""
18
 
19
  NAME = "tdx_parking"
20
+ DESCRIPTION = "Query nearby parking lots with real-time available spaces, pricing, and EV charging station information"
21
  CATEGORY = "停車與充電"
22
  TAGS = ["tdx", "停車", "充電站", "電動車"]
23
  KEYWORDS = ["停車", "停車場", "充電", "充電站", "車位", "電動車"]
features/mcp/tools/tdx_thsr.py CHANGED
@@ -18,7 +18,7 @@ class TDXTHSRTool(MCPTool):
18
  """TDX 台灣高鐵時刻表查詢"""
19
 
20
  NAME = "tdx_thsr"
21
- DESCRIPTION = "查詢台灣高鐵時刻表、票價、最近車站(南港-左營)"
22
  CATEGORY = "軌道運輸"
23
  TAGS = ["tdx", "高鐵", "THSR", "時刻表", "票價"]
24
  KEYWORDS = ["高鐵", "THSR", "HSR", "高速鐵路", "時刻", "票價"]
 
18
  """TDX 台灣高鐵時刻表查詢"""
19
 
20
  NAME = "tdx_thsr"
21
+ DESCRIPTION = "Query Taiwan High Speed Rail (THSR) schedules, fares, and nearest stations (Nangang to Zuoying)"
22
  CATEGORY = "軌道運輸"
23
  TAGS = ["tdx", "高鐵", "THSR", "時刻表", "票價"]
24
  KEYWORDS = ["高鐵", "THSR", "HSR", "高速鐵路", "時刻", "票價"]
features/mcp/tools/tdx_train.py CHANGED
@@ -18,7 +18,7 @@ class TDXTrainTool(MCPTool):
18
  """TDX 台鐵時刻表查詢"""
19
 
20
  NAME = "tdx_train"
21
- DESCRIPTION = "查詢台鐵列車時刻表。參數提取規則:「從AB→origin_station=A,destination_station=B;「往B→destination_station=B(起點用GPS);「車次123→train_no=123"
22
  CATEGORY = "軌道運輸"
23
  TAGS = ["tdx", "台鐵", "TRA", "火車", "時刻表"]
24
  KEYWORDS = ["台鐵", "臺鐵", "火車", "TRA", "列車", "時刻", "自強號", "莒光號", "區間車"]
 
18
  """TDX 台鐵時刻表查詢"""
19
 
20
  NAME = "tdx_train"
21
+ DESCRIPTION = "Query Taiwan Railway (TRA) train schedules. Parameter extraction: 'from A to B' origin_station=A, destination_station=B; 'to B' destination_station=B (origin from GPS); 'train 123' train_no=123."
22
  CATEGORY = "軌道運輸"
23
  TAGS = ["tdx", "台鐵", "TRA", "火車", "時刻表"]
24
  KEYWORDS = ["台鐵", "臺鐵", "火車", "TRA", "列車", "時刻", "自強號", "莒光號", "區間車"]
features/mcp/tools/tdx_youbike.py CHANGED
@@ -17,7 +17,7 @@ class TDXBikeTool(MCPTool):
17
  """TDX YouBike 即時查詢"""
18
 
19
  NAME = "tdx_youbike"
20
- DESCRIPTION = "查詢附近 YouBike 站點、即時車輛數、空位數(支援 YouBike 1.0/2.0"
21
  CATEGORY = "微型運具"
22
  TAGS = ["tdx", "youbike", "ubike", "共享單車", "微笑單車"]
23
  KEYWORDS = [
 
17
  """TDX YouBike 即時查詢"""
18
 
19
  NAME = "tdx_youbike"
20
+ DESCRIPTION = "Query nearby YouBike stations with real-time available bikes and parking spaces (supports YouBike 1.0/2.0)"
21
  CATEGORY = "微型運具"
22
  TAGS = ["tdx", "youbike", "ubike", "共享單車", "微笑單車"]
23
  KEYWORDS = [
features/mcp/tools/weather_tool.py CHANGED
@@ -30,7 +30,7 @@ class WeatherTool(MCPTool):
30
  """天氣查詢 MCP 工具"""
31
 
32
  NAME = "weather_query"
33
- DESCRIPTION = "查詢指定城市的即時天氣資訊(溫度、濕度、天氣狀況等)"
34
  CATEGORY = "生活資訊"
35
  TAGS = ["weather", "天氣", "氣象"]
36
  KEYWORDS = ["天氣", "氣溫", "下雨", "晴天", "陰天", "weather", "溫度", "濕度", "會不會下雨", "熱不熱", "冷不冷"]
 
30
  """天氣查詢 MCP 工具"""
31
 
32
  NAME = "weather_query"
33
+ DESCRIPTION = "Query real-time weather information (temperature, humidity, conditions, etc.) for a specified city"
34
  CATEGORY = "生活資訊"
35
  TAGS = ["weather", "天氣", "氣象"]
36
  KEYWORDS = ["天氣", "氣溫", "下雨", "晴天", "陰天", "weather", "溫度", "濕度", "會不會下雨", "熱不熱", "冷不冷"]
models/speaker_identification/scripts/inference.py CHANGED
@@ -5,7 +5,7 @@ import numpy as np
5
  import pickle
6
  import wave
7
  import pyaudio
8
- from speechbrain.pretrained import EncoderClassifier
9
  from sklearn.metrics.pairwise import cosine_similarity
10
 
11
  # 常數
 
5
  import pickle
6
  import wave
7
  import pyaudio
8
+ from speechbrain.inference import EncoderClassifier
9
  from sklearn.metrics.pairwise import cosine_similarity
10
 
11
  # 常數
routers/voice.py CHANGED
@@ -24,8 +24,10 @@ class SpeakerLabelBindRequest(BaseModel):
24
  class TTSRequest(BaseModel):
25
  """TTS 請求"""
26
  text: str
27
- voice: str = "nova"
28
  speed: float = 1.0
 
 
29
 
30
 
31
  @router.post("/bind-speaker")
@@ -88,6 +90,8 @@ async def text_to_speech(
88
  text=request.text,
89
  voice=request.voice,
90
  speed=request.speed,
 
 
91
  )
92
 
93
  if not result.get("success"):
 
24
  class TTSRequest(BaseModel):
25
  """TTS 請求"""
26
  text: str
27
+ voice: str = "coral"
28
  speed: float = 1.0
29
+ emotion: Optional[str] = None # 情緒標籤(neutral, happy, sad, angry, fear, surprise)
30
+ care_mode: bool = False # 是否為關懷模式
31
 
32
 
33
  @router.post("/bind-speaker")
 
90
  text=request.text,
91
  voice=request.voice,
92
  speed=request.speed,
93
+ emotion=request.emotion,
94
+ care_mode=request.care_mode,
95
  )
96
 
97
  if not result.get("success"):
services/ai_service.py CHANGED
@@ -17,6 +17,16 @@ 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
 
@@ -71,6 +81,7 @@ def _build_base_system_prompt(
71
  use_care_mode: bool,
72
  care_emotion: Optional[str],
73
  user_name: Optional[str],
 
74
  ) -> str:
75
  if use_care_mode:
76
  base_prompt = CARE_MODE_SYSTEM_PROMPT.strip()
@@ -81,9 +92,12 @@ def _build_base_system_prompt(
81
  "你是 BloomWare 的個人化助理 小花,由銘傳大學人工智慧應用學系 槓上開發 團隊開發。"
82
  "你不是 GPT,也不要自稱 GPT。"
83
  "你是一個友善、有禮、幽默且能夠提供幫助的AI助手。"
84
- "請使用使用者的語言進行回覆,保持簡潔清晰的表達。"
85
  )
86
 
 
 
 
 
87
  if user_name:
88
  base_prompt = f"用戶名稱:{user_name}\n\n{base_prompt}"
89
 
@@ -650,6 +664,7 @@ async def generate_response_for_user(
650
  user_name: Optional[str] = None,
651
  emotion_label: Optional[str] = None,
652
  env_context: Optional[Dict[str, Any]] = None,
 
653
  ) -> str:
654
  """
655
  為用戶生成AI回應
@@ -681,6 +696,7 @@ async def generate_response_for_user(
681
  user_name=user_name,
682
  emotion_label=emotion_label,
683
  env_context=env_context,
 
684
  )
685
  else:
686
  # 回退到原有的全局歷史管理(用於向後兼容)
@@ -699,6 +715,7 @@ async def generate_response_for_user(
699
  user_name=user_name,
700
  emotion_label=emotion_label,
701
  env_context=env_context,
 
702
  )
703
 
704
  logger.error("未提供消息列表或用戶消息")
@@ -727,6 +744,7 @@ async def _generate_response_with_chat_db(
727
  user_name: Optional[str] = None,
728
  emotion_label: Optional[str] = None,
729
  env_context: Optional[Dict[str, Any]] = None,
 
730
  ):
731
  """使用DB管理對話歷史的實現"""
732
  try:
@@ -738,14 +756,24 @@ async def _generate_response_with_chat_db(
738
  system_prompt = f"{CARE_MODE_SYSTEM_PROMPT}\n\n{emotion_text}"
739
  logger.info(f"💙 使用關懷模式 System Prompt,情緒:{care_emotion}")
740
  else:
 
 
 
 
 
 
 
 
 
 
741
  system_prompt = (
742
  "你是 BloomWare 的個人化助理 小花,由銘傳大學人工智慧應用學系 槓上開發 團隊開發。"
743
  "你不是 GPT,也不要自稱 GPT。"
744
  "你是一個友善、有禮、幽默且能夠提供幫助的AI助手。\n\n"
745
- "【重要】語言使用規範:\n"
746
- "- 回覆用戶時:必須使用繁體中文,保持簡潔清晰的表達\n"
747
  "- 調用工具時:所有參數必須使用英文(城市名、國家名、貨幣代碼等)\n"
748
- "- 範例:用戶問「台北天氣」→ 調用工具時參數用 {\"city\": \"Taipei\"},回覆時說「台北目前...\""
749
  )
750
 
751
  # 在系統提示前加上用戶名稱
@@ -860,6 +888,7 @@ async def _generate_response_with_chat_db(
860
  use_care_mode=use_care_mode,
861
  care_emotion=care_emotion,
862
  user_name=user_name,
 
863
  )
864
 
865
  messages_to_send = _compose_messages_with_context(
@@ -915,6 +944,7 @@ async def _generate_response_with_chat_db(
915
  user_name=user_name,
916
  emotion_label=emotion_label,
917
  env_context=env_context,
 
918
  )
919
 
920
 
@@ -934,6 +964,7 @@ async def _generate_response_with_global_history(
934
  user_name: Optional[str] = None,
935
  emotion_label: Optional[str] = None,
936
  env_context: Optional[Dict[str, Any]] = None,
 
937
  ):
938
  """使用全局歷史的回退實現(向後兼容)"""
939
  try:
@@ -945,14 +976,24 @@ async def _generate_response_with_global_history(
945
  system_prompt = f"{CARE_MODE_SYSTEM_PROMPT}\n\n{emotion_text}"
946
  logger.info(f"💙 使用關懷模式 System Prompt(全局歷史),情緒:{care_emotion}")
947
  else:
 
 
 
 
 
 
 
 
 
 
948
  system_prompt = (
949
  "你是 BloomWare 的個人化助理 小花,由銘傳大學人工智慧應用學系 槓上開發 團隊開發。"
950
  "你不是 GPT,也不要自稱 GPT。"
951
  "你是一個友善、有禮、幽默且能夠提供幫助的AI助手。\n\n"
952
- "【重要】語言使用規範:\n"
953
- "- 回覆用戶時:必須使用繁體中文,保持簡潔清晰的表達\n"
954
  "- 調用工具時:所有參數必須使用英文(城市名、國家名、貨幣代碼等)\n"
955
- "- 範例:用戶問「台北天氣」→ 調用工具時參數用 {\"city\": \"Taipei\"},回覆時說「台北目前...\""
956
  )
957
 
958
  # 在系統提示前加上用戶名稱
@@ -1007,6 +1048,7 @@ async def _generate_response_with_global_history(
1007
  use_care_mode=use_care_mode,
1008
  care_emotion=care_emotion,
1009
  user_name=user_name,
 
1010
  )
1011
 
1012
  # 關懷模式不帶長期記憶
 
17
  # 超時設定(秒)
18
  OPENAI_TIMEOUT = settings.OPENAI_TIMEOUT
19
 
20
+ # 語言指令模板
21
+ LANGUAGE_INSTRUCTIONS = {
22
+ "zh": "請使用繁體中文回覆",
23
+ "en": "Please respond in English",
24
+ "id": "Silakan balas dalam Bahasa Indonesia",
25
+ "ja": "日本語で返信してください",
26
+ "vi": "Vui lòng trả lời bằng tiếng Việt",
27
+ "auto": "請使用與用戶相同的語言回覆"
28
+ }
29
+
30
  # 情緒關懷模式 System Prompt(新增)
31
  CARE_MODE_SYSTEM_PROMPT = """你是 BloomWare 的情緒關懷助手「小花」,由銘傳大學人工智慧應用學系槓上開發團隊打造。你不是 GPT,也不要自稱 GPT;你的任務是在情緒低落時傾聽、陪伴。
32
 
 
81
  use_care_mode: bool,
82
  care_emotion: Optional[str],
83
  user_name: Optional[str],
84
+ language: Optional[str] = None,
85
  ) -> str:
86
  if use_care_mode:
87
  base_prompt = CARE_MODE_SYSTEM_PROMPT.strip()
 
92
  "你是 BloomWare 的個人化助理 小花,由銘傳大學人工智慧應用學系 槓上開發 團隊開發。"
93
  "你不是 GPT,也不要自稱 GPT。"
94
  "你是一個友善、有禮、幽默且能夠提供幫助的AI助手。"
 
95
  )
96
 
97
+ # 加入語言指令(明確指定輸出語言)
98
+ language_instruction = LANGUAGE_INSTRUCTIONS.get(language or "auto", LANGUAGE_INSTRUCTIONS["auto"])
99
+ base_prompt = f"{base_prompt}\n\n【重要】{language_instruction},保持簡潔清晰的表達。"
100
+
101
  if user_name:
102
  base_prompt = f"用戶名稱:{user_name}\n\n{base_prompt}"
103
 
 
664
  user_name: Optional[str] = None,
665
  emotion_label: Optional[str] = None,
666
  env_context: Optional[Dict[str, Any]] = None,
667
+ language: Optional[str] = None,
668
  ) -> str:
669
  """
670
  為用戶生成AI回應
 
696
  user_name=user_name,
697
  emotion_label=emotion_label,
698
  env_context=env_context,
699
+ language=language,
700
  )
701
  else:
702
  # 回退到原有的全局歷史管理(用於向後兼容)
 
715
  user_name=user_name,
716
  emotion_label=emotion_label,
717
  env_context=env_context,
718
+ language=language,
719
  )
720
 
721
  logger.error("未提供消息列表或用戶消息")
 
744
  user_name: Optional[str] = None,
745
  emotion_label: Optional[str] = None,
746
  env_context: Optional[Dict[str, Any]] = None,
747
+ language: Optional[str] = None,
748
  ):
749
  """使用DB管理對話歷史的實現"""
750
  try:
 
756
  system_prompt = f"{CARE_MODE_SYSTEM_PROMPT}\n\n{emotion_text}"
757
  logger.info(f"💙 使用關懷模式 System Prompt,情緒:{care_emotion}")
758
  else:
759
+ # 根據語言參數調整回應語言
760
+ language_instruction = {
761
+ "zh": "繁體中文",
762
+ "en": "English",
763
+ "ko": "한국어 (Korean)",
764
+ "ja": "日本語 (Japanese)",
765
+ "id": "Bahasa Indonesia",
766
+ "vi": "Tiếng Việt (Vietnamese)"
767
+ }.get(language, "繁體中文")
768
+
769
  system_prompt = (
770
  "你是 BloomWare 的個人化助理 小花,由銘傳大學人工智慧應用學系 槓上開發 團隊開發。"
771
  "你不是 GPT,也不要自稱 GPT。"
772
  "你是一個友善、有禮、幽默且能夠提供幫助的AI助手。\n\n"
773
+ f"【重要】語言使用規範:\n"
774
+ f"- 回覆用戶時:必須使用 {language_instruction},保持簡潔清晰的表達\n"
775
  "- 調用工具時:所有參數必須使用英文(城市名、國家名、貨幣代碼等)\n"
776
+ "- 範例:用戶問「台北天氣」→ 調用工具時參數用 {\"city\": \"Taipei\"},回覆時用對應語言描述"
777
  )
778
 
779
  # 在系統提示前加上用戶名稱
 
888
  use_care_mode=use_care_mode,
889
  care_emotion=care_emotion,
890
  user_name=user_name,
891
+ language=language,
892
  )
893
 
894
  messages_to_send = _compose_messages_with_context(
 
944
  user_name=user_name,
945
  emotion_label=emotion_label,
946
  env_context=env_context,
947
+ language=language,
948
  )
949
 
950
 
 
964
  user_name: Optional[str] = None,
965
  emotion_label: Optional[str] = None,
966
  env_context: Optional[Dict[str, Any]] = None,
967
+ language: Optional[str] = None,
968
  ):
969
  """使用全局歷史的回退實現(向後兼容)"""
970
  try:
 
976
  system_prompt = f"{CARE_MODE_SYSTEM_PROMPT}\n\n{emotion_text}"
977
  logger.info(f"💙 使用關懷模式 System Prompt(全局歷史),情緒:{care_emotion}")
978
  else:
979
+ # 根據語言參數調整回應語言
980
+ language_instruction = {
981
+ "zh": "繁體中文",
982
+ "en": "English",
983
+ "ko": "한국어 (Korean)",
984
+ "ja": "日本語 (Japanese)",
985
+ "id": "Bahasa Indonesia",
986
+ "vi": "Tiếng Việt (Vietnamese)"
987
+ }.get(language, "繁體中文")
988
+
989
  system_prompt = (
990
  "你是 BloomWare 的個人化助理 小花,由銘傳大學人工智慧應用學系 槓上開發 團隊開發。"
991
  "你不是 GPT,也不要自稱 GPT。"
992
  "你是一個友善、有禮、幽默且能夠提供幫助的AI助手。\n\n"
993
+ f"【重要】語言使用規範:\n"
994
+ f"- 回覆用戶時:必須使用 {language_instruction},保持簡潔清晰的表達\n"
995
  "- 調用工具時:所有參數必須使用英文(城市名、國家名、貨幣代碼等)\n"
996
+ "- 範例:用戶問「台北天氣」→ 調用工具時參數用 {\"city\": \"Taipei\"},回覆時用對應語言描述"
997
  )
998
 
999
  # 在系統提示前加上用戶名稱
 
1048
  use_care_mode=use_care_mode,
1049
  care_emotion=care_emotion,
1050
  user_name=user_name,
1051
+ language=language,
1052
  )
1053
 
1054
  # 關懷模式不帶長期記憶
services/audio_emotion_service.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 音頻情緒辨識服務
3
+ 使用 HuBERT 中文語音情緒辨識模型進行語音語調分析
4
+
5
+ 支援情緒:angry, fear, happy, neutral, sad, surprise
6
+ """
7
+
8
+ import io
9
+ import logging
10
+ import asyncio
11
+ import tempfile
12
+ import os
13
+ from typing import Dict, Any, Optional, Tuple
14
+ import numpy as np
15
+
16
+ from core.logging import get_logger
17
+
18
+ logger = get_logger("services.audio_emotion")
19
+
20
+ # 延遲導入(避免啟動時載入模型)
21
+ _emotion_module = None
22
+
23
+
24
+ def _load_emotion_module():
25
+ """延遲載入情緒辨識模組"""
26
+ global _emotion_module
27
+ if _emotion_module is None:
28
+ try:
29
+ from models.emotion_recognition import emotion
30
+ _emotion_module = emotion
31
+ logger.info("✅ 音頻情緒辨識模組載入成功")
32
+ except Exception as e:
33
+ logger.error(f"❌ 音頻情緒辨識模組載入失敗: {e}")
34
+ _emotion_module = None
35
+ return _emotion_module
36
+
37
+
38
+ class AudioEmotionService:
39
+ """音頻情緒辨識服務"""
40
+
41
+ # 情緒標籤映射(統一格式)
42
+ EMOTION_MAP = {
43
+ "生氣(angry)": "angry",
44
+ "恐懼(fear)": "fear",
45
+ "開心(happy)": "happy",
46
+ "中性(neutral)": "neutral",
47
+ "悲傷(sad)": "sad",
48
+ "驚訝(surprise)": "surprise"
49
+ }
50
+
51
+ def __init__(self):
52
+ self._initialized = False
53
+
54
+ async def predict_from_bytes(
55
+ self,
56
+ audio_bytes: bytes,
57
+ sample_rate: int = 16000
58
+ ) -> Dict[str, Any]:
59
+ """
60
+ 從音頻 bytes 預測情緒
61
+
62
+ Args:
63
+ audio_bytes: PCM16 音頻數據
64
+ sample_rate: 採樣率(預設 16000Hz)
65
+
66
+ Returns:
67
+ {
68
+ "success": bool,
69
+ "emotion": str, # 情緒標籤(angry, fear, happy, neutral, sad, surprise)
70
+ "confidence": float, # 置信度(0-1)
71
+ "all_emotions": dict, # 所有情緒的置信度
72
+ "source": "audio", # 情緒來源
73
+ "error": str (optional)
74
+ }
75
+ """
76
+ try:
77
+ # 延遲載入模組
78
+ emotion_module = _load_emotion_module()
79
+ if emotion_module is None:
80
+ return {
81
+ "success": False,
82
+ "emotion": "neutral",
83
+ "confidence": 0.0,
84
+ "error": "情緒辨識模組未載入"
85
+ }
86
+
87
+ # 檢查音頻長度
88
+ if len(audio_bytes) < sample_rate * 2: # 至少 1 秒
89
+ logger.warning(f"⚠️ 音頻過短: {len(audio_bytes)} bytes")
90
+ return {
91
+ "success": False,
92
+ "emotion": "neutral",
93
+ "confidence": 0.0,
94
+ "error": "音頻過短"
95
+ }
96
+
97
+ logger.info(f"🎤 開始音頻情緒辨識,音頻大小: {len(audio_bytes)} bytes")
98
+
99
+ # 使用臨時檔案(emotion.predict() 需要檔案路徑)
100
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file:
101
+ temp_path = temp_file.name
102
+
103
+ # 寫入 WAV 檔案
104
+ import wave
105
+ with wave.open(temp_path, 'wb') as wf:
106
+ wf.setnchannels(1)
107
+ wf.setsampwidth(2) # 16-bit PCM
108
+ wf.setframerate(sample_rate)
109
+ wf.writeframes(audio_bytes)
110
+
111
+ try:
112
+ # 在執行緒池中執行(避免阻塞事件循環)
113
+ loop = asyncio.get_event_loop()
114
+ pred_id, confidence, all_emotions = await loop.run_in_executor(
115
+ None,
116
+ emotion_module.predict,
117
+ temp_path
118
+ )
119
+
120
+ # 映射情緒標籤
121
+ raw_emotion = emotion_module.id2class(pred_id)
122
+ emotion = self.EMOTION_MAP.get(raw_emotion, "neutral")
123
+
124
+ # 轉換所有情緒置信度
125
+ normalized_emotions = {}
126
+ for raw_label, score_str in all_emotions.items():
127
+ normalized_label = self.EMOTION_MAP.get(raw_label, raw_label)
128
+ normalized_emotions[normalized_label] = float(score_str)
129
+
130
+ logger.info(f"✅ 音頻情緒辨識完成: {emotion} (置信度: {confidence:.4f})")
131
+
132
+ return {
133
+ "success": True,
134
+ "emotion": emotion,
135
+ "confidence": float(confidence),
136
+ "all_emotions": normalized_emotions,
137
+ "source": "audio"
138
+ }
139
+
140
+ finally:
141
+ # 清理臨時檔案
142
+ try:
143
+ os.unlink(temp_path)
144
+ except Exception:
145
+ pass
146
+
147
+ except Exception as e:
148
+ logger.exception(f"❌ 音頻情緒辨識失敗: {e}")
149
+ return {
150
+ "success": False,
151
+ "emotion": "neutral",
152
+ "confidence": 0.0,
153
+ "error": str(e)
154
+ }
155
+
156
+
157
+ # 全域單例
158
+ audio_emotion_service = AudioEmotionService()
159
+
160
+
161
+ async def predict_emotion_from_audio(
162
+ audio_bytes: bytes,
163
+ sample_rate: int = 16000
164
+ ) -> Dict[str, Any]:
165
+ """
166
+ 便捷函數:從音頻預測情緒
167
+
168
+ Args:
169
+ audio_bytes: PCM16 音頻數據
170
+ sample_rate: 採樣率
171
+
172
+ Returns:
173
+ 情緒辨識結果
174
+ """
175
+ return await audio_emotion_service.predict_from_bytes(audio_bytes, sample_rate)
services/realtime_stt_service.py CHANGED
@@ -1,13 +1,15 @@
1
  """
2
  OpenAI Realtime API - 即時語音轉文字服務
3
  使用 WebSocket 進行低延遲串流轉錄
 
 
4
  """
5
 
6
  import os
7
  import json
8
  import asyncio
9
  import logging
10
- from typing import Optional, Callable, Dict, Any
11
  import websockets
12
  from dotenv import load_dotenv
13
 
@@ -22,6 +24,16 @@ if not OPENAI_API_KEY:
22
  # OpenAI Realtime API WebSocket URL
23
  REALTIME_API_URL = "wss://api.openai.com/v1/realtime?intent=transcription"
24
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  class RealtimeSTTService:
27
  """OpenAI Realtime API 即時語音轉文字服務"""
@@ -31,6 +43,58 @@ class RealtimeSTTService:
31
  self.ws: Optional[websockets.WebSocketClientProtocol] = None
32
  self.is_connected = False
33
  self._receive_task: Optional[asyncio.Task] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  async def connect(
36
  self,
@@ -48,7 +112,7 @@ class RealtimeSTTService:
48
  on_transcript_done: 接收完整轉錄結果的回調函數
49
  on_vad_committed: VAD 偵測到語音結束的回調函數
50
  model: 使用的模型(gpt-4o-transcribe 或 gpt-4o-mini-transcribe)
51
- language: 語言代碼
52
 
53
  Returns:
54
  bool: 連線是否成功
@@ -57,6 +121,16 @@ class RealtimeSTTService:
57
  logger.error("❌ OpenAI API Key 未設置")
58
  return False
59
 
 
 
 
 
 
 
 
 
 
 
60
  try:
61
  logger.info(f"🔌 連接到 OpenAI Realtime API: {REALTIME_API_URL}")
62
 
@@ -72,27 +146,37 @@ class RealtimeSTTService:
72
  self.is_connected = True
73
  logger.info("✅ 已連接到 OpenAI Realtime API")
74
 
75
- # 發送 session 配置(按照 Microsoft/Azure 官方範例
 
 
 
76
  session_config = {
77
  "type": "transcription_session.update",
78
- "session": { # ← 關鍵:所有配置要包在 session 物件裡
79
  "input_audio_format": "pcm16",
80
  "input_audio_transcription": {
81
  "model": model,
82
- "prompt": "",
83
- "language": language
84
  },
85
  "turn_detection": {
86
  "type": "server_vad",
87
  "threshold": 0.5,
88
  "prefix_padding_ms": 300,
89
  "silence_duration_ms": 500
 
 
 
90
  }
91
  }
92
  }
 
 
 
 
93
 
94
  await self.ws.send(json.dumps(session_config))
95
- logger.info("📤 已發送 session 配置")
96
 
97
  # 啟動接收事件的背景任務
98
  self._receive_task = asyncio.create_task(
 
1
  """
2
  OpenAI Realtime API - 即時語音轉文字服務
3
  使用 WebSocket 進行低延遲串流轉錄
4
+
5
+ 支援語言:中文(zh)、英文(en)、印尼文(id)、日文(ja)、越南文(vi)
6
  """
7
 
8
  import os
9
  import json
10
  import asyncio
11
  import logging
12
+ from typing import Optional, Callable, Dict, Any, Literal
13
  import websockets
14
  from dotenv import load_dotenv
15
 
 
24
  # OpenAI Realtime API WebSocket URL
25
  REALTIME_API_URL = "wss://api.openai.com/v1/realtime?intent=transcription"
26
 
27
+ # 支援的語言列表
28
+ SupportedLanguage = Literal["zh", "en", "id", "ja", "vi"]
29
+ SUPPORTED_LANGUAGES = {
30
+ "zh": "中文",
31
+ "en": "English",
32
+ "id": "Bahasa Indonesia",
33
+ "ja": "日本語",
34
+ "vi": "Tiếng Việt"
35
+ }
36
+
37
 
38
  class RealtimeSTTService:
39
  """OpenAI Realtime API 即時語音轉文字服務"""
 
43
  self.ws: Optional[websockets.WebSocketClientProtocol] = None
44
  self.is_connected = False
45
  self._receive_task: Optional[asyncio.Task] = None
46
+ self.current_language: str = "zh"
47
+
48
+ def _build_language_prompt(self) -> str:
49
+ """
50
+ 建立語言提示,引導 Whisper 優先識別支援的 5 種語言
51
+
52
+ Whisper 的 prompt 參數可以包含:
53
+ - 多語言範例文字
54
+ - 引導模型識別特定語言
55
+
56
+ Returns:
57
+ 語言提示字串
58
+ """
59
+ # 使用多語言範例引導 Whisper(每種語言的常見詞彙)
60
+ prompt_samples = [
61
+ "你好", # 中文
62
+ "Hello", # 英文
63
+ "Halo", # 印尼文
64
+ "こんにちは", # 日文
65
+ "Xin chào" # 越南文
66
+ ]
67
+
68
+ return ", ".join(prompt_samples)
69
+
70
+ def _validate_language(self, language: str) -> Optional[str]:
71
+ """
72
+ 驗證並正規化語言代碼
73
+
74
+ Args:
75
+ language: 語言代碼(或 'auto' 表示自動檢測)
76
+
77
+ Returns:
78
+ 正規化後的語言代碼,或 None(自動檢測)
79
+ """
80
+ lang = language.lower().strip()
81
+
82
+ # 自動檢測模式
83
+ if lang in ('auto', 'detect', ''):
84
+ logger.info("🌐 啟用自動語言檢測")
85
+ return None
86
+
87
+ if lang in SUPPORTED_LANGUAGES:
88
+ return lang
89
+
90
+ # 嘗試從完整語言名稱匹配
91
+ for code, name in SUPPORTED_LANGUAGES.items():
92
+ if name.lower() == lang.lower():
93
+ return code
94
+
95
+ # 不支援的語言,使用自動檢測
96
+ logger.warning(f"⚠️ 不支援的語言 '{language}',改用自動檢測")
97
+ return None
98
 
99
  async def connect(
100
  self,
 
112
  on_transcript_done: 接收完整轉錄結果的回調函數
113
  on_vad_committed: VAD 偵測到語音結束的回調函數
114
  model: 使用的模型(gpt-4o-transcribe 或 gpt-4o-mini-transcribe)
115
+ language: 語言代碼(zh/en/id/ja/vi)
116
 
117
  Returns:
118
  bool: 連線是否成功
 
121
  logger.error("❌ OpenAI API Key 未設置")
122
  return False
123
 
124
+ # 驗證語言
125
+ validated_language = self._validate_language(language)
126
+ self.current_language = validated_language or "auto"
127
+
128
+ if validated_language:
129
+ language_name = SUPPORTED_LANGUAGES.get(validated_language, validated_language)
130
+ logger.info(f"🌐 語言設定: {language_name} ({validated_language})")
131
+ else:
132
+ logger.info("🌐 語言設定: 自動檢測(支援 zh/en/id/ja/vi)")
133
+
134
  try:
135
  logger.info(f"🔌 連接到 OpenAI Realtime API: {REALTIME_API_URL}")
136
 
 
146
  self.is_connected = True
147
  logger.info("✅ 已連接到 OpenAI Realtime API")
148
 
149
+ # 建立語言提示(引導 Whisper 優先識別支援的 5 種語言
150
+ language_prompt = self._build_language_prompt()
151
+
152
+ # 發送 session 配置(正確格式:需要 session 物件包裹)
153
  session_config = {
154
  "type": "transcription_session.update",
155
+ "session": {
156
  "input_audio_format": "pcm16",
157
  "input_audio_transcription": {
158
  "model": model,
159
+ "prompt": language_prompt # 使用語言提示引導識別
160
+ # 不指定 language,讓 Whisper 自動檢測(但透過 prompt 引導)
161
  },
162
  "turn_detection": {
163
  "type": "server_vad",
164
  "threshold": 0.5,
165
  "prefix_padding_ms": 300,
166
  "silence_duration_ms": 500
167
+ },
168
+ "input_audio_noise_reduction": {
169
+ "type": "far_field"
170
  }
171
  }
172
  }
173
+
174
+ # 如果指定了語言,則加入配置
175
+ if validated_language:
176
+ session_config["session"]["input_audio_transcription"]["language"] = validated_language
177
 
178
  await self.ws.send(json.dumps(session_config))
179
+ logger.info("📤 已發送 session 配置(含語言引導提示)")
180
 
181
  # 啟動接收事件的背景任務
182
  self._receive_task = asyncio.create_task(
services/stt_service.py DELETED
@@ -1,182 +0,0 @@
1
- """
2
- OpenAI Whisper STT 服務
3
- 使用 OpenAI Whisper API 進行語音轉文字
4
- """
5
-
6
- import os
7
- import io
8
- import wave
9
- import logging
10
- import tempfile
11
- from typing import Optional, Dict, Any
12
- from openai import OpenAI
13
- from dotenv import load_dotenv
14
-
15
- load_dotenv()
16
-
17
- logger = logging.getLogger("services.stt")
18
-
19
- # OpenAI 配置
20
- OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
21
- if not OPENAI_API_KEY:
22
- logger.warning("⚠️ OPENAI_API_KEY 未設置")
23
-
24
- client = OpenAI(api_key=OPENAI_API_KEY) if OPENAI_API_KEY else None
25
-
26
-
27
- class STTService:
28
- """OpenAI Whisper 語音轉文字服務"""
29
-
30
- def __init__(self):
31
- self.client = client
32
- if not self.client:
33
- logger.error("❌ OpenAI client 初始化失敗,請檢查 OPENAI_API_KEY")
34
-
35
- async def transcribe(
36
- self,
37
- audio_data: bytes,
38
- language: str = "zh",
39
- prompt: Optional[str] = None,
40
- stream: bool = False,
41
- on_delta: Optional[callable] = None
42
- ) -> Dict[str, Any]:
43
- """
44
- 使用 Whisper API 將音頻轉文字
45
-
46
- Args:
47
- audio_data: 音頻數據(PCM16 raw bytes 或完整 WAV 文件)
48
- language: 語言代碼(zh, en, ja, ko 等)
49
- prompt: 提示詞(可選,幫助提高準確度)
50
- stream: 是否啟用串流轉錄(邊轉邊發)
51
- on_delta: 串流模式下的回調函數,接收部分轉錄結果
52
-
53
- Returns:
54
- {
55
- "success": bool,
56
- "text": str, # 轉錄文字
57
- "language": str, # 偵測到的語言
58
- "error": str # 錯誤訊息(如果失敗)
59
- }
60
- """
61
- if not self.client:
62
- return {
63
- "success": False,
64
- "text": "",
65
- "error": "OpenAI client 未初始化"
66
- }
67
-
68
- try:
69
- logger.info(f"🎙️ 開始 STT 轉錄,音頻大小: {len(audio_data)} bytes")
70
-
71
- # 檢查是否為原始 PCM 數據(沒有 WAV header)
72
- # WAV 文件以 "RIFF" 開頭
73
- is_raw_pcm = not audio_data.startswith(b'RIFF')
74
-
75
- if is_raw_pcm:
76
- # 將 PCM16 數據轉換為 WAV 格式
77
- logger.info("🔄 轉換 PCM16 → WAV 格式")
78
- wav_buffer = io.BytesIO()
79
- with wave.open(wav_buffer, 'wb') as wav_file:
80
- wav_file.setnchannels(1) # 單聲道
81
- wav_file.setsampwidth(2) # 16-bit = 2 bytes
82
- wav_file.setframerate(16000) # 16kHz
83
- wav_file.writeframes(audio_data)
84
- audio_data = wav_buffer.getvalue()
85
-
86
- # 將音頻數據寫入臨時文件(Whisper API 需要文件路徑)
87
- with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_audio:
88
- temp_audio.write(audio_data)
89
- temp_audio_path = temp_audio.name
90
-
91
- try:
92
- # 調用 Whisper API
93
- with open(temp_audio_path, "rb") as audio_file:
94
- transcript_params = {
95
- "model": "gpt-4o-mini-transcribe", # 2025 最佳實踐:WER 提升 25%
96
- "file": audio_file,
97
- "language": language
98
- }
99
-
100
- # 如果提供提示詞,加入參數
101
- if prompt:
102
- transcript_params["prompt"] = prompt
103
-
104
- response = self.client.audio.transcriptions.create(**transcript_params)
105
-
106
- # 提取轉錄文字
107
- transcribed_text = response.text.strip()
108
-
109
- logger.info(f"✅ STT 轉錄成功: {transcribed_text[:50]}...")
110
-
111
- return {
112
- "success": True,
113
- "text": transcribed_text,
114
- "language": language
115
- }
116
-
117
- finally:
118
- # 清理臨時文件
119
- import os as os_module
120
- if os_module.path.exists(temp_audio_path):
121
- os_module.remove(temp_audio_path)
122
-
123
- except Exception as e:
124
- logger.exception(f"❌ STT 轉錄失敗: {e}")
125
- return {
126
- "success": False,
127
- "text": "",
128
- "error": str(e)
129
- }
130
-
131
- async def transcribe_with_retry(
132
- self,
133
- audio_data: bytes,
134
- language: str = "zh",
135
- max_retries: int = 2
136
- ) -> Dict[str, Any]:
137
- """
138
- 帶重試機制的轉錄
139
-
140
- Args:
141
- audio_data: 音頻數據
142
- language: 語言代碼
143
- max_retries: 最大重試次數
144
-
145
- Returns:
146
- 同 transcribe()
147
- """
148
- for attempt in range(max_retries + 1):
149
- result = await self.transcribe(audio_data, language)
150
-
151
- if result["success"]:
152
- return result
153
-
154
- if attempt < max_retries:
155
- logger.warning(f"⚠️ STT 重試 {attempt + 1}/{max_retries}")
156
- else:
157
- logger.error("❌ STT 達到最大重試次數")
158
-
159
- return result
160
-
161
-
162
- # 全域單例
163
- stt_service = STTService()
164
-
165
-
166
- async def transcribe_audio(audio_data: bytes, language: str = "zh") -> Dict[str, Any]:
167
- """
168
- 便捷函數:轉錄音頻
169
-
170
- Args:
171
- audio_data: 音頻數據(bytes)
172
- language: 語言代碼
173
-
174
- Returns:
175
- {
176
- "success": bool,
177
- "text": str,
178
- "language": str,
179
- "error": str (optional)
180
- }
181
- """
182
- return await stt_service.transcribe_with_retry(audio_data, language)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/tts_service.py CHANGED
@@ -1,61 +1,129 @@
1
  """
2
- OpenAI TTS 服務
3
- 使用 OpenAI Text-to-Speech API 進行文字轉語音
 
 
 
 
 
 
4
  """
5
 
6
  import os
7
  import logging
8
- from typing import Optional, Dict, Any, Literal
9
- from openai import OpenAI
 
 
10
  from dotenv import load_dotenv
11
 
 
 
 
 
 
 
 
12
  load_dotenv()
13
 
14
- logger = logging.getLogger("services.tts")
 
 
 
 
15
 
16
- # OpenAI 配置
17
- OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
18
- if not OPENAI_API_KEY:
19
- logger.warning("⚠️ OPENAI_API_KEY 未設置")
 
 
 
 
 
20
 
21
- client = OpenAI(api_key=OPENAI_API_KEY) if OPENAI_API_KEY else None
 
22
 
23
- # 支援的 TTS 聲音
24
- VoiceType = Literal["alloy", "echo", "fable", "onyx", "nova", "shimmer"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
 
27
  class TTSService:
28
- """OpenAI Text-to-Speech 服務"""
29
 
30
  def __init__(self):
31
- self.client = client
32
- if not self.client:
33
- logger.error("❌ OpenAI client 初始化失敗,請檢查 OPENAI_API_KEY")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  async def synthesize(
36
  self,
37
  text: str,
38
- voice: VoiceType = "nova",
39
  model: str = "gpt-4o-mini-tts",
40
  speed: float = 1.0,
41
- instruction: Optional[str] = None
 
 
 
42
  ) -> Dict[str, Any]:
43
  """
44
- 使用 OpenAI TTS API 將文字轉語音(2025 最佳實踐:gpt-4o-mini-tts
45
 
46
  Args:
47
  text: 要轉換的文字
48
- voice: 聲音類型(alloy, echo, fable, onyx, nova, shimmer)
49
  model: TTS 模型(gpt-4o-mini-tts 或 tts-1-hd)
50
  speed: 語速(0.25 到 4.0)
51
- instruction: 語音指令(如「用溫柔、安慰的語氣說話」
 
 
 
52
 
53
  Returns:
54
  {
55
  "success": bool,
56
- "audio_data": bytes, # MP3 音頻數據
57
  "voice": str,
58
- "error": str # 錯誤訊息(如果失敗)
 
59
  }
60
  """
61
  if not self.client:
@@ -73,16 +141,21 @@ class TTSService:
73
  "model": model,
74
  "voice": voice,
75
  "input": text,
76
- "speed": speed
 
77
  }
78
 
 
 
 
79
  # 如果提供情緒指令(gpt-4o-mini-tts 支援)
80
- if instruction and model == "gpt-4o-mini-tts":
81
- params["instruction"] = instruction
 
82
 
83
- response = self.client.audio.speech.create(**params)
84
 
85
- # 獲取音頻數據(MP3 格式)
86
  audio_data = response.content
87
 
88
  logger.info(f"✅ TTS 合成成功,音頻大小: {len(audio_data)} bytes")
@@ -90,7 +163,8 @@ class TTSService:
90
  return {
91
  "success": True,
92
  "audio_data": audio_data,
93
- "voice": voice
 
94
  }
95
 
96
  except Exception as e:
@@ -101,6 +175,129 @@ class TTSService:
101
  "error": str(e)
102
  }
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  # 全域單例
106
  tts_service = TTSService()
@@ -108,23 +305,77 @@ tts_service = TTSService()
108
 
109
  async def text_to_speech(
110
  text: str,
111
- voice: VoiceType = "nova",
112
- speed: float = 1.0
 
113
  ) -> Dict[str, Any]:
114
  """
115
- 便捷函數:將文字轉為語音
116
 
117
  Args:
118
  text: 要轉換的文字
119
- voice: 聲音類型(alloy, echo, fable, onyx, nova, shimmer)
120
  speed: 語速(0.25 到 4.0)
 
121
 
122
  Returns:
123
  {
124
  "success": bool,
125
  "audio_data": bytes,
126
  "voice": str,
 
127
  "error": str (optional)
128
  }
129
  """
130
- return await tts_service.synthesize(text, voice, speed=speed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ OpenAI TTS 服務(2025 最佳實踐版)
3
+ 使用 AsyncOpenAI + Streaming 進行低延遲文字轉語音
4
+
5
+ 特色:
6
+ - 異步 API(AsyncOpenAI)
7
+ - 串流播放(邊生成邊播放,降低 TTFB)
8
+ - 支援情緒指令(gpt-4o-mini-tts)
9
+ - 多語言支援(自動檢測:中文、英文、印尼文、日文、越南文)
10
  """
11
 
12
  import os
13
  import logging
14
+ import asyncio
15
+ from typing import Optional, Dict, Any, Literal, AsyncIterator
16
+ from openai import AsyncOpenAI
17
+ from openai.helpers import LocalAudioPlayer
18
  from dotenv import load_dotenv
19
 
20
+ # 統一日誌配置
21
+ from core.logging import get_logger
22
+ logger = get_logger("services.tts")
23
+
24
+ # 統一配置管理
25
+ from core.config import settings
26
+
27
  load_dotenv()
28
 
29
+ # 支援的 TTS 聲音(2025 新增:coral, sage, verse)
30
+ VoiceType = Literal["coral", "sage", "verse", "alloy", "echo", "fable", "onyx", "nova", "shimmer"]
31
+
32
+ # 支援的音頻格式
33
+ AudioFormat = Literal["mp3", "opus", "aac", "flac", "wav", "pcm"]
34
 
35
+ # 情緒指令預設模板
36
+ EMOTION_INSTRUCTIONS = {
37
+ "neutral": "用平穩、自然的語氣說話",
38
+ "happy": "用開心、愉悅的語氣說話",
39
+ "sad": "用溫柔、安慰的語氣說話",
40
+ "angry": "用冷靜、理性的語氣說話",
41
+ "fear": "用溫暖、鼓勵的語氣說話",
42
+ "surprise": "用輕快、活潑的語氣說話"
43
+ }
44
 
45
+ # 關懷模式特殊指令
46
+ CARE_MODE_INSTRUCTION = "用溫柔、關懷、陪伴的語氣說話,讓對方感受到被理解和支持"
47
 
48
+
49
+ def get_emotion_instruction(emotion: Optional[str], care_mode: bool = False) -> str:
50
+ """
51
+ 根據情緒選擇對應的 TTS instruction
52
+
53
+ Args:
54
+ emotion: 情緒標籤(neutral, happy, sad, angry, fear, surprise)
55
+ care_mode: 是否為關懷模式
56
+
57
+ Returns:
58
+ TTS instruction 字串
59
+ """
60
+ # 關懷模式優先
61
+ if care_mode:
62
+ return CARE_MODE_INSTRUCTION
63
+
64
+ # 根據情緒選擇
65
+ if emotion and emotion in EMOTION_INSTRUCTIONS:
66
+ return EMOTION_INSTRUCTIONS[emotion]
67
+
68
+ # 預設中性語氣
69
+ return EMOTION_INSTRUCTIONS["neutral"]
70
 
71
 
72
  class TTSService:
73
+ """OpenAI Text-to-Speech 服務(異步版)"""
74
 
75
  def __init__(self):
76
+ self._client: Optional[AsyncOpenAI] = None
77
+ self._initialized = False
78
+
79
+ @property
80
+ def client(self) -> Optional[AsyncOpenAI]:
81
+ """延遲初始化 AsyncOpenAI 客戶端"""
82
+ if not self._initialized:
83
+ api_key = settings.OPENAI_API_KEY
84
+ if api_key:
85
+ self._client = AsyncOpenAI(
86
+ api_key=api_key,
87
+ timeout=float(settings.OPENAI_TIMEOUT),
88
+ max_retries=3
89
+ )
90
+ logger.info("✅ TTS 服務初始化成功(AsyncOpenAI)")
91
+ else:
92
+ logger.error("❌ TTS 服務初始化失敗:OPENAI_API_KEY 未設置")
93
+ self._initialized = True
94
+ return self._client
95
 
96
  async def synthesize(
97
  self,
98
  text: str,
99
+ voice: VoiceType = "coral",
100
  model: str = "gpt-4o-mini-tts",
101
  speed: float = 1.0,
102
+ instruction: Optional[str] = None,
103
+ emotion: Optional[str] = None,
104
+ care_mode: bool = False,
105
+ response_format: AudioFormat = "mp3"
106
  ) -> Dict[str, Any]:
107
  """
108
+ 使用 OpenAI TTS API 將文字轉語音(非串流版
109
 
110
  Args:
111
  text: 要轉換的文字
112
+ voice: 聲音類型(coral, sage, verse, alloy, echo, fable, onyx, nova, shimmer)
113
  model: TTS 模型(gpt-4o-mini-tts 或 tts-1-hd)
114
  speed: 語速(0.25 到 4.0)
115
+ instruction: 語音指令(手動指定,優先級最高
116
+ emotion: 情緒標籤(自動選擇 instruction)
117
+ care_mode: 是否為關懷模式(使用特殊語氣)
118
+ response_format: 音頻格式(mp3, opus, aac, flac, wav, pcm)
119
 
120
  Returns:
121
  {
122
  "success": bool,
123
+ "audio_data": bytes,
124
  "voice": str,
125
+ "format": str,
126
+ "error": str (optional)
127
  }
128
  """
129
  if not self.client:
 
141
  "model": model,
142
  "voice": voice,
143
  "input": text,
144
+ "speed": speed,
145
+ "response_format": response_format
146
  }
147
 
148
+ # 選擇 instruction(優先級:手動 > 情緒自動選擇)
149
+ final_instruction = instruction or get_emotion_instruction(emotion, care_mode)
150
+
151
  # 如果提供情緒指令(gpt-4o-mini-tts 支援)
152
+ if final_instruction and model == "gpt-4o-mini-tts":
153
+ params["instructions"] = final_instruction
154
+ logger.info(f"🎭 TTS 語氣指令: {final_instruction}")
155
 
156
+ response = await self.client.audio.speech.create(**params)
157
 
158
+ # 獲取音頻數據
159
  audio_data = response.content
160
 
161
  logger.info(f"✅ TTS 合成成功,音頻大小: {len(audio_data)} bytes")
 
163
  return {
164
  "success": True,
165
  "audio_data": audio_data,
166
+ "voice": voice,
167
+ "format": response_format
168
  }
169
 
170
  except Exception as e:
 
175
  "error": str(e)
176
  }
177
 
178
+ async def synthesize_stream(
179
+ self,
180
+ text: str,
181
+ voice: VoiceType = "coral",
182
+ model: str = "gpt-4o-mini-tts",
183
+ speed: float = 1.0,
184
+ instruction: Optional[str] = None,
185
+ emotion: Optional[str] = None,
186
+ care_mode: bool = False,
187
+ response_format: AudioFormat = "pcm"
188
+ ) -> AsyncIterator[bytes]:
189
+ """
190
+ 使用 OpenAI TTS API 串流生成語音(邊生成邊播放,低延遲)
191
+
192
+ Args:
193
+ text: 要轉換的文字
194
+ voice: 聲音類型
195
+ model: TTS 模型
196
+ speed: 語速
197
+ instruction: 語音指令(手動指定,優先級最高)
198
+ emotion: 情緒標籤(自動選擇 instruction)
199
+ care_mode: 是否為關懷模式
200
+ response_format: 音頻格式(建議用 pcm 以獲得最低延遲)
201
+
202
+ Yields:
203
+ bytes: 音頻數據塊
204
+ """
205
+ if not self.client:
206
+ logger.error("❌ OpenAI client 未初始化")
207
+ return
208
+
209
+ try:
210
+ logger.info(f"🔊 開始 TTS 串流合成,文字長度: {len(text)}, 聲音: {voice}")
211
+
212
+ # 調用 OpenAI TTS API(串流模式)
213
+ params = {
214
+ "model": model,
215
+ "voice": voice,
216
+ "input": text,
217
+ "speed": speed,
218
+ "response_format": response_format
219
+ }
220
+
221
+ # 選擇 instruction(優先級:手動 > 情緒自動選擇)
222
+ final_instruction = instruction or get_emotion_instruction(emotion, care_mode)
223
+
224
+ if final_instruction and model == "gpt-4o-mini-tts":
225
+ params["instructions"] = final_instruction
226
+ logger.info(f"🎭 TTS 串流語氣指令: {final_instruction}")
227
+
228
+ async with self.client.audio.speech.with_streaming_response.create(**params) as response:
229
+ logger.info("✅ TTS 串流已啟動")
230
+
231
+ # 逐塊產出音頻數據
232
+ async for chunk in response.iter_bytes(chunk_size=4096):
233
+ if chunk:
234
+ yield chunk
235
+
236
+ logger.info("✅ TTS 串流完成")
237
+
238
+ except Exception as e:
239
+ logger.exception(f"❌ TTS 串流失敗: {e}")
240
+
241
+ async def play_locally(
242
+ self,
243
+ text: str,
244
+ voice: VoiceType = "coral",
245
+ model: str = "gpt-4o-mini-tts",
246
+ speed: float = 1.0,
247
+ instruction: Optional[str] = None
248
+ ) -> Dict[str, Any]:
249
+ """
250
+ 使用 LocalAudioPlayer 直接播放語音(本地測試用)
251
+
252
+ Args:
253
+ text: 要轉換的文字
254
+ voice: 聲音類型
255
+ model: TTS 模型
256
+ speed: 語速
257
+ instruction: 語音指令
258
+
259
+ Returns:
260
+ {
261
+ "success": bool,
262
+ "error": str (optional)
263
+ }
264
+ """
265
+ if not self.client:
266
+ return {
267
+ "success": False,
268
+ "error": "OpenAI client 未初始化"
269
+ }
270
+
271
+ try:
272
+ logger.info(f"🔊 開始本地播放,文字長度: {len(text)}, 聲音: {voice}")
273
+
274
+ params = {
275
+ "model": model,
276
+ "voice": voice,
277
+ "input": text,
278
+ "speed": speed,
279
+ "response_format": "pcm"
280
+ }
281
+
282
+ if instruction and model == "gpt-4o-mini-tts":
283
+ params["instructions"] = instruction
284
+
285
+ async with self.client.audio.speech.with_streaming_response.create(**params) as response:
286
+ await LocalAudioPlayer().play(response)
287
+
288
+ logger.info("✅ 本地播放完成")
289
+
290
+ return {
291
+ "success": True
292
+ }
293
+
294
+ except Exception as e:
295
+ logger.exception(f"❌ 本地播放失敗: {e}")
296
+ return {
297
+ "success": False,
298
+ "error": str(e)
299
+ }
300
+
301
 
302
  # 全域單例
303
  tts_service = TTSService()
 
305
 
306
  async def text_to_speech(
307
  text: str,
308
+ voice: VoiceType = "coral",
309
+ speed: float = 1.0,
310
+ instruction: Optional[str] = None
311
  ) -> Dict[str, Any]:
312
  """
313
+ 便捷函數:將文字轉為語音(非串流)
314
 
315
  Args:
316
  text: 要轉換的文字
317
+ voice: 聲音類型(coral, sage, verse, alloy, echo, fable, onyx, nova, shimmer)
318
  speed: 語速(0.25 到 4.0)
319
+ instruction: 語音指令(如「用溫柔、安慰的語氣說話」)
320
 
321
  Returns:
322
  {
323
  "success": bool,
324
  "audio_data": bytes,
325
  "voice": str,
326
+ "format": str,
327
  "error": str (optional)
328
  }
329
  """
330
+ return await tts_service.synthesize(text, voice, speed=speed, instruction=instruction)
331
+
332
+
333
+ async def text_to_speech_stream(
334
+ text: str,
335
+ voice: VoiceType = "coral",
336
+ speed: float = 1.0,
337
+ instruction: Optional[str] = None
338
+ ) -> AsyncIterator[bytes]:
339
+ """
340
+ 便捷函數:將文字轉為語音(串流模式,低延遲)
341
+
342
+ Args:
343
+ text: 要轉換的文字
344
+ voice: 聲音類型
345
+ speed: 語速
346
+ instruction: 語音指令
347
+
348
+ Yields:
349
+ bytes: 音頻數據塊
350
+ """
351
+ async for chunk in tts_service.synthesize_stream(text, voice, speed=speed, instruction=instruction):
352
+ yield chunk
353
+
354
+
355
+ async def test_tts_playback(
356
+ text: str = "今天是美好的一天!",
357
+ voice: VoiceType = "coral",
358
+ instruction: Optional[str] = "用開心、愉悅的語氣說話"
359
+ ) -> None:
360
+ """
361
+ 快速測試 TTS 播放(使用 LocalAudioPlayer)
362
+
363
+ Args:
364
+ text: 要播放的文字
365
+ voice: 聲音類型
366
+ instruction: 語音指令
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__":
376
+ # 測試範例:播放中文語音
377
+ asyncio.run(test_tts_playback(
378
+ text="你好!我是 BloomWare 智能助手,很高興為你服務!",
379
+ voice="coral",
380
+ instruction="用溫暖、友善的語氣說話"
381
+ ))
static/frontend/js/app.js CHANGED
@@ -76,7 +76,7 @@ async function checkLoginStatus() {
76
  /**
77
  * 初始化應用(登入後)
78
  */
79
- function initializeApp(token) {
80
  console.log('🚀 初始化應用...');
81
 
82
  // 初始化各個模組的事件監聽器
@@ -92,12 +92,69 @@ function initializeApp(token) {
92
  // 同步 MCP 工具 metadata
93
  syncToolMetadata();
94
 
 
 
 
95
  // 初始化 WebSocket
96
  initializeWebSocket(token);
97
 
98
  console.log('✅ 應用初始化完成');
99
  }
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  // ========== 頁面初始化 ==========
102
 
103
  // 只在聊天室頁面(/static/)執行登入檢查
 
76
  /**
77
  * 初始化應用(登入後)
78
  */
79
+ async function initializeApp(token) {
80
  console.log('🚀 初始化應用...');
81
 
82
  // 初始化各個模組的事件監聽器
 
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') {
122
+ showErrorNotification('需要麥克風權限才能使用語音功能,請在瀏覽器設定中允許');
123
+ } else {
124
+ alert('需要麥克風權限才能使用語音功能,請在瀏覽器設定中允許');
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) => {
139
+ console.warn('⚠️ 地理位置權限被拒絕:', error);
140
+ reject(error);
141
+ },
142
+ { enableHighAccuracy: false, timeout: 10000, maximumAge: 0 }
143
+ );
144
+ });
145
+ } catch (error) {
146
+ console.warn('⚠️ 地理位置權限被拒絕,部分功能(如查詢附近公車)將無法使用');
147
+ if (typeof showErrorNotification === 'function') {
148
+ showErrorNotification('建議允許地理位置權限以使用完整功能(如查詢附近公車、天氣等)');
149
+ }
150
+ }
151
+ } else {
152
+ console.warn('⚠️ 此瀏覽器不支援地理位置功能');
153
+ }
154
+
155
+ console.log('✅ 權限請求完成');
156
+ }
157
+
158
  // ========== 頁面初始化 ==========
159
 
160
  // 只在聊天室頁面(/static/)執行登入檢查
static/frontend/js/tools.js CHANGED
@@ -4,6 +4,115 @@ const positions = ['pos-top-right', 'pos-top-left', 'pos-bottom-right', 'pos-bot
4
  let usedPositions = [];
5
  const MAX_CARDS = 4;
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  // 抽屜相關元素
8
  let toolDrawer = null;
9
  let toolDrawerToggle = null;
@@ -488,6 +597,7 @@ function renderWeatherData(data) {
488
  const weather = data.weather?.[0] || {};
489
  const wind = data.wind || {};
490
  const sys = data.sys || {};
 
491
 
492
  // 格式化時間
493
  const formatTime = (timestamp) => {
@@ -498,35 +608,35 @@ function renderWeatherData(data) {
498
 
499
  return `
500
  <div class="data-row">
501
- <span class="data-label">🌡️ 溫度</span>
502
  <span class="data-value">${main.temp?.toFixed(1) || '--'}°C</span>
503
  </div>
504
  <div class="data-row">
505
- <span class="data-label">🤔 體感</span>
506
  <span class="data-value">${main.feels_like?.toFixed(1) || '--'}°C</span>
507
  </div>
508
  <div class="data-row">
509
- <span class="data-label">☁️ 狀況</span>
510
  <span class="data-value">${weather.description || '--'}</span>
511
  </div>
512
  <div class="data-row">
513
- <span class="data-label">💧 濕度</span>
514
  <span class="data-value">${main.humidity || '--'}%</span>
515
  </div>
516
  <div class="data-row">
517
- <span class="data-label">🌪️ 風速</span>
518
  <span class="data-value">${wind.speed?.toFixed(1) || '--'} m/s</span>
519
  </div>
520
  <div class="data-row">
521
- <span class="data-label">📊 氣壓</span>
522
  <span class="data-value">${main.pressure || '--'} hPa</span>
523
  </div>
524
  <div class="data-row">
525
- <span class="data-label">🌅 日出</span>
526
  <span class="data-value">${formatTime(sys.sunrise)}</span>
527
  </div>
528
  <div class="data-row">
529
- <span class="data-label">🌇 日落</span>
530
  <span class="data-value">${formatTime(sys.sunset)}</span>
531
  </div>
532
  `;
@@ -536,21 +646,15 @@ function renderWeatherData(data) {
536
  * 渲染健康指標
537
  */
538
  function renderHealthMetrics(healthData) {
 
 
539
  if (!healthData || healthData.length === 0) {
540
- return '<p class="data-row">無健康數據</p>';
541
  }
542
 
543
- const metricNames = {
544
- heart_rate: '❤️ 心率',
545
- step_count: '👟 步數',
546
- oxygen_level: '🫁 血氧',
547
- respiratory_rate: '💨 呼吸',
548
- sleep_analysis: '😴 睡眠'
549
- };
550
-
551
  const metricIcons = {
552
  heart_rate: '❤️',
553
- step_count: '👟',
554
  oxygen_level: '🫁',
555
  respiratory_rate: '💨',
556
  sleep_analysis: '😴'
@@ -571,7 +675,7 @@ function renderHealthMetrics(healthData) {
571
  // 渲染每種指標
572
  Object.entries(grouped).forEach(([metric, items], index) => {
573
  const icon = metricIcons[metric] || '📊';
574
- const label = metricNames[metric]?.replace(/^.+\s/, '') || metric;
575
  const latestItem = items[0]; // 最新的數據
576
  const value = latestItem.value;
577
  const unit = latestItem.unit || '';
@@ -600,13 +704,13 @@ function renderHealthMetrics(healthData) {
600
  </div>
601
  ${timeStr ? `
602
  <div class="data-row" style="opacity: 0.7;">
603
- <span class="data-label" style="font-size: 0.85em;">記錄時間</span>
604
  <span class="data-value" style="font-size: 0.85em;">${timeStr}</span>
605
  </div>
606
  ` : ''}
607
  ${items.length > 1 ? `
608
  <div class="data-row" style="opacity: 0.6;">
609
- <span class="data-label" style="font-size: 0.8em;">平均值</span>
610
  <span class="data-value" style="font-size: 0.8em;">${(items.reduce((sum, i) => sum + i.value, 0) / items.length).toFixed(1)} ${unit}</span>
611
  </div>
612
  ` : ''}
@@ -622,32 +726,35 @@ function renderHealthMetrics(healthData) {
622
  * 渲染新聞列表
623
  */
624
  function renderNewsList(articles) {
 
625
  let html = '';
626
  articles.slice(0, 3).forEach(article => {
627
  html += `
628
  <div class="data-row" style="flex-direction: column; align-items: flex-start; margin-bottom: 10px;">
629
- <span class="data-label" style="font-weight: bold;">${article.title || '無標題'}</span>
630
  <span class="data-value" style="font-size: 0.85em; opacity: 0.8;">${article.source?.name || article.source || ''}</span>
631
  </div>
632
  `;
633
  });
634
 
635
- return html || '<p>無新聞</p>';
636
  }
637
 
638
  /**
639
  * 渲染鍵值對(天氣等)
640
  */
641
  function renderKeyValuePairs(data) {
 
 
642
  const keyMap = {
643
- city: '城市',
644
- temp: '溫度',
645
- temperature: '溫度',
646
- condition: '狀況',
647
- weather: '天氣',
648
- humidity: '濕度',
649
- wind_speed: '風速',
650
- description: '描述'
651
  };
652
 
653
  let html = '';
@@ -680,6 +787,7 @@ function renderKeyValuePairs(data) {
680
  * 渲染匯率信息
681
  */
682
  function renderExchangeRate(data) {
 
683
  const currencySymbols = {
684
  "USD": "$", "TWD": "NT$", "JPY": "¥", "EUR": "€",
685
  "GBP": "£", "CNY": "¥", "KRW": "₩", "HKD": "HK$"
@@ -696,7 +804,7 @@ function renderExchangeRate(data) {
696
  if (data.rate !== undefined) {
697
  html += `
698
  <div class="data-row">
699
- <span class="data-label">💰 匯率</span>
700
  <span class="data-value">1 ${fromCurrency} = ${data.rate.toFixed(4)} ${toCurrency}</span>
701
  </div>
702
  `;
@@ -706,7 +814,7 @@ function renderExchangeRate(data) {
706
  if (data.amount && data.converted_amount !== undefined) {
707
  html += `
708
  <div class="data-row">
709
- <span class="data-label">🔄 轉換</span>
710
  <span class="data-value">${fromSymbol}${data.amount.toFixed(2)} = ${toSymbol}${data.converted_amount.toFixed(2)}</span>
711
  </div>
712
  `;
@@ -717,54 +825,56 @@ function renderExchangeRate(data) {
717
  const time = new Date(data.raw_data.metadata.timestamp).toLocaleString('zh-TW');
718
  html += `
719
  <div class="data-row">
720
- <span class="data-label">⏰ 時間</span>
721
  <span class="data-value">${time}</span>
722
  </div>
723
  `;
724
  }
725
 
726
- return html || '<p>無匯率數據</p>';
727
  }
728
 
729
  /**
730
  * 渲染火車列車資訊
731
  */
732
  function renderTrainList(trains) {
 
 
733
  if (!trains || trains.length === 0) {
734
- return '<p class="data-row">查無列車資訊</p>';
735
  }
736
 
737
  let html = '<div class="train-list">';
738
 
739
  trains.forEach((train, index) => {
740
- const trainType = train.train_type || '未知';
741
  const trainNo = train.train_no || '---';
742
  const departTime = train.departure_time ? train.departure_time.substring(0, 5) : '--:--';
743
  const arriveTime = train.arrival_time ? train.arrival_time.substring(0, 5) : '--:--';
744
- const duration = train.duration_min ? `${train.duration_min}分鐘` : '未知';
745
- const originStation = train.origin_station || '未知';
746
- const destStation = train.destination_station || '未知';
747
 
748
  html += `
749
  <div class="train-item" style="border-bottom: 1px solid #eee; padding: 12px 0; ${index === trains.length - 1 ? 'border-bottom: none;' : ''}">
750
  <div class="data-row" style="margin-bottom: 8px;">
751
- <span class="data-label" style="font-weight: bold; color: #0066cc;">🚂 ${trainType} ${trainNo}</span>
752
  </div>
753
  <div class="data-row">
754
- <span class="data-label">📍 起訖站</span>
755
  <span class="data-value">${originStation} → ${destStation}</span>
756
  </div>
757
  <div class="data-row">
758
- <span class="data-label">⏰ 出發</span>
759
  <span class="data-value">${departTime}</span>
760
  </div>
761
  <div class="data-row">
762
- <span class="data-label">⏱️ 抵達</span>
763
  <span class="data-value">${arriveTime}</span>
764
  </div>
765
  <div class="data-row">
766
- <span class="data-label">🕐 行駛時間</span>
767
- <span class="data-value">${duration}</span>
768
  </div>
769
  </div>
770
  `;
@@ -778,16 +888,19 @@ function renderTrainList(trains) {
778
  * 渲染火車站點資訊
779
  */
780
  function renderTrainStations(stations) {
 
 
781
  if (!stations || stations.length === 0) {
782
- return '<p class="data-row">查無車站資訊</p>';
783
  }
784
 
785
  let html = '<div class="station-list">';
786
 
787
  stations.forEach((station, index) => {
788
- const stationName = station.station_name || station.name || '未知車站';
789
- const distance = station.distance_m ? `${Math.round(station.distance_m)}公尺` : '';
790
- const walkTime = station.walking_time_min ? `步行約${station.walking_time_min}分鐘` : '';
 
791
 
792
  html += `
793
  <div class="station-item" style="border-bottom: 1px solid #eee; padding: 12px 0; ${index === stations.length - 1 ? 'border-bottom: none;' : ''}">
@@ -796,14 +909,14 @@ function renderTrainStations(stations) {
796
  </div>
797
  ${distance ? `
798
  <div class="data-row">
799
- <span class="data-label">📏 距離</span>
800
  <span class="data-value">${distance}</span>
801
  </div>
802
  ` : ''}
803
- ${walkTime ? `
804
  <div class="data-row">
805
- <span class="data-label">🚶 步行時間</span>
806
- <span class="data-value">${walkTime}</span>
807
  </div>
808
  ` : ''}
809
  </div>
@@ -818,29 +931,34 @@ function renderTrainStations(stations) {
818
  * 渲染 YouBike 站點資訊
819
  */
820
  function renderYouBikeStations(stations) {
 
 
821
  if (!stations || stations.length === 0) {
822
- return '<p class="data-row">附近無 YouBike 站點</p>';
823
  }
824
 
825
  let html = '<div class="youbike-list">';
826
 
827
  stations.forEach((station, index) => {
828
- const stationName = station.station_name || '未知站點';
829
  const availableBikes = station.available_bikes ?? 0;
830
  const availableSpaces = station.available_spaces ?? 0;
831
  const distance = station.distance_m || 0;
832
  const walkingTime = station.walking_time_min || 0;
833
  const bikeType = station.bike_type || 'YouBike';
834
- const serviceStatus = station.service_status === 1 ? '營運中' : '暫停服務';
 
 
 
835
 
836
  // 可借車輛狀態:0 = 紅色,1-3 = 橘色,>3 = 綠色
837
- let bikeStatusColor = '#e74c3c'; // 紅色
838
  let bikeStatusIcon = '🚫';
839
  if (availableBikes > 3) {
840
- bikeStatusColor = '#27ae60'; // 綠色
841
  bikeStatusIcon = '✅';
842
  } else if (availableBikes > 0) {
843
- bikeStatusColor = '#f39c12'; // 橘色
844
  bikeStatusIcon = '⚠️';
845
  }
846
 
@@ -850,19 +968,19 @@ function renderYouBikeStations(stations) {
850
  <span class="data-label" style="font-weight: bold; color: #e67e22;">🚲 ${stationName}</span>
851
  </div>
852
  <div class="data-row">
853
- <span class="data-label">📍 距離</span>
854
- <span class="data-value">${distance}m (步行約 ${walkingTime} 分鐘)</span>
855
  </div>
856
  <div class="data-row">
857
- <span class="data-label">🚴 可借車輛</span>
858
- <span class="data-value" style="color: ${bikeStatusColor}; font-weight: bold;">${bikeStatusIcon} ${availableBikes} </span>
859
  </div>
860
  <div class="data-row">
861
- <span class="data-label">🅿️ 可還空位</span>
862
- <span class="data-value">${availableSpaces} </span>
863
  </div>
864
  <div class="data-row">
865
- <span class="data-label">ℹ️ 類型</span>
866
  <span class="data-value">${bikeType} (${serviceStatus})</span>
867
  </div>
868
  </div>
@@ -877,8 +995,10 @@ function renderYouBikeStations(stations) {
877
  * 渲染公車到站資訊
878
  */
879
  function renderBusArrivals(arrivals, routeName) {
 
 
880
  if (!arrivals || arrivals.length === 0) {
881
- return '<p>目前無到站資訊</p>';
882
  }
883
 
884
  let html = '';
@@ -886,7 +1006,7 @@ function renderBusArrivals(arrivals, routeName) {
886
  // 按站點分組
887
  const stopGroups = {};
888
  arrivals.forEach(arr => {
889
- const stopName = arr.stop_name || '未知站點';
890
  if (!stopGroups[stopName]) {
891
  stopGroups[stopName] = [];
892
  }
@@ -907,11 +1027,11 @@ function renderBusArrivals(arrivals, routeName) {
907
  `;
908
 
909
  stopArrivals.forEach(arr => {
910
- const direction = arr.direction === 0 ? '往 ↑' : '返 ↓';
911
- const status = arr.status || '未知';
912
  html += `
913
  <div style="display: flex; justify-content: space-between; width: 100%; padding: 2px 0;">
914
- <span style="font-size: 0.9em; opacity: 0.8;">${direction}</span>
915
  <span class="data-value" style="font-size: 0.9em;">${status}</span>
916
  </div>
917
  `;
@@ -927,7 +1047,8 @@ function renderBusArrivals(arrivals, routeName) {
927
  * 渲染地理反查資訊(reverse_geocode)
928
  */
929
  function renderReverseGeocode(data) {
930
- const displayName = data.display_name || '未知地點';
 
931
  const city = data.city || '';
932
  const road = data.road || '';
933
  const houseNumber = data.house_number || '';
@@ -952,34 +1073,34 @@ function renderReverseGeocode(data) {
952
 
953
  return `
954
  <div class="data-row">
955
- <span class="data-label">📍 位置</span>
956
  <span class="data-value" style="font-weight: bold;">${displayName}</span>
957
  </div>
958
  ${city ? `
959
  <div class="data-row">
960
- <span class="data-label">🏙️ 城市</span>
961
  <span class="data-value">${city}</span>
962
  </div>
963
  ` : ''}
964
  ${road ? `
965
  <div class="data-row">
966
- <span class="data-label">🛣️ 道路</span>
967
  <span class="data-value">${road}${houseNumber ? ' ' + houseNumber : ''}</span>
968
  </div>
969
  ` : ''}
970
  ${suburb ? `
971
  <div class="data-row">
972
- <span class="data-label">🏘️ 區域</span>
973
  <span class="data-value">${suburb}</span>
974
  </div>
975
  ` : ''}
976
  <div class="data-row">
977
- <span class="data-label">🌐 座標</span>
978
  <span class="data-value" style="font-size: 0.85em;">${lat}, ${lon}</span>
979
  </div>
980
  <div class="data-row" style="margin-top: 8px;">
981
  <a href="${mapsUrl}" target="_blank" style="color: #0066cc; text-decoration: none; font-size: 0.9em;">
982
- 🗺️ Google Maps 中查看
983
  </a>
984
  </div>
985
  `;
@@ -989,21 +1110,23 @@ function renderReverseGeocode(data) {
989
  * 渲染附近公車站點
990
  */
991
  function renderNearbyStops(stops) {
 
 
992
  if (!stops || stops.length === 0) {
993
- return '<p>附近沒有公車站</p>';
994
  }
995
 
996
  let html = '';
997
  stops.slice(0, 5).forEach((stop, index) => {
998
- const stopName = stop.stop_name || '未知站點';
999
  const distance = stop.distance_m ? `${Math.round(stop.distance_m)}m` : '';
1000
- const walkTime = stop.walking_time_min ? `步行 ${stop.walking_time_min} 分` : '';
1001
 
1002
  html += `
1003
  <div class="data-row" style="margin-bottom: 8px;">
1004
  <div style="flex: 1;">
1005
  <div style="font-weight: 600; margin-bottom: 2px;">🚏 ${stopName}</div>
1006
- <div style="font-size: 0.85em; opacity: 0.7;">${walkTime} ${distance ? `(${distance})` : ''}</div>
1007
  </div>
1008
  </div>
1009
  `;
@@ -1016,17 +1139,22 @@ function renderNearbyStops(stops) {
1016
  * 渲染導航路線(directions)
1017
  */
1018
  function renderDirections(data) {
1019
- const originLabel = data.origin_label || '起點';
1020
- const destLabel = data.dest_label || '目的地';
 
1021
  const distanceM = data.distance_m;
1022
  const durationS = data.duration_s;
1023
 
1024
  // 格式化距離
1025
  let distanceStr = '--';
1026
  if (distanceM !== undefined) {
1027
- distanceStr = distanceM >= 1000
1028
- ? `${(distanceM / 1000).toFixed(1)} 公里`
1029
- : `${Math.round(distanceM)} 公尺`;
 
 
 
 
1030
  }
1031
 
1032
  // 格式化時間
@@ -1036,9 +1164,12 @@ function renderDirections(data) {
1036
  if (minutes >= 60) {
1037
  const hours = Math.floor(minutes / 60);
1038
  const mins = minutes % 60;
1039
- durationStr = mins > 0 ? `${hours} 小時 ${mins} 分鐘` : `${hours} `;
 
 
1040
  } else {
1041
- durationStr = `${minutes} 分鐘`;
 
1042
  }
1043
  }
1044
 
@@ -1049,7 +1180,7 @@ function renderDirections(data) {
1049
  mapsLink = `
1050
  <div class="data-row" style="margin-top: 8px;">
1051
  <a href="${mapsUrl}" target="_blank" style="color: #0066cc; text-decoration: none; font-size: 0.9em;">
1052
- 🗺️ Google Maps 中查看
1053
  </a>
1054
  </div>
1055
  `;
@@ -1057,19 +1188,19 @@ function renderDirections(data) {
1057
 
1058
  return `
1059
  <div class="data-row">
1060
- <span class="data-label">📍 起點</span>
1061
  <span class="data-value">${originLabel}</span>
1062
  </div>
1063
  <div class="data-row">
1064
- <span class="data-label">🎯 目的地</span>
1065
  <span class="data-value">${destLabel}</span>
1066
  </div>
1067
  <div class="data-row">
1068
- <span class="data-label">📏 距離</span>
1069
  <span class="data-value">${distanceStr}</span>
1070
  </div>
1071
  <div class="data-row">
1072
- <span class="data-label">⏱️ 預估時間</span>
1073
  <span class="data-value">${durationStr}</span>
1074
  </div>
1075
  ${mapsLink}
@@ -1080,8 +1211,10 @@ function renderDirections(data) {
1080
  * 渲染捷運到站資訊(tdx_metro arrivals)
1081
  */
1082
  function renderMetroArrivals(arrivals) {
 
 
1083
  if (!arrivals || arrivals.length === 0) {
1084
- return '<p class="data-row">目前無捷運到站資訊</p>';
1085
  }
1086
 
1087
  let html = '<div class="metro-arrivals">';
@@ -1089,7 +1222,7 @@ function renderMetroArrivals(arrivals) {
1089
  // 按路線分組
1090
  const lineGroups = {};
1091
  arrivals.forEach(arr => {
1092
- const lineName = arr.line_name || '未知路線';
1093
  if (!lineGroups[lineName]) {
1094
  lineGroups[lineName] = [];
1095
  }
@@ -1106,15 +1239,17 @@ function renderMetroArrivals(arrivals) {
1106
  `;
1107
 
1108
  lineArrivals.slice(0, 3).forEach(arr => {
1109
- const dest = arr.destination || '未知';
1110
  const timeSec = arr.arrival_time_sec;
1111
- const status = arr.train_status || '未知';
1112
 
1113
  let timeStr = status;
1114
  if (timeSec > 0) {
1115
  const min = Math.floor(timeSec / 60);
1116
  const sec = timeSec % 60;
1117
- timeStr = min > 0 ? `${min}${sec} 秒` : `${sec} 秒`;
 
 
1118
  }
1119
 
1120
  html += `
@@ -1136,16 +1271,19 @@ function renderMetroArrivals(arrivals) {
1136
  * 渲染捷運站點資訊(tdx_metro stations)
1137
  */
1138
  function renderMetroStations(stations) {
 
 
1139
  if (!stations || stations.length === 0) {
1140
- return '<p class="data-row">附近無捷運站</p>';
1141
  }
1142
 
1143
  let html = '<div class="metro-stations">';
1144
 
1145
  stations.forEach((station, index) => {
1146
- const stationName = station.station_name || '未知車站';
1147
- const distance = station.distance_m ? `${Math.round(station.distance_m)} 公尺` : '';
1148
- const walkTime = station.walking_time_min ? `步行約 ${station.walking_time_min} 分鐘` : '';
 
1149
  const address = station.address || '';
1150
 
1151
  html += `
@@ -1155,19 +1293,19 @@ function renderMetroStations(stations) {
1155
  </div>
1156
  ${distance ? `
1157
  <div class="data-row">
1158
- <span class="data-label">📏 距離</span>
1159
  <span class="data-value">${distance}</span>
1160
  </div>
1161
  ` : ''}
1162
- ${walkTime ? `
1163
  <div class="data-row">
1164
- <span class="data-label">🚶 步行時間</span>
1165
- <span class="data-value">${walkTime}</span>
1166
  </div>
1167
  ` : ''}
1168
  ${address ? `
1169
  <div class="data-row">
1170
- <span class="data-label">📍 地址</span>
1171
  <span class="data-value" style="font-size: 0.85em;">${address}</span>
1172
  </div>
1173
  ` : ''}
@@ -1183,7 +1321,8 @@ function renderMetroStations(stations) {
1183
  * 渲染正向地理編碼(forward_geocode)
1184
  */
1185
  function renderForwardGeocode(data) {
1186
- const displayName = data.display_name || '未知地點';
 
1187
  const lat = data.lat?.toFixed(6) || '';
1188
  const lon = data.lon?.toFixed(6) || '';
1189
  const city = data.city || '';
@@ -1195,34 +1334,34 @@ function renderForwardGeocode(data) {
1195
 
1196
  return `
1197
  <div class="data-row">
1198
- <span class="data-label">📍 地點</span>
1199
  <span class="data-value" style="font-weight: bold;">${displayName}</span>
1200
  </div>
1201
  ${city ? `
1202
  <div class="data-row">
1203
- <span class="data-label">🏙️ 城市</span>
1204
  <span class="data-value">${city}</span>
1205
  </div>
1206
  ` : ''}
1207
  ${road ? `
1208
  <div class="data-row">
1209
- <span class="data-label">🛣️ 道路</span>
1210
  <span class="data-value">${road}</span>
1211
  </div>
1212
  ` : ''}
1213
  ${suburb ? `
1214
  <div class="data-row">
1215
- <span class="data-label">🏘️ 區域</span>
1216
  <span class="data-value">${suburb}</span>
1217
  </div>
1218
  ` : ''}
1219
  <div class="data-row">
1220
- <span class="data-label">🌐 座標</span>
1221
  <span class="data-value" style="font-size: 0.85em;">${lat}, ${lon}</span>
1222
  </div>
1223
  <div class="data-row" style="margin-top: 8px;">
1224
  <a href="${mapsUrl}" target="_blank" style="color: #0066cc; text-decoration: none; font-size: 0.9em;">
1225
- 🗺️ Google Maps 中查看
1226
  </a>
1227
  </div>
1228
  `;
 
4
  let usedPositions = [];
5
  const MAX_CARDS = 4;
6
 
7
+ // 多語言標籤定義
8
+ const LABELS = {
9
+ zh: {
10
+ temperature: '溫度', condition: '狀況', humidity: '濕度', wind_speed: '風速',
11
+ weather: '天氣', city: '城市', description: '描述',
12
+ feels_like: '體感', pressure: '氣壓', sunrise: '日出', sunset: '日落',
13
+ heart_rate: '心率', step_count: '步數', oxygen_level: '血氧', respiratory_rate: '呼吸',
14
+ sleep_analysis: '睡眠', record_time: '記錄時間', average: '平均值',
15
+ no_news: '無新聞', no_data: '無數據', unknown: '未知',
16
+ exchange_rate: '匯率', conversion: '轉換', time: '時間',
17
+ train_type: '車種', origin_station: '起站', dest_station: '迄站',
18
+ departure: '出發', arrival: '抵達', duration: '行駛時間',
19
+ distance: '距離', walking_time: '步行時間', station: '車站',
20
+ available_bikes: '可借車輛', available_spaces: '可還空位',
21
+ bike_type: '類型', service_status: '服務狀態', operating: '營運中', suspended: '暫停服務',
22
+ location: '位置', coordinates: '座標', origin: '起點', destination: '目的地',
23
+ estimated_time: '預估時間', view_in_maps: '在 Google Maps 中查看',
24
+ line: '路線', address: '地址', road: '道路', area: '區域'
25
+ },
26
+ en: {
27
+ temperature: 'Temperature', condition: 'Condition', humidity: 'Humidity', wind_speed: 'Wind Speed',
28
+ weather: 'Weather', city: 'City', description: 'Description',
29
+ feels_like: 'Feels Like', pressure: 'Pressure', sunrise: 'Sunrise', sunset: 'Sunset',
30
+ heart_rate: 'Heart Rate', step_count: 'Steps', oxygen_level: 'Oxygen', respiratory_rate: 'Respiratory',
31
+ sleep_analysis: 'Sleep', record_time: 'Record Time', average: 'Average',
32
+ no_news: 'No News', no_data: 'No Data', unknown: 'Unknown',
33
+ exchange_rate: 'Exchange Rate', conversion: 'Conversion', time: 'Time',
34
+ train_type: 'Train Type', origin_station: 'Origin', dest_station: 'Destination',
35
+ departure: 'Departure', arrival: 'Arrival', duration: 'Duration',
36
+ distance: 'Distance', walking_time: 'Walking Time', station: 'Station',
37
+ available_bikes: 'Available Bikes', available_spaces: 'Available Spaces',
38
+ bike_type: 'Type', service_status: 'Service Status', operating: 'Operating', suspended: 'Suspended',
39
+ location: 'Location', coordinates: 'Coordinates', origin: 'Origin', destination: 'Destination',
40
+ estimated_time: 'Estimated Time', view_in_maps: 'View in Google Maps',
41
+ line: 'Line', address: 'Address', road: 'Road', area: 'Area'
42
+ },
43
+ ko: {
44
+ temperature: '온도', condition: '상태', humidity: '습도', wind_speed: '풍속',
45
+ weather: '날씨', city: '도시', description: '설명',
46
+ feels_like: '체감', pressure: '기압', sunrise: '일출', sunset: '일몰',
47
+ heart_rate: '심박수', step_count: '걸음 수', oxygen_level: '혈중 산소', respiratory_rate: '호흡',
48
+ sleep_analysis: '수면', record_time: '기록 시간', average: '평균',
49
+ no_news: '뉴스 없음', no_data: '데이터 없음', unknown: '알 수 없음',
50
+ exchange_rate: '환율', conversion: '환전', time: '시간',
51
+ train_type: '열차 종류', origin_station: '출발역', dest_station: '도착역',
52
+ departure: '출발', arrival: '도착', duration: '소요 시간',
53
+ distance: '거리', walking_time: '도보 시간', station: '역',
54
+ available_bikes: '대여 가능', available_spaces: '반납 가능',
55
+ bike_type: '유형', service_status: '서비스 상태', operating: '운영 중', suspended: '일시 중단',
56
+ location: '위치', coordinates: '좌표', origin: '출발지', destination: '목적지',
57
+ estimated_time: '예상 시간', view_in_maps: 'Google Maps에서 보기',
58
+ line: '노선', address: '주소', road: '도로', area: '지역'
59
+ },
60
+ ja: {
61
+ temperature: '気温', condition: '状況', humidity: '湿度', wind_speed: '風速',
62
+ weather: '天気', city: '都市', description: '説明',
63
+ feels_like: '体感', pressure: '気圧', sunrise: '日の出', sunset: '日の入り',
64
+ heart_rate: '心拍数', step_count: '歩数', oxygen_level: '血中酸素', respiratory_rate: '呼吸',
65
+ sleep_analysis: '睡眠', record_time: '記録時刻', average: '平均',
66
+ no_news: 'ニュースなし', no_data: 'データなし', unknown: '不明',
67
+ exchange_rate: '為替レート', conversion: '換算', time: '時刻',
68
+ train_type: '列車種別', origin_station: '出発駅', dest_station: '到着駅',
69
+ departure: '出発', arrival: '到着', duration: '所要時間',
70
+ distance: '距離', walking_time: '徒歩時間', station: '駅',
71
+ available_bikes: '利用可能', available_spaces: '返却可能',
72
+ bike_type: 'タイプ', service_status: 'サービス状態', operating: '運行中', suspended: '一時停止',
73
+ location: '場所', coordinates: '座標', origin: '出発地', destination: '目的地',
74
+ estimated_time: '予想時間', view_in_maps: 'Google Mapsで見る',
75
+ line: '路線', address: '住所', road: '道路', area: 'エリア'
76
+ },
77
+ id: {
78
+ temperature: 'Suhu', condition: 'Kondisi', humidity: 'Kelembaban', wind_speed: 'Kecepatan Angin',
79
+ weather: 'Cuaca', city: 'Kota', description: 'Deskripsi',
80
+ feels_like: 'Terasa', pressure: 'Tekanan', sunrise: 'Matahari Terbit', sunset: 'Matahari Terbenam',
81
+ heart_rate: 'Detak Jantung', step_count: 'Langkah', oxygen_level: 'Oksigen', respiratory_rate: 'Pernapasan',
82
+ sleep_analysis: 'Tidur', record_time: 'Waktu Rekam', average: 'Rata-rata',
83
+ no_news: 'Tidak Ada Berita', no_data: 'Tidak Ada Data', unknown: 'Tidak Diketahui',
84
+ exchange_rate: 'Nilai Tukar', conversion: 'Konversi', time: 'Waktu',
85
+ train_type: 'Jenis Kereta', origin_station: 'Stasiun Asal', dest_station: 'Stasiun Tujuan',
86
+ departure: 'Keberangkatan', arrival: 'Kedatangan', duration: 'Durasi',
87
+ distance: 'Jarak', walking_time: 'Waktu Jalan', station: 'Stasiun',
88
+ available_bikes: 'Sepeda Tersedia', available_spaces: 'Tempat Tersedia',
89
+ bike_type: 'Tipe', service_status: 'Status Layanan', operating: 'Beroperasi', suspended: 'Ditangguhkan',
90
+ location: 'Lokasi', coordinates: 'Koordinat', origin: 'Asal', destination: 'Tujuan',
91
+ estimated_time: 'Waktu Estimasi', view_in_maps: 'Lihat di Google Maps',
92
+ line: 'Jalur', address: 'Alamat', road: 'Jalan', area: 'Area'
93
+ },
94
+ vi: {
95
+ temperature: 'Nhiệt độ', condition: 'Tình trạng', humidity: 'Độ ẩm', wind_speed: 'Tốc độ gió',
96
+ weather: 'Thời tiết', city: 'Thành phố', description: 'Mô tả',
97
+ feels_like: 'Cảm giác', pressure: 'Áp suất', sunrise: 'Mặt trời mọc', sunset: 'Mặt trời lặn',
98
+ heart_rate: 'Nhịp tim', step_count: 'Số bước', oxygen_level: 'Oxy', respiratory_rate: 'Hô hấp',
99
+ sleep_analysis: 'Giấc ngủ', record_time: 'Thời gian ghi', average: 'Trung bình',
100
+ no_news: 'Không có tin', no_data: 'Không có dữ liệu', unknown: 'Không rõ',
101
+ exchange_rate: 'Tỷ giá', conversion: 'Chuyển đổi', time: 'Thời gian',
102
+ train_type: 'Loại tàu', origin_station: 'Ga đi', dest_station: 'Ga đến',
103
+ departure: 'Khởi hành', arrival: 'Đến', duration: 'Thời gian di chuyển',
104
+ distance: 'Khoảng cách', walking_time: 'Thời gian đi bộ', station: 'Ga',
105
+ available_bikes: 'Xe có sẵn', available_spaces: 'Chỗ trống',
106
+ bike_type: 'Loại', service_status: 'Trạng thái dịch vụ', operating: 'Hoạt động', suspended: 'Tạm ngừng',
107
+ location: 'Vị trí', coordinates: 'Tọa độ', origin: 'Điểm đi', destination: 'Điểm đến',
108
+ estimated_time: 'Thời gian ước tính', view_in_maps: 'Xem trên Google Maps',
109
+ line: 'Tuyến', address: 'Địa chỉ', road: 'Đường', area: 'Khu vực'
110
+ }
111
+ };
112
+
113
+ // 當前語言(從用戶訊息自動檢測)
114
+ let currentLanguage = 'zh';
115
+
116
  // 抽屜相關元素
117
  let toolDrawer = null;
118
  let toolDrawerToggle = null;
 
597
  const weather = data.weather?.[0] || {};
598
  const wind = data.wind || {};
599
  const sys = data.sys || {};
600
+ const labels = LABELS[currentLanguage] || LABELS.zh;
601
 
602
  // 格式化時間
603
  const formatTime = (timestamp) => {
 
608
 
609
  return `
610
  <div class="data-row">
611
+ <span class="data-label">🌡️ ${labels.temperature}</span>
612
  <span class="data-value">${main.temp?.toFixed(1) || '--'}°C</span>
613
  </div>
614
  <div class="data-row">
615
+ <span class="data-label">🤔 ${labels.feels_like}</span>
616
  <span class="data-value">${main.feels_like?.toFixed(1) || '--'}°C</span>
617
  </div>
618
  <div class="data-row">
619
+ <span class="data-label">☁️ ${labels.condition}</span>
620
  <span class="data-value">${weather.description || '--'}</span>
621
  </div>
622
  <div class="data-row">
623
+ <span class="data-label">💧 ${labels.humidity}</span>
624
  <span class="data-value">${main.humidity || '--'}%</span>
625
  </div>
626
  <div class="data-row">
627
+ <span class="data-label">🌪️ ${labels.wind_speed}</span>
628
  <span class="data-value">${wind.speed?.toFixed(1) || '--'} m/s</span>
629
  </div>
630
  <div class="data-row">
631
+ <span class="data-label">📊 ${labels.pressure}</span>
632
  <span class="data-value">${main.pressure || '--'} hPa</span>
633
  </div>
634
  <div class="data-row">
635
+ <span class="data-label">🌅 ${labels.sunrise}</span>
636
  <span class="data-value">${formatTime(sys.sunrise)}</span>
637
  </div>
638
  <div class="data-row">
639
+ <span class="data-label">🌇 ${labels.sunset}</span>
640
  <span class="data-value">${formatTime(sys.sunset)}</span>
641
  </div>
642
  `;
 
646
  * 渲染健康指標
647
  */
648
  function renderHealthMetrics(healthData) {
649
+ const labels = LABELS[currentLanguage] || LABELS.zh;
650
+
651
  if (!healthData || healthData.length === 0) {
652
+ return `<p class="data-row">${labels.no_data}</p>`;
653
  }
654
 
 
 
 
 
 
 
 
 
655
  const metricIcons = {
656
  heart_rate: '❤️',
657
+ step_count: '',
658
  oxygen_level: '🫁',
659
  respiratory_rate: '💨',
660
  sleep_analysis: '😴'
 
675
  // 渲染每種指標
676
  Object.entries(grouped).forEach(([metric, items], index) => {
677
  const icon = metricIcons[metric] || '📊';
678
+ const label = labels[metric] || metric;
679
  const latestItem = items[0]; // 最新的數據
680
  const value = latestItem.value;
681
  const unit = latestItem.unit || '';
 
704
  </div>
705
  ${timeStr ? `
706
  <div class="data-row" style="opacity: 0.7;">
707
+ <span class="data-label" style="font-size: 0.85em;">${labels.record_time}</span>
708
  <span class="data-value" style="font-size: 0.85em;">${timeStr}</span>
709
  </div>
710
  ` : ''}
711
  ${items.length > 1 ? `
712
  <div class="data-row" style="opacity: 0.6;">
713
+ <span class="data-label" style="font-size: 0.8em;">${labels.average}</span>
714
  <span class="data-value" style="font-size: 0.8em;">${(items.reduce((sum, i) => sum + i.value, 0) / items.length).toFixed(1)} ${unit}</span>
715
  </div>
716
  ` : ''}
 
726
  * 渲染新聞列表
727
  */
728
  function renderNewsList(articles) {
729
+ const labels = LABELS[currentLanguage] || LABELS.zh;
730
  let html = '';
731
  articles.slice(0, 3).forEach(article => {
732
  html += `
733
  <div class="data-row" style="flex-direction: column; align-items: flex-start; margin-bottom: 10px;">
734
+ <span class="data-label" style="font-weight: bold;">${article.title || labels.unknown}</span>
735
  <span class="data-value" style="font-size: 0.85em; opacity: 0.8;">${article.source?.name || article.source || ''}</span>
736
  </div>
737
  `;
738
  });
739
 
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,
751
+ temp: labels.temperature,
752
+ temperature: labels.temperature,
753
+ condition: labels.condition,
754
+ weather: labels.weather,
755
+ humidity: labels.humidity,
756
+ wind_speed: labels.wind_speed,
757
+ description: labels.description
758
  };
759
 
760
  let html = '';
 
787
  * 渲染匯率信息
788
  */
789
  function renderExchangeRate(data) {
790
+ const labels = LABELS[currentLanguage] || LABELS.zh;
791
  const currencySymbols = {
792
  "USD": "$", "TWD": "NT$", "JPY": "¥", "EUR": "€",
793
  "GBP": "£", "CNY": "¥", "KRW": "₩", "HKD": "HK$"
 
804
  if (data.rate !== undefined) {
805
  html += `
806
  <div class="data-row">
807
+ <span class="data-label">💰 ${labels.exchange_rate}</span>
808
  <span class="data-value">1 ${fromCurrency} = ${data.rate.toFixed(4)} ${toCurrency}</span>
809
  </div>
810
  `;
 
814
  if (data.amount && data.converted_amount !== undefined) {
815
  html += `
816
  <div class="data-row">
817
+ <span class="data-label">🔄 ${labels.conversion}</span>
818
  <span class="data-value">${fromSymbol}${data.amount.toFixed(2)} = ${toSymbol}${data.converted_amount.toFixed(2)}</span>
819
  </div>
820
  `;
 
825
  const time = new Date(data.raw_data.metadata.timestamp).toLocaleString('zh-TW');
826
  html += `
827
  <div class="data-row">
828
+ <span class="data-label">⏰ ${labels.time}</span>
829
  <span class="data-value">${time}</span>
830
  </div>
831
  `;
832
  }
833
 
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
+
843
  if (!trains || trains.length === 0) {
844
+ return `<p class="data-row">${labels.no_data}</p>`;
845
  }
846
 
847
  let html = '<div class="train-list">';
848
 
849
  trains.forEach((train, index) => {
850
+ const trainType = train.train_type || labels.unknown;
851
  const trainNo = train.train_no || '---';
852
  const departTime = train.departure_time ? train.departure_time.substring(0, 5) : '--:--';
853
  const arriveTime = train.arrival_time ? train.arrival_time.substring(0, 5) : '--:--';
854
+ const durationText = train.duration_min ? `${train.duration_min}${currentLanguage === 'zh' ? '分鐘' : currentLanguage === 'en' ? ' min' : currentLanguage === 'ko' ? '분' : currentLanguage === 'ja' ? '分' : currentLanguage === 'id' ? ' menit' : ' phút'}` : labels.unknown;
855
+ const originStation = train.origin_station || labels.unknown;
856
+ const destStation = train.destination_station || labels.unknown;
857
 
858
  html += `
859
  <div class="train-item" style="border-bottom: 1px solid #eee; padding: 12px 0; ${index === trains.length - 1 ? 'border-bottom: none;' : ''}">
860
  <div class="data-row" style="margin-bottom: 8px;">
861
+ <span class="data-label" style="font-weight: bold; color: #0066cc;">🚂 ${trainType} ${trainNo}</span>
862
  </div>
863
  <div class="data-row">
864
+ <span class="data-label">📍 ${labels.origin_station} → ${labels.dest_station}</span>
865
  <span class="data-value">${originStation} → ${destStation}</span>
866
  </div>
867
  <div class="data-row">
868
+ <span class="data-label">⏰ ${labels.departure}</span>
869
  <span class="data-value">${departTime}</span>
870
  </div>
871
  <div class="data-row">
872
+ <span class="data-label">⏱️ ${labels.arrival}</span>
873
  <span class="data-value">${arriveTime}</span>
874
  </div>
875
  <div class="data-row">
876
+ <span class="data-label">🕐 ${labels.duration}</span>
877
+ <span class="data-value">${durationText}</span>
878
  </div>
879
  </div>
880
  `;
 
888
  * 渲染火車站點資訊
889
  */
890
  function renderTrainStations(stations) {
891
+ const labels = LABELS[currentLanguage] || LABELS.zh;
892
+
893
  if (!stations || stations.length === 0) {
894
+ return `<p class="data-row">${labels.no_data}</p>`;
895
  }
896
 
897
  let html = '<div class="station-list">';
898
 
899
  stations.forEach((station, index) => {
900
+ const stationName = station.station_name || station.name || labels.unknown;
901
+ const distanceUnit = currentLanguage === 'zh' ? '公尺' : currentLanguage === 'en' ? 'm' : currentLanguage === 'ko' ? '미터' : currentLanguage === 'ja' ? 'メートル' : currentLanguage === 'id' ? 'm' : 'm';
902
+ const distance = station.distance_m ? `${Math.round(station.distance_m)}${distanceUnit}` : '';
903
+ const walkTimeText = station.walking_time_min ? `${currentLanguage === 'zh' ? '步行約' : ''}${station.walking_time_min}${currentLanguage === 'zh' ? '分鐘' : currentLanguage === 'en' ? ' min walk' : currentLanguage === 'ko' ? '분 도보' : currentLanguage === 'ja' ? '分 徒歩' : currentLanguage === 'id' ? ' menit jalan' : ' phút đi bộ'}` : '';
904
 
905
  html += `
906
  <div class="station-item" style="border-bottom: 1px solid #eee; padding: 12px 0; ${index === stations.length - 1 ? 'border-bottom: none;' : ''}">
 
909
  </div>
910
  ${distance ? `
911
  <div class="data-row">
912
+ <span class="data-label">📏 ${labels.distance}</span>
913
  <span class="data-value">${distance}</span>
914
  </div>
915
  ` : ''}
916
+ ${walkTimeText ? `
917
  <div class="data-row">
918
+ <span class="data-label">🚶 ${labels.walking_time}</span>
919
+ <span class="data-value">${walkTimeText}</span>
920
  </div>
921
  ` : ''}
922
  </div>
 
931
  * 渲染 YouBike 站點資訊
932
  */
933
  function renderYouBikeStations(stations) {
934
+ const labels = LABELS[currentLanguage] || LABELS.zh;
935
+
936
  if (!stations || stations.length === 0) {
937
+ return `<p class="data-row">${labels.no_data}</p>`;
938
  }
939
 
940
  let html = '<div class="youbike-list">';
941
 
942
  stations.forEach((station, index) => {
943
+ const stationName = station.station_name || labels.unknown;
944
  const availableBikes = station.available_bikes ?? 0;
945
  const availableSpaces = station.available_spaces ?? 0;
946
  const distance = station.distance_m || 0;
947
  const walkingTime = station.walking_time_min || 0;
948
  const bikeType = station.bike_type || 'YouBike';
949
+ const serviceStatus = station.service_status === 1 ? labels.operating : labels.suspended;
950
+ const walkText = currentLanguage === 'zh' ? `步行約 ${walkingTime} 分鐘` : currentLanguage === 'en' ? `${walkingTime} min walk` : currentLanguage === 'ko' ? `도보 ${walkingTime}분` : currentLanguage === 'ja' ? `徒歩${walkingTime}分` : currentLanguage === 'id' ? `${walkingTime} menit jalan` : `${walkingTime} phút đi bộ`;
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) {
958
+ bikeStatusColor = '#27ae60';
959
  bikeStatusIcon = '✅';
960
  } else if (availableBikes > 0) {
961
+ bikeStatusColor = '#f39c12';
962
  bikeStatusIcon = '⚠️';
963
  }
964
 
 
968
  <span class="data-label" style="font-weight: bold; color: #e67e22;">🚲 ${stationName}</span>
969
  </div>
970
  <div class="data-row">
971
+ <span class="data-label">📍 ${labels.distance}</span>
972
+ <span class="data-value">${distance}m (${walkText})</span>
973
  </div>
974
  <div class="data-row">
975
+ <span class="data-label">🚴 ${labels.available_bikes}</span>
976
+ <span class="data-value" style="color: ${bikeStatusColor}; font-weight: bold;">${bikeStatusIcon} ${availableBikes} ${bikeUnit}</span>
977
  </div>
978
  <div class="data-row">
979
+ <span class="data-label">🅿️ ${labels.available_spaces}</span>
980
+ <span class="data-value">${availableSpaces} ${spaceUnit}</span>
981
  </div>
982
  <div class="data-row">
983
+ <span class="data-label">ℹ️ ${labels.bike_type}</span>
984
  <span class="data-value">${bikeType} (${serviceStatus})</span>
985
  </div>
986
  </div>
 
995
  * 渲染公車到站資訊
996
  */
997
  function renderBusArrivals(arrivals, routeName) {
998
+ const labels = LABELS[currentLanguage] || LABELS.zh;
999
+
1000
  if (!arrivals || arrivals.length === 0) {
1001
+ return `<p>${labels.no_data}</p>`;
1002
  }
1003
 
1004
  let html = '';
 
1006
  // 按站點分組
1007
  const stopGroups = {};
1008
  arrivals.forEach(arr => {
1009
+ const stopName = arr.stop_name || labels.unknown;
1010
  if (!stopGroups[stopName]) {
1011
  stopGroups[stopName] = [];
1012
  }
 
1027
  `;
1028
 
1029
  stopArrivals.forEach(arr => {
1030
+ const directionText = arr.direction === 0 ? (currentLanguage === 'zh' ? '往 ↑' : currentLanguage === 'en' ? 'To ↑' : currentLanguage === 'ko' ? '방향 ↑' : currentLanguage === 'ja' ? '行き ↑' : currentLanguage === 'id' ? 'Ke ↑' : 'Đến ↑') : (currentLanguage === 'zh' ? '返 ↓' : currentLanguage === 'en' ? 'Return ↓' : currentLanguage === 'ko' ? '회차 ↓' : currentLanguage === 'ja' ? '戻り ↓' : currentLanguage === 'id' ? 'Kembali ↓' : 'Về ↓');
1031
+ const status = arr.status || labels.unknown;
1032
  html += `
1033
  <div style="display: flex; justify-content: space-between; width: 100%; padding: 2px 0;">
1034
+ <span style="font-size: 0.9em; opacity: 0.8;">${directionText}</span>
1035
  <span class="data-value" style="font-size: 0.9em;">${status}</span>
1036
  </div>
1037
  `;
 
1047
  * 渲染地理反查資訊(reverse_geocode)
1048
  */
1049
  function renderReverseGeocode(data) {
1050
+ const labels = LABELS[currentLanguage] || LABELS.zh;
1051
+ const displayName = data.display_name || labels.unknown;
1052
  const city = data.city || '';
1053
  const road = data.road || '';
1054
  const houseNumber = data.house_number || '';
 
1073
 
1074
  return `
1075
  <div class="data-row">
1076
+ <span class="data-label">📍 ${labels.location}</span>
1077
  <span class="data-value" style="font-weight: bold;">${displayName}</span>
1078
  </div>
1079
  ${city ? `
1080
  <div class="data-row">
1081
+ <span class="data-label">🏙️ ${labels.city}</span>
1082
  <span class="data-value">${city}</span>
1083
  </div>
1084
  ` : ''}
1085
  ${road ? `
1086
  <div class="data-row">
1087
+ <span class="data-label">🛣️ ${labels.road}</span>
1088
  <span class="data-value">${road}${houseNumber ? ' ' + houseNumber : ''}</span>
1089
  </div>
1090
  ` : ''}
1091
  ${suburb ? `
1092
  <div class="data-row">
1093
+ <span class="data-label">🏘️ ${labels.area}</span>
1094
  <span class="data-value">${suburb}</span>
1095
  </div>
1096
  ` : ''}
1097
  <div class="data-row">
1098
+ <span class="data-label">🌐 ${labels.coordinates}</span>
1099
  <span class="data-value" style="font-size: 0.85em;">${lat}, ${lon}</span>
1100
  </div>
1101
  <div class="data-row" style="margin-top: 8px;">
1102
  <a href="${mapsUrl}" target="_blank" style="color: #0066cc; text-decoration: none; font-size: 0.9em;">
1103
+ 🗺️ ${labels.view_in_maps}
1104
  </a>
1105
  </div>
1106
  `;
 
1110
  * 渲染附近公車站點
1111
  */
1112
  function renderNearbyStops(stops) {
1113
+ const labels = LABELS[currentLanguage] || LABELS.zh;
1114
+
1115
  if (!stops || stops.length === 0) {
1116
+ return `<p>${labels.no_data}</p>`;
1117
  }
1118
 
1119
  let html = '';
1120
  stops.slice(0, 5).forEach((stop, index) => {
1121
+ const stopName = stop.stop_name || labels.unknown;
1122
  const distance = stop.distance_m ? `${Math.round(stop.distance_m)}m` : '';
1123
+ const walkTimeText = stop.walking_time_min ? `${currentLanguage === 'zh' ? '步行 ' : ''}${stop.walking_time_min}${currentLanguage === 'zh' ? ' ' : currentLanguage === 'en' ? ' min walk' : currentLanguage === 'ko' ? '분 도보' : currentLanguage === 'ja' ? '分 徒歩' : currentLanguage === 'id' ? ' menit jalan' : ' phút đi bộ'}` : '';
1124
 
1125
  html += `
1126
  <div class="data-row" style="margin-bottom: 8px;">
1127
  <div style="flex: 1;">
1128
  <div style="font-weight: 600; margin-bottom: 2px;">🚏 ${stopName}</div>
1129
+ <div style="font-size: 0.85em; opacity: 0.7;">${walkTimeText} ${distance ? `(${distance})` : ''}</div>
1130
  </div>
1131
  </div>
1132
  `;
 
1139
  * 渲染導航路線(directions)
1140
  */
1141
  function renderDirections(data) {
1142
+ const labels = LABELS[currentLanguage] || LABELS.zh;
1143
+ const originLabel = data.origin_label || labels.origin;
1144
+ const destLabel = data.dest_label || labels.destination;
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) {
1152
+ const kmUnit = currentLanguage === 'zh' ? '公里' : currentLanguage === 'en' ? ' km' : currentLanguage === 'ko' ? '킬로미터' : currentLanguage === 'ja' ? 'キロ' : currentLanguage === 'id' ? ' km' : ' km';
1153
+ distanceStr = `${(distanceM / 1000).toFixed(1)}${kmUnit}`;
1154
+ } else {
1155
+ const mUnit = currentLanguage === 'zh' ? '公尺' : currentLanguage === 'en' ? ' m' : currentLanguage === 'ko' ? '미터' : currentLanguage === 'ja' ? 'メートル' : currentLanguage === 'id' ? ' m' : ' m';
1156
+ distanceStr = `${Math.round(distanceM)}${mUnit}`;
1157
+ }
1158
  }
1159
 
1160
  // 格式化時間
 
1164
  if (minutes >= 60) {
1165
  const hours = Math.floor(minutes / 60);
1166
  const mins = minutes % 60;
1167
+ const hourUnit = currentLanguage === 'zh' ? '小時' : currentLanguage === 'en' ? ' hr' : currentLanguage === 'ko' ? '시간' : currentLanguage === 'ja' ? '間' : currentLanguage === 'id' ? ' jam' : ' giờ';
1168
+ const minUnit = currentLanguage === 'zh' ? '分鐘' : currentLanguage === 'en' ? ' min' : currentLanguage === 'ko' ? '분' : currentLanguage === 'ja' ? '分' : currentLanguage === 'id' ? ' menit' : ' phút';
1169
+ durationStr = mins > 0 ? `${hours}${hourUnit} ${mins}${minUnit}` : `${hours}${hourUnit}`;
1170
  } else {
1171
+ const minUnit = currentLanguage === 'zh' ? '分鐘' : currentLanguage === 'en' ? ' min' : currentLanguage === 'ko' ? '분' : currentLanguage === 'ja' ? '分' : currentLanguage === 'id' ? ' menit' : ' phút';
1172
+ durationStr = `${minutes}${minUnit}`;
1173
  }
1174
  }
1175
 
 
1180
  mapsLink = `
1181
  <div class="data-row" style="margin-top: 8px;">
1182
  <a href="${mapsUrl}" target="_blank" style="color: #0066cc; text-decoration: none; font-size: 0.9em;">
1183
+ 🗺️ ${labels.view_in_maps}
1184
  </a>
1185
  </div>
1186
  `;
 
1188
 
1189
  return `
1190
  <div class="data-row">
1191
+ <span class="data-label">📍 ${labels.origin}</span>
1192
  <span class="data-value">${originLabel}</span>
1193
  </div>
1194
  <div class="data-row">
1195
+ <span class="data-label">🎯 ${labels.destination}</span>
1196
  <span class="data-value">${destLabel}</span>
1197
  </div>
1198
  <div class="data-row">
1199
+ <span class="data-label">📏 ${labels.distance}</span>
1200
  <span class="data-value">${distanceStr}</span>
1201
  </div>
1202
  <div class="data-row">
1203
+ <span class="data-label">⏱️ ${labels.estimated_time}</span>
1204
  <span class="data-value">${durationStr}</span>
1205
  </div>
1206
  ${mapsLink}
 
1211
  * 渲染捷運到站資訊(tdx_metro arrivals)
1212
  */
1213
  function renderMetroArrivals(arrivals) {
1214
+ const labels = LABELS[currentLanguage] || LABELS.zh;
1215
+
1216
  if (!arrivals || arrivals.length === 0) {
1217
+ return `<p class="data-row">${labels.no_data}</p>`;
1218
  }
1219
 
1220
  let html = '<div class="metro-arrivals">';
 
1222
  // 按路線分組
1223
  const lineGroups = {};
1224
  arrivals.forEach(arr => {
1225
+ const lineName = arr.line_name || labels.unknown;
1226
  if (!lineGroups[lineName]) {
1227
  lineGroups[lineName] = [];
1228
  }
 
1239
  `;
1240
 
1241
  lineArrivals.slice(0, 3).forEach(arr => {
1242
+ const dest = arr.destination || labels.unknown;
1243
  const timeSec = arr.arrival_time_sec;
1244
+ const status = arr.train_status || labels.unknown;
1245
 
1246
  let timeStr = status;
1247
  if (timeSec > 0) {
1248
  const min = Math.floor(timeSec / 60);
1249
  const sec = timeSec % 60;
1250
+ const minUnit = currentLanguage === 'zh' ? '分' : currentLanguage === 'en' ? ' min' : currentLanguage === 'ko' ? '분' : currentLanguage === 'ja' ? '' : currentLanguage === 'id' ? ' menit' : ' phút';
1251
+ const secUnit = currentLanguage === 'zh' ? '秒' : currentLanguage === 'en' ? ' sec' : currentLanguage === 'ko' ? '초' : currentLanguage === 'ja' ? '秒' : currentLanguage === 'id' ? ' detik' : ' giây';
1252
+ timeStr = min > 0 ? `${min}${minUnit} ${sec}${secUnit}` : `${sec}${secUnit}`;
1253
  }
1254
 
1255
  html += `
 
1271
  * 渲染捷運站點資訊(tdx_metro stations)
1272
  */
1273
  function renderMetroStations(stations) {
1274
+ const labels = LABELS[currentLanguage] || LABELS.zh;
1275
+
1276
  if (!stations || stations.length === 0) {
1277
+ return `<p class="data-row">${labels.no_data}</p>`;
1278
  }
1279
 
1280
  let html = '<div class="metro-stations">';
1281
 
1282
  stations.forEach((station, index) => {
1283
+ const stationName = station.station_name || labels.unknown;
1284
+ const distanceUnit = currentLanguage === 'zh' ? '公尺' : currentLanguage === 'en' ? 'm' : currentLanguage === 'ko' ? '미터' : currentLanguage === 'ja' ? 'メートル' : currentLanguage === 'id' ? 'm' : 'm';
1285
+ const distance = station.distance_m ? `${Math.round(station.distance_m)} ${distanceUnit}` : '';
1286
+ const walkTimeText = station.walking_time_min ? `${currentLanguage === 'zh' ? '步行約 ' : ''}${station.walking_time_min}${currentLanguage === 'zh' ? ' 分鐘' : currentLanguage === 'en' ? ' min walk' : currentLanguage === 'ko' ? '분 도보' : currentLanguage === 'ja' ? '分 徒歩' : currentLanguage === 'id' ? ' menit jalan' : ' phút đi bộ'}` : '';
1287
  const address = station.address || '';
1288
 
1289
  html += `
 
1293
  </div>
1294
  ${distance ? `
1295
  <div class="data-row">
1296
+ <span class="data-label">📏 ${labels.distance}</span>
1297
  <span class="data-value">${distance}</span>
1298
  </div>
1299
  ` : ''}
1300
+ ${walkTimeText ? `
1301
  <div class="data-row">
1302
+ <span class="data-label">🚶 ${labels.walking_time}</span>
1303
+ <span class="data-value">${walkTimeText}</span>
1304
  </div>
1305
  ` : ''}
1306
  ${address ? `
1307
  <div class="data-row">
1308
+ <span class="data-label">📍 ${labels.address}</span>
1309
  <span class="data-value" style="font-size: 0.85em;">${address}</span>
1310
  </div>
1311
  ` : ''}
 
1321
  * 渲染正向地理編碼(forward_geocode)
1322
  */
1323
  function renderForwardGeocode(data) {
1324
+ const labels = LABELS[currentLanguage] || LABELS.zh;
1325
+ const displayName = data.display_name || labels.unknown;
1326
  const lat = data.lat?.toFixed(6) || '';
1327
  const lon = data.lon?.toFixed(6) || '';
1328
  const city = data.city || '';
 
1334
 
1335
  return `
1336
  <div class="data-row">
1337
+ <span class="data-label">📍 ${labels.location}</span>
1338
  <span class="data-value" style="font-weight: bold;">${displayName}</span>
1339
  </div>
1340
  ${city ? `
1341
  <div class="data-row">
1342
+ <span class="data-label">🏙️ ${labels.city}</span>
1343
  <span class="data-value">${city}</span>
1344
  </div>
1345
  ` : ''}
1346
  ${road ? `
1347
  <div class="data-row">
1348
+ <span class="data-label">🛣️ ${labels.road}</span>
1349
  <span class="data-value">${road}</span>
1350
  </div>
1351
  ` : ''}
1352
  ${suburb ? `
1353
  <div class="data-row">
1354
+ <span class="data-label">🏘️ ${labels.area}</span>
1355
  <span class="data-value">${suburb}</span>
1356
  </div>
1357
  ` : ''}
1358
  <div class="data-row">
1359
+ <span class="data-label">🌐 ${labels.coordinates}</span>
1360
  <span class="data-value" style="font-size: 0.85em;">${lat}, ${lon}</span>
1361
  </div>
1362
  <div class="data-row" style="margin-top: 8px;">
1363
  <a href="${mapsUrl}" target="_blank" style="color: #0066cc; text-decoration: none; font-size: 0.9em;">
1364
+ 🗺️ ${labels.view_in_maps}
1365
  </a>
1366
  </div>
1367
  `;
static/frontend/js/websocket.js CHANGED
@@ -331,12 +331,15 @@ class WebSocketManager {
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,
338
- mode: 'realtime_chat' // 即時轉錄模式(使用 OpenAI Realtime API)
 
339
  });
 
 
340
 
341
  this.isRecording = true;
342
 
 
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,
338
+ mode: 'realtime_chat', // 即時轉錄模式(使用 OpenAI Realtime API)
339
+ language: 'auto' // 自動檢測語言(支援:zh/en/id/ja/vi)
340
  });
341
+
342
+ console.log('🌐 使用自動語言檢測');
343
 
344
  this.isRecording = true;
345