Spaces:
Sleeping
Sleeping
Commit ·
91f3927
1
Parent(s): feabc9f
Good
Browse files- app.py +77 -175
- core/intent_detector.py +37 -13
- core/pipeline.py +264 -4
- features/mcp/agent_bridge.py +16 -40
- features/mcp/tools/directions_tool.py +1 -1
- features/mcp/tools/exchange_tool.py +1 -1
- features/mcp/tools/geocode_tool.py +1 -1
- features/mcp/tools/geocoding_tool.py +1 -1
- features/mcp/tools/healthkit_tool.py +1 -1
- features/mcp/tools/news_tool.py +1 -1
- features/mcp/tools/tdx_bus_arrival.py +1 -1
- features/mcp/tools/tdx_metro.py +1 -1
- features/mcp/tools/tdx_parking.py +1 -1
- features/mcp/tools/tdx_thsr.py +1 -1
- features/mcp/tools/tdx_train.py +1 -1
- features/mcp/tools/tdx_youbike.py +1 -1
- features/mcp/tools/weather_tool.py +1 -1
- models/speaker_identification/scripts/inference.py +1 -1
- routers/voice.py +5 -1
- services/ai_service.py +49 -7
- services/audio_emotion_service.py +175 -0
- services/realtime_stt_service.py +91 -7
- services/stt_service.py +0 -182
- services/tts_service.py +285 -34
- static/frontend/js/app.js +58 -1
- static/frontend/js/tools.js +255 -116
- static/frontend/js/websocket.js +5 -2
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=
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 222 |
-
|
| 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 = "
|
| 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 = "
|
| 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 = "
|
| 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 = "
|
| 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 = "
|
| 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.
|
| 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 = "
|
| 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 |
-
"- 回覆用戶時:必須使用
|
| 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 |
-
"- 回覆用戶時:必須使用
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 76 |
session_config = {
|
| 77 |
"type": "transcription_session.update",
|
| 78 |
-
"session": {
|
| 79 |
"input_audio_format": "pcm16",
|
| 80 |
"input_audio_transcription": {
|
| 81 |
"model": model,
|
| 82 |
-
"prompt":
|
| 83 |
-
|
| 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 |
-
使用
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
import os
|
| 7 |
import logging
|
| 8 |
-
|
| 9 |
-
from
|
|
|
|
|
|
|
| 10 |
from dotenv import load_dotenv
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
load_dotenv()
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
#
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
class TTSService:
|
| 28 |
-
"""OpenAI Text-to-Speech 服務"""
|
| 29 |
|
| 30 |
def __init__(self):
|
| 31 |
-
self.
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
async def synthesize(
|
| 36 |
self,
|
| 37 |
text: str,
|
| 38 |
-
voice: VoiceType = "
|
| 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 將文字轉語音(
|
| 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,
|
| 57 |
"voice": str,
|
| 58 |
-
"
|
|
|
|
| 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
|
| 81 |
-
params["
|
|
|
|
| 82 |
|
| 83 |
-
response = self.client.audio.speech.create(**params)
|
| 84 |
|
| 85 |
-
# 獲取音頻數據
|
| 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 = "
|
| 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">🌡️
|
| 502 |
<span class="data-value">${main.temp?.toFixed(1) || '--'}°C</span>
|
| 503 |
</div>
|
| 504 |
<div class="data-row">
|
| 505 |
-
<span class="data-label">🤔
|
| 506 |
<span class="data-value">${main.feels_like?.toFixed(1) || '--'}°C</span>
|
| 507 |
</div>
|
| 508 |
<div class="data-row">
|
| 509 |
-
<span class="data-label">☁️
|
| 510 |
<span class="data-value">${weather.description || '--'}</span>
|
| 511 |
</div>
|
| 512 |
<div class="data-row">
|
| 513 |
-
<span class="data-label">💧
|
| 514 |
<span class="data-value">${main.humidity || '--'}%</span>
|
| 515 |
</div>
|
| 516 |
<div class="data-row">
|
| 517 |
-
<span class="data-label">🌪️
|
| 518 |
<span class="data-value">${wind.speed?.toFixed(1) || '--'} m/s</span>
|
| 519 |
</div>
|
| 520 |
<div class="data-row">
|
| 521 |
-
<span class="data-label">📊
|
| 522 |
<span class="data-value">${main.pressure || '--'} hPa</span>
|
| 523 |
</div>
|
| 524 |
<div class="data-row">
|
| 525 |
-
<span class="data-label">🌅
|
| 526 |
<span class="data-value">${formatTime(sys.sunrise)}</span>
|
| 527 |
</div>
|
| 528 |
<div class="data-row">
|
| 529 |
-
<span class="data-label">🌇
|
| 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
|
| 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 =
|
| 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;">
|
| 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;">
|
| 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 ||
|
| 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 ||
|
| 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">💰
|
| 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">🔄
|
| 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">⏰
|
| 721 |
<span class="data-value">${time}</span>
|
| 722 |
</div>
|
| 723 |
`;
|
| 724 |
}
|
| 725 |
|
| 726 |
-
return html ||
|
| 727 |
}
|
| 728 |
|
| 729 |
/**
|
| 730 |
* 渲染火車列車資訊
|
| 731 |
*/
|
| 732 |
function renderTrainList(trains) {
|
|
|
|
|
|
|
| 733 |
if (!trains || trains.length === 0) {
|
| 734 |
-
return
|
| 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
|
| 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}
|
| 752 |
</div>
|
| 753 |
<div class="data-row">
|
| 754 |
-
<span class="data-label">📍
|
| 755 |
<span class="data-value">${originStation} → ${destStation}</span>
|
| 756 |
</div>
|
| 757 |
<div class="data-row">
|
| 758 |
-
<span class="data-label">⏰
|
| 759 |
<span class="data-value">${departTime}</span>
|
| 760 |
</div>
|
| 761 |
<div class="data-row">
|
| 762 |
-
<span class="data-label">⏱️
|
| 763 |
<span class="data-value">${arriveTime}</span>
|
| 764 |
</div>
|
| 765 |
<div class="data-row">
|
| 766 |
-
<span class="data-label">🕐
|
| 767 |
-
<span class="data-value">${
|
| 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
|
| 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
|
| 790 |
-
const
|
|
|
|
| 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">📏
|
| 800 |
<span class="data-value">${distance}</span>
|
| 801 |
</div>
|
| 802 |
` : ''}
|
| 803 |
-
${
|
| 804 |
<div class="data-row">
|
| 805 |
-
<span class="data-label">🚶
|
| 806 |
-
<span class="data-value">${
|
| 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
|
| 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">📍
|
| 854 |
-
<span class="data-value">${distance}m (
|
| 855 |
</div>
|
| 856 |
<div class="data-row">
|
| 857 |
-
<span class="data-label">🚴
|
| 858 |
-
<span class="data-value" style="color: ${bikeStatusColor}; font-weight: bold;">${bikeStatusIcon} ${availableBikes}
|
| 859 |
</div>
|
| 860 |
<div class="data-row">
|
| 861 |
-
<span class="data-label">🅿️
|
| 862 |
-
<span class="data-value">${availableSpaces}
|
| 863 |
</div>
|
| 864 |
<div class="data-row">
|
| 865 |
-
<span class="data-label">ℹ️
|
| 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
|
| 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
|
| 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;">${
|
| 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
|
|
|
|
| 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">📍
|
| 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">🏙️
|
| 961 |
<span class="data-value">${city}</span>
|
| 962 |
</div>
|
| 963 |
` : ''}
|
| 964 |
${road ? `
|
| 965 |
<div class="data-row">
|
| 966 |
-
<span class="data-label">🛣️
|
| 967 |
<span class="data-value">${road}${houseNumber ? ' ' + houseNumber : ''}</span>
|
| 968 |
</div>
|
| 969 |
` : ''}
|
| 970 |
${suburb ? `
|
| 971 |
<div class="data-row">
|
| 972 |
-
<span class="data-label">🏘️
|
| 973 |
<span class="data-value">${suburb}</span>
|
| 974 |
</div>
|
| 975 |
` : ''}
|
| 976 |
<div class="data-row">
|
| 977 |
-
<span class="data-label">🌐
|
| 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 |
-
🗺️
|
| 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
|
| 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
|
| 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;">${
|
| 1007 |
</div>
|
| 1008 |
</div>
|
| 1009 |
`;
|
|
@@ -1016,17 +1139,22 @@ function renderNearbyStops(stops) {
|
|
| 1016 |
* 渲染導航路線(directions)
|
| 1017 |
*/
|
| 1018 |
function renderDirections(data) {
|
| 1019 |
-
const
|
| 1020 |
-
const
|
|
|
|
| 1021 |
const distanceM = data.distance_m;
|
| 1022 |
const durationS = data.duration_s;
|
| 1023 |
|
| 1024 |
// 格式化距離
|
| 1025 |
let distanceStr = '--';
|
| 1026 |
if (distanceM !== undefined) {
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 1040 |
} else {
|
| 1041 |
-
|
|
|
|
| 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 |
-
🗺️
|
| 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">📍
|
| 1061 |
<span class="data-value">${originLabel}</span>
|
| 1062 |
</div>
|
| 1063 |
<div class="data-row">
|
| 1064 |
-
<span class="data-label">🎯
|
| 1065 |
<span class="data-value">${destLabel}</span>
|
| 1066 |
</div>
|
| 1067 |
<div class="data-row">
|
| 1068 |
-
<span class="data-label">📏
|
| 1069 |
<span class="data-value">${distanceStr}</span>
|
| 1070 |
</div>
|
| 1071 |
<div class="data-row">
|
| 1072 |
-
<span class="data-label">⏱️
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 1141 |
}
|
| 1142 |
|
| 1143 |
let html = '<div class="metro-stations">';
|
| 1144 |
|
| 1145 |
stations.forEach((station, index) => {
|
| 1146 |
-
const stationName = station.station_name ||
|
| 1147 |
-
const
|
| 1148 |
-
const
|
|
|
|
| 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">📏
|
| 1159 |
<span class="data-value">${distance}</span>
|
| 1160 |
</div>
|
| 1161 |
` : ''}
|
| 1162 |
-
${
|
| 1163 |
<div class="data-row">
|
| 1164 |
-
<span class="data-label">🚶
|
| 1165 |
-
<span class="data-value">${
|
| 1166 |
</div>
|
| 1167 |
` : ''}
|
| 1168 |
${address ? `
|
| 1169 |
<div class="data-row">
|
| 1170 |
-
<span class="data-label">📍
|
| 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
|
|
|
|
| 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">📍
|
| 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">🏙️
|
| 1204 |
<span class="data-value">${city}</span>
|
| 1205 |
</div>
|
| 1206 |
` : ''}
|
| 1207 |
${road ? `
|
| 1208 |
<div class="data-row">
|
| 1209 |
-
<span class="data-label">🛣️
|
| 1210 |
<span class="data-value">${road}</span>
|
| 1211 |
</div>
|
| 1212 |
` : ''}
|
| 1213 |
${suburb ? `
|
| 1214 |
<div class="data-row">
|
| 1215 |
-
<span class="data-label">🏘️
|
| 1216 |
<span class="data-value">${suburb}</span>
|
| 1217 |
</div>
|
| 1218 |
` : ''}
|
| 1219 |
<div class="data-row">
|
| 1220 |
-
<span class="data-label">🌐
|
| 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 |
-
🗺️
|
| 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 |
|