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