Spaces:
Running
Running
Commit
·
921a78a
1
Parent(s):
d96697e
Latest
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .env.production.example +0 -63
- app.py +21 -5
- bloom-ware-login/app/icon.svg +54 -0
- bloom-ware-login/app/layout.tsx +0 -2
- bloom-ware-login/components/login-form.tsx +110 -5
- bloom-ware-login/next.config.mjs +4 -0
- bloom-ware-login/package-lock.json +0 -0
- bloom-ware-login/tsconfig.json +19 -5
- core/database/cache.py +8 -0
- features/mcp/agent_bridge.py +163 -11
- features/mcp/auto_registry.py +33 -18
- features/mcp/coordinator.py +16 -3
- features/mcp/tools/base_tool.py +7 -0
- features/mcp/tools/geocode_tool.py +139 -114
- features/mcp/tools/tdx_base.py +137 -24
- features/mcp/tools/tdx_bus_arrival.py +501 -175
- features/mcp/tools/tdx_metro.py +117 -20
- features/mcp/tools/tdx_parking.py +121 -20
- features/mcp/tools/tdx_thsr.py +85 -15
- features/mcp/tools/tdx_train.py +209 -55
- features/mcp/tools/tdx_youbike.py +149 -23
- features/mcp_config.json +52 -31
- services/ai_service.py +30 -18
- static/frontend/index.html +198 -534
- static/frontend/js/agent.js +4 -82
- static/frontend/js/app.js +12 -16
- static/frontend/js/canvas.js +6 -67
- static/frontend/js/location.js +220 -0
- static/frontend/js/login.js +0 -730
- static/frontend/js/tools.js +208 -258
- static/frontend/js/tts.js +1 -88
- static/frontend/js/ui.js +89 -138
- static/frontend/js/websocket.js +49 -375
- static/frontend/login.html +0 -547
- static/frontend/login/404.html +1 -0
- static/frontend/login/404/index.html +1 -0
- static/frontend/login/__next.__PAGE__.txt +17 -0
- static/frontend/login/__next._full.txt +27 -0
- static/frontend/login/__next._index.txt +6 -0
- static/frontend/login/__next._tree.txt +13 -0
- static/frontend/login/_next/static/JHSefKGBAlaH-P1I6_EFb/_buildManifest.js +11 -0
- static/frontend/login/_next/static/JHSefKGBAlaH-P1I6_EFb/_clientMiddlewareManifest.json +1 -0
- static/frontend/login/_next/static/JHSefKGBAlaH-P1I6_EFb/_ssgManifest.js +1 -0
- static/frontend/login/_next/static/chunks/1f600c83a5ecf168.css +3 -0
- static/frontend/login/_next/static/chunks/42879de7b8087bc9.js +1 -0
- static/frontend/login/_next/static/chunks/66925481c7f9f43e.js +0 -0
- static/frontend/login/_next/static/chunks/752a4bd6387d6ef7.js +1 -0
- static/frontend/login/_next/static/chunks/9b0c5fad00d39a77.js +1 -0
- static/frontend/login/_next/static/chunks/a6dad97d9634a72d.js +0 -0
- static/frontend/login/_next/static/chunks/c518a733bdcf3dee.js +4 -0
.env.production.example
DELETED
|
@@ -1,63 +0,0 @@
|
|
| 1 |
-
# ========================================
|
| 2 |
-
# Bloom Ware 生產環境配置範本
|
| 3 |
-
# ========================================
|
| 4 |
-
#
|
| 5 |
-
# 使用說明:
|
| 6 |
-
# 1. 複製此檔案為 .env.production
|
| 7 |
-
# 2. 填入真實的 API Keys 和憑證
|
| 8 |
-
# 3. 在 Render Dashboard 設定環境變數(不要提交到 Git)
|
| 9 |
-
#
|
| 10 |
-
# ========================================
|
| 11 |
-
|
| 12 |
-
# === 環境識別 ===
|
| 13 |
-
ENVIRONMENT=production
|
| 14 |
-
|
| 15 |
-
# === Firebase 配置 ===
|
| 16 |
-
FIREBASE_PROJECT_ID=your-firebase-project-id
|
| 17 |
-
|
| 18 |
-
# Firebase 憑證(JSON 格式,單行字串)
|
| 19 |
-
# 生成方式:cat your-firebase-credentials.json | python3 -m json.tool --compact
|
| 20 |
-
# 範例:{"type":"service_account","project_id":"your-project-id",...}
|
| 21 |
-
FIREBASE_CREDENTIALS_JSON={"type":"service_account","project_id":"YOUR_PROJECT_ID","private_key_id":"YOUR_PRIVATE_KEY_ID","private_key":"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n","client_email":"YOUR_SERVICE_ACCOUNT_EMAIL","client_id":"YOUR_CLIENT_ID","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"YOUR_CERT_URL","universe_domain":"googleapis.com"}
|
| 22 |
-
|
| 23 |
-
# === OpenAI 配置 ===
|
| 24 |
-
OPENAI_API_KEY=sk-proj-YOUR_PRODUCTION_API_KEY
|
| 25 |
-
OPENAI_MODEL=gpt-5-nano
|
| 26 |
-
OPENAI_TIMEOUT=30
|
| 27 |
-
|
| 28 |
-
# === Google OAuth 配置 ===
|
| 29 |
-
# 重要:GOOGLE_REDIRECT_URI 必須更新為 Render 網址
|
| 30 |
-
GOOGLE_CLIENT_ID=YOUR_GOOGLE_CLIENT_ID
|
| 31 |
-
GOOGLE_CLIENT_SECRET=YOUR_GOOGLE_CLIENT_SECRET
|
| 32 |
-
GOOGLE_REDIRECT_URI=https://your-app.onrender.com/auth/google/callback
|
| 33 |
-
|
| 34 |
-
# === 第三方 API Keys ===
|
| 35 |
-
WEATHER_API_KEY=YOUR_OPENWEATHERMAP_API_KEY
|
| 36 |
-
NEWSDATA_API_KEY=YOUR_NEWSDATA_API_KEY
|
| 37 |
-
EXCHANGE_API_KEY=YOUR_EXCHANGERATE_API_KEY
|
| 38 |
-
OPENROUTESERVICE_API_KEY=YOUR_ORS_API_KEY
|
| 39 |
-
|
| 40 |
-
# === JWT 認證配置 ===
|
| 41 |
-
# 重要:生產環境必須使用新的 Secret Key
|
| 42 |
-
# 生成方式:python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
| 43 |
-
JWT_SECRET_KEY=YOUR_PRODUCTION_JWT_SECRET_KEY_HERE
|
| 44 |
-
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
| 45 |
-
|
| 46 |
-
# === 伺服器配置 ===
|
| 47 |
-
HOST=0.0.0.0
|
| 48 |
-
PORT=10000 # Render 固定使用 10000
|
| 49 |
-
|
| 50 |
-
# === GPT 意圖檢測配置 ===
|
| 51 |
-
USE_GPT_INTENT=true
|
| 52 |
-
GPT_INTENT_MODEL=gpt-5-nano
|
| 53 |
-
|
| 54 |
-
# ========================================
|
| 55 |
-
# 🔐 安全提醒
|
| 56 |
-
# ========================================
|
| 57 |
-
#
|
| 58 |
-
# 1. 絕對不要提交此檔案到 Git(已在 .gitignore 排除)
|
| 59 |
-
# 2. Firebase JSON 必須是單行字串(移除所有換行符)
|
| 60 |
-
# 3. JWT Secret 必須與開發環境不同
|
| 61 |
-
# 4. Google OAuth Redirect URI 必須在 Google Cloud Console 註冊
|
| 62 |
-
#
|
| 63 |
-
# ========================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
CHANGED
|
@@ -8,6 +8,10 @@ import secrets
|
|
| 8 |
from datetime import datetime
|
| 9 |
from typing import List, Dict, Optional, Any
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Request, UploadFile, File, HTTPException, Depends
|
| 12 |
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
from fastapi.responses import JSONResponse, FileResponse, RedirectResponse
|
|
@@ -415,12 +419,22 @@ app.add_middleware(CSPMiddleware)
|
|
| 415 |
|
| 416 |
# 掛載靜態檔案目錄(語音沉浸式前端)
|
| 417 |
static_dir = Path("static/frontend")
|
|
|
|
|
|
|
| 418 |
if static_dir.exists() and static_dir.is_dir():
|
| 419 |
app.mount("/static", StaticFiles(directory=str(static_dir), html=True), name="frontend")
|
| 420 |
logger.info(f"✅ 已掛載語音沉浸式前端: /static → {static_dir}")
|
| 421 |
else:
|
| 422 |
logger.warning("⚠️ 未找到 static/frontend/ 目錄")
|
| 423 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
# 環境設定
|
| 425 |
app.state.intent_model = settings.OPENAI_MODEL
|
| 426 |
|
|
@@ -728,6 +742,8 @@ async def websocket_endpoint_with_jwt(websocket: WebSocket, token: str = Query(N
|
|
| 728 |
except Exception as e:
|
| 729 |
logger.error(f"初始化對話時出錯: {str(e)}")
|
| 730 |
|
|
|
|
|
|
|
| 731 |
# 發送個性化歡迎消息(語音登入模式跳過)
|
| 732 |
if not is_voice_login_mode:
|
| 733 |
try:
|
|
@@ -1557,7 +1573,7 @@ async def handle_command(command, user_id):
|
|
| 1557 |
@app.get("/")
|
| 1558 |
async def root():
|
| 1559 |
"""根路徑導向登入頁面"""
|
| 1560 |
-
return RedirectResponse(url="/
|
| 1561 |
|
| 1562 |
|
| 1563 |
@app.get("/status")
|
|
@@ -1655,7 +1671,7 @@ async def google_oauth_legacy_callback(
|
|
| 1655 |
error_params = f"?error={error}"
|
| 1656 |
if state:
|
| 1657 |
error_params += f"&state={state}"
|
| 1658 |
-
return RedirectResponse(url=f"/
|
| 1659 |
|
| 1660 |
if not code:
|
| 1661 |
return JSONResponse(status_code=400, content={"success": False, "error": "NO_AUTHORIZATION_CODE"})
|
|
@@ -1693,7 +1709,7 @@ async def google_oauth_callback_get(
|
|
| 1693 |
if error:
|
| 1694 |
# 如果有錯誤,重定向到前端顯示錯誤
|
| 1695 |
return RedirectResponse(
|
| 1696 |
-
url=f"/
|
| 1697 |
status_code=302
|
| 1698 |
)
|
| 1699 |
|
|
@@ -1701,12 +1717,12 @@ async def google_oauth_callback_get(
|
|
| 1701 |
return JSONResponse(status_code=400, content={"success": False, "error": "NO_AUTHORIZATION_CODE"})
|
| 1702 |
|
| 1703 |
# 構造前端處理的URL
|
| 1704 |
-
frontend_url = f"/
|
| 1705 |
return RedirectResponse(url=frontend_url, status_code=302)
|
| 1706 |
|
| 1707 |
except Exception as e:
|
| 1708 |
logger.error(f"Google OAuth GET 回調處理失敗: {e}")
|
| 1709 |
-
return RedirectResponse(url="/
|
| 1710 |
|
| 1711 |
@app.post("/auth/google/callback")
|
| 1712 |
async def google_oauth_callback_post(auth_request: GoogleAuthCodeRequest):
|
|
|
|
| 8 |
from datetime import datetime
|
| 9 |
from typing import List, Dict, Optional, Any
|
| 10 |
|
| 11 |
+
# 載入 .env 環境變數(必須在其他 import 之前)
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
+
load_dotenv()
|
| 14 |
+
|
| 15 |
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Request, UploadFile, File, HTTPException, Depends
|
| 16 |
from fastapi.middleware.cors import CORSMiddleware
|
| 17 |
from fastapi.responses import JSONResponse, FileResponse, RedirectResponse
|
|
|
|
| 419 |
|
| 420 |
# 掛載靜態檔案目錄(語音沉浸式前端)
|
| 421 |
static_dir = Path("static/frontend")
|
| 422 |
+
login_dir = Path("static/frontend/login")
|
| 423 |
+
|
| 424 |
if static_dir.exists() and static_dir.is_dir():
|
| 425 |
app.mount("/static", StaticFiles(directory=str(static_dir), html=True), name="frontend")
|
| 426 |
logger.info(f"✅ 已掛載語音沉浸式前端: /static → {static_dir}")
|
| 427 |
else:
|
| 428 |
logger.warning("⚠️ 未找到 static/frontend/ 目錄")
|
| 429 |
|
| 430 |
+
# 掛載登入頁面 (Next.js build 產出)
|
| 431 |
+
# 使用 html=True 自動處理 index.html,訪問 /login/ 會自動載入 index.html
|
| 432 |
+
if login_dir.exists() and login_dir.is_dir():
|
| 433 |
+
app.mount("/login", StaticFiles(directory=str(login_dir), html=True), name="login_static")
|
| 434 |
+
logger.info(f"✅ 已掛載登入頁面: /login → {login_dir}")
|
| 435 |
+
else:
|
| 436 |
+
logger.warning("⚠️ 未找到 static/frontend/login/ 目錄,請先 build bloom-ware-login 專案")
|
| 437 |
+
|
| 438 |
# 環境設定
|
| 439 |
app.state.intent_model = settings.OPENAI_MODEL
|
| 440 |
|
|
|
|
| 742 |
except Exception as e:
|
| 743 |
logger.error(f"初始化對話時出錯: {str(e)}")
|
| 744 |
|
| 745 |
+
# 注意:chat_id 會在下面的歡迎訊息中一起發送,不需要額外發送 chat_ready
|
| 746 |
+
|
| 747 |
# 發送個性化歡迎消息(語音登入模式跳過)
|
| 748 |
if not is_voice_login_mode:
|
| 749 |
try:
|
|
|
|
| 1573 |
@app.get("/")
|
| 1574 |
async def root():
|
| 1575 |
"""根路徑導向登入頁面"""
|
| 1576 |
+
return RedirectResponse(url="/login/", status_code=307)
|
| 1577 |
|
| 1578 |
|
| 1579 |
@app.get("/status")
|
|
|
|
| 1671 |
error_params = f"?error={error}"
|
| 1672 |
if state:
|
| 1673 |
error_params += f"&state={state}"
|
| 1674 |
+
return RedirectResponse(url=f"/login?{error_params}", status_code=302)
|
| 1675 |
|
| 1676 |
if not code:
|
| 1677 |
return JSONResponse(status_code=400, content={"success": False, "error": "NO_AUTHORIZATION_CODE"})
|
|
|
|
| 1709 |
if error:
|
| 1710 |
# 如果有錯誤,重定向到前端顯示錯誤
|
| 1711 |
return RedirectResponse(
|
| 1712 |
+
url=f"/login?error={error}&state={state or ''}",
|
| 1713 |
status_code=302
|
| 1714 |
)
|
| 1715 |
|
|
|
|
| 1717 |
return JSONResponse(status_code=400, content={"success": False, "error": "NO_AUTHORIZATION_CODE"})
|
| 1718 |
|
| 1719 |
# 構造前端處理的URL
|
| 1720 |
+
frontend_url = f"/login?code={code}&state={state or ''}&scope={scope or ''}"
|
| 1721 |
return RedirectResponse(url=frontend_url, status_code=302)
|
| 1722 |
|
| 1723 |
except Exception as e:
|
| 1724 |
logger.error(f"Google OAuth GET 回調處理失敗: {e}")
|
| 1725 |
+
return RedirectResponse(url="/login?error=callback_error", status_code=302)
|
| 1726 |
|
| 1727 |
@app.post("/auth/google/callback")
|
| 1728 |
async def google_oauth_callback_post(auth_request: GoogleAuthCodeRequest):
|
bloom-ware-login/app/icon.svg
ADDED
|
|
bloom-ware-login/app/layout.tsx
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
import type React from "react"
|
| 2 |
import type { Metadata } from "next"
|
| 3 |
import { Geist, Geist_Mono, Playfair_Display } from "next/font/google"
|
| 4 |
-
import { Analytics } from "@vercel/analytics/next"
|
| 5 |
import "./globals.css"
|
| 6 |
|
| 7 |
const _geist = Geist({ subsets: ["latin"] })
|
|
@@ -23,7 +22,6 @@ export default function RootLayout({
|
|
| 23 |
<html lang="en">
|
| 24 |
<body className={`font-sans antialiased`}>
|
| 25 |
{children}
|
| 26 |
-
<Analytics />
|
| 27 |
</body>
|
| 28 |
</html>
|
| 29 |
)
|
|
|
|
| 1 |
import type React from "react"
|
| 2 |
import type { Metadata } from "next"
|
| 3 |
import { Geist, Geist_Mono, Playfair_Display } from "next/font/google"
|
|
|
|
| 4 |
import "./globals.css"
|
| 5 |
|
| 6 |
const _geist = Geist({ subsets: ["latin"] })
|
|
|
|
| 22 |
<html lang="en">
|
| 23 |
<body className={`font-sans antialiased`}>
|
| 24 |
{children}
|
|
|
|
| 25 |
</body>
|
| 26 |
</html>
|
| 27 |
)
|
bloom-ware-login/components/login-form.tsx
CHANGED
|
@@ -1,18 +1,123 @@
|
|
| 1 |
"use client"
|
| 2 |
|
|
|
|
| 3 |
import { Button } from "@/components/ui/button"
|
| 4 |
import { TulipIllustration } from "@/components/tulip-illustration"
|
| 5 |
import { Mic } from "lucide-react"
|
| 6 |
|
| 7 |
export function LoginForm() {
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
}
|
| 12 |
|
| 13 |
const handleVoiceLogin = () => {
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
return (
|
|
|
|
| 1 |
"use client"
|
| 2 |
|
| 3 |
+
import { useEffect } from "react"
|
| 4 |
import { Button } from "@/components/ui/button"
|
| 5 |
import { TulipIllustration } from "@/components/tulip-illustration"
|
| 6 |
import { Mic } from "lucide-react"
|
| 7 |
|
| 8 |
export function LoginForm() {
|
| 9 |
+
// 處理 OAuth callback
|
| 10 |
+
useEffect(() => {
|
| 11 |
+
const params = new URLSearchParams(window.location.search);
|
| 12 |
+
const code = params.get('code');
|
| 13 |
+
const state = params.get('state');
|
| 14 |
+
const error = params.get('error');
|
| 15 |
+
|
| 16 |
+
if (error) {
|
| 17 |
+
console.error('❌ OAuth 錯誤:', error);
|
| 18 |
+
alert(`Google 登入失敗: ${error}`);
|
| 19 |
+
// 清除 URL 參數
|
| 20 |
+
window.history.replaceState({}, '', window.location.pathname);
|
| 21 |
+
return;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
if (code && state) {
|
| 25 |
+
console.log('🔍 檢測到 OAuth callback,處理授權碼...');
|
| 26 |
+
handleOAuthCallback(code, state);
|
| 27 |
+
}
|
| 28 |
+
}, []);
|
| 29 |
+
|
| 30 |
+
const handleOAuthCallback = async (code: string, state: string) => {
|
| 31 |
+
try {
|
| 32 |
+
// 從 sessionStorage 獲取 PKCE 參數
|
| 33 |
+
const storedState = sessionStorage.getItem('oauth_state');
|
| 34 |
+
const codeVerifier = sessionStorage.getItem('oauth_code_verifier');
|
| 35 |
+
|
| 36 |
+
console.log('🔐 驗證 state 參數...');
|
| 37 |
+
if (state !== storedState) {
|
| 38 |
+
throw new Error('State 參數不匹配,可能存在 CSRF 攻擊');
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
console.log('📤 發送授權碼到後端...');
|
| 42 |
+
const response = await fetch('/auth/google/callback', {
|
| 43 |
+
method: 'POST',
|
| 44 |
+
headers: {
|
| 45 |
+
'Content-Type': 'application/json',
|
| 46 |
+
},
|
| 47 |
+
body: JSON.stringify({
|
| 48 |
+
code,
|
| 49 |
+
state,
|
| 50 |
+
code_verifier: codeVerifier,
|
| 51 |
+
}),
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
const data = await response.json();
|
| 55 |
+
|
| 56 |
+
if (data.success) {
|
| 57 |
+
console.log('✅ 登入成功!');
|
| 58 |
+
|
| 59 |
+
// 存儲 JWT token
|
| 60 |
+
localStorage.setItem('jwt_token', data.access_token);
|
| 61 |
+
|
| 62 |
+
// 清除 sessionStorage
|
| 63 |
+
sessionStorage.removeItem('oauth_state');
|
| 64 |
+
sessionStorage.removeItem('oauth_code_verifier');
|
| 65 |
+
|
| 66 |
+
// 清除 URL 參數並導向主應用
|
| 67 |
+
window.history.replaceState({}, '', window.location.pathname);
|
| 68 |
+
|
| 69 |
+
// 導向主應用頁面
|
| 70 |
+
window.location.href = '/static/';
|
| 71 |
+
} else {
|
| 72 |
+
throw new Error(data.error || '登入失敗');
|
| 73 |
+
}
|
| 74 |
+
} catch (error) {
|
| 75 |
+
console.error('❌ OAuth callback 處理失敗:', error);
|
| 76 |
+
alert(`登入處理失敗: ${error}`);
|
| 77 |
+
|
| 78 |
+
// 清除 URL 參數
|
| 79 |
+
window.history.replaceState({}, '', window.location.pathname);
|
| 80 |
+
}
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
const handleGoogleLogin = async () => {
|
| 84 |
+
try {
|
| 85 |
+
console.log('🚀 開始 Google OAuth 登入流程...');
|
| 86 |
+
|
| 87 |
+
// 從後端獲取授權 URL 和 PKCE 參數
|
| 88 |
+
const response = await fetch('/auth/google/url');
|
| 89 |
+
const data = await response.json();
|
| 90 |
+
|
| 91 |
+
if (!data.success) {
|
| 92 |
+
throw new Error(data.error || '獲取授權 URL 失敗');
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
console.log('✅ 獲取授權 URL 成功');
|
| 96 |
+
|
| 97 |
+
// 存儲 PKCE 參數到 sessionStorage
|
| 98 |
+
sessionStorage.setItem('oauth_state', data.state);
|
| 99 |
+
sessionStorage.setItem('oauth_code_verifier', data.code_verifier);
|
| 100 |
+
|
| 101 |
+
console.log('🔐 PKCE 參數已存儲');
|
| 102 |
+
|
| 103 |
+
// 重定向到 Google 授權頁面
|
| 104 |
+
console.log('🌐 重定向到 Google 授權頁面...');
|
| 105 |
+
window.location.href = data.auth_url;
|
| 106 |
+
|
| 107 |
+
} catch (error) {
|
| 108 |
+
console.error('❌ OAuth 初始化失敗:', error);
|
| 109 |
+
alert('Google 登入初始化失敗,請稍後再試');
|
| 110 |
+
}
|
| 111 |
}
|
| 112 |
|
| 113 |
const handleVoiceLogin = () => {
|
| 114 |
+
console.log('🎤 開始語音登入...');
|
| 115 |
+
|
| 116 |
+
// 存儲匿名語音登入 token
|
| 117 |
+
localStorage.setItem('jwt_token', 'anonymous_voice_login');
|
| 118 |
+
|
| 119 |
+
// 導向主應用頁面(語音登入模式)
|
| 120 |
+
window.location.href = '/static/';
|
| 121 |
}
|
| 122 |
|
| 123 |
return (
|
bloom-ware-login/next.config.mjs
CHANGED
|
@@ -6,6 +6,10 @@ const nextConfig = {
|
|
| 6 |
images: {
|
| 7 |
unoptimized: true,
|
| 8 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
export default nextConfig
|
|
|
|
| 6 |
images: {
|
| 7 |
unoptimized: true,
|
| 8 |
},
|
| 9 |
+
output: 'export',
|
| 10 |
+
basePath: '/login', // 所有靜態資源路徑加上 /login 前綴
|
| 11 |
+
distDir: 'out',
|
| 12 |
+
trailingSlash: true,
|
| 13 |
}
|
| 14 |
|
| 15 |
export default nextConfig
|
bloom-ware-login/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
bloom-ware-login/tsconfig.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
| 1 |
{
|
| 2 |
"compilerOptions": {
|
| 3 |
-
"lib": [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"allowJs": true,
|
| 5 |
"target": "ES6",
|
| 6 |
"skipLibCheck": true,
|
|
@@ -11,7 +15,7 @@
|
|
| 11 |
"moduleResolution": "bundler",
|
| 12 |
"resolveJsonModule": true,
|
| 13 |
"isolatedModules": true,
|
| 14 |
-
"jsx": "
|
| 15 |
"incremental": true,
|
| 16 |
"plugins": [
|
| 17 |
{
|
|
@@ -19,9 +23,19 @@
|
|
| 19 |
}
|
| 20 |
],
|
| 21 |
"paths": {
|
| 22 |
-
"@/*": [
|
|
|
|
|
|
|
| 23 |
}
|
| 24 |
},
|
| 25 |
-
"include": [
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"compilerOptions": {
|
| 3 |
+
"lib": [
|
| 4 |
+
"dom",
|
| 5 |
+
"dom.iterable",
|
| 6 |
+
"esnext"
|
| 7 |
+
],
|
| 8 |
"allowJs": true,
|
| 9 |
"target": "ES6",
|
| 10 |
"skipLibCheck": true,
|
|
|
|
| 15 |
"moduleResolution": "bundler",
|
| 16 |
"resolveJsonModule": true,
|
| 17 |
"isolatedModules": true,
|
| 18 |
+
"jsx": "react-jsx",
|
| 19 |
"incremental": true,
|
| 20 |
"plugins": [
|
| 21 |
{
|
|
|
|
| 23 |
}
|
| 24 |
],
|
| 25 |
"paths": {
|
| 26 |
+
"@/*": [
|
| 27 |
+
"./*"
|
| 28 |
+
]
|
| 29 |
}
|
| 30 |
},
|
| 31 |
+
"include": [
|
| 32 |
+
"next-env.d.ts",
|
| 33 |
+
"**/*.ts",
|
| 34 |
+
"**/*.tsx",
|
| 35 |
+
".next/types/**/*.ts",
|
| 36 |
+
".next/dev/types/**/*.ts"
|
| 37 |
+
],
|
| 38 |
+
"exclude": [
|
| 39 |
+
"node_modules"
|
| 40 |
+
]
|
| 41 |
}
|
core/database/cache.py
CHANGED
|
@@ -324,6 +324,14 @@ class DatabaseCache:
|
|
| 324 |
key = self._generate_cache_key("route", key=cache_key)
|
| 325 |
await self.route_cache.set(key, payload)
|
| 326 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
|
| 328 |
# 全局緩存實例
|
| 329 |
db_cache = DatabaseCache()
|
|
|
|
| 324 |
key = self._generate_cache_key("route", key=cache_key)
|
| 325 |
await self.route_cache.set(key, payload)
|
| 326 |
|
| 327 |
+
async def get_tdx_cached(self, cache_key: str) -> Optional[Any]:
|
| 328 |
+
"""獲取 TDX API 快取資料"""
|
| 329 |
+
return await self.route_cache.get(cache_key)
|
| 330 |
+
|
| 331 |
+
async def set_tdx_cache(self, cache_key: str, data: Any, ttl: int = 60):
|
| 332 |
+
"""設置 TDX API 快取資料(使用 route_cache,因為 TDX 也是路線相關)"""
|
| 333 |
+
await self.route_cache.set(cache_key, data)
|
| 334 |
+
|
| 335 |
|
| 336 |
# 全局緩存實例
|
| 337 |
db_cache = DatabaseCache()
|
features/mcp/agent_bridge.py
CHANGED
|
@@ -143,6 +143,54 @@ class MCPAgentBridge:
|
|
| 143 |
flow="navigation",
|
| 144 |
)
|
| 145 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
def _directions_failure_fallback(self, arguments: Dict[str, Any], exc: Exception) -> ToolResult:
|
| 148 |
labels = {
|
|
@@ -445,6 +493,62 @@ class MCPAgentBridge:
|
|
| 445 |
* 參數:query(關鍵詞)、country(國家,預設 tw)、category(分類,預設 top)、language(語言,預設 zh)
|
| 446 |
* 今日新聞、科技新聞、台灣新聞都應該調用此工具
|
| 447 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
- 地點查詢與導航(重要!):
|
| 449 |
* **當前位置查詢**:
|
| 450 |
- 問「我在哪」「這是哪裡」「現在在哪」「我的位置」→ 使用 reverse_geocode(不需參數,系統自動用 GPS 座標)
|
|
@@ -487,6 +591,10 @@ class MCPAgentBridge:
|
|
| 487 |
- "美元匯率" → {{"is_tool_call": true, "tool_name": "exchange_query:from_currency=USD,to_currency=TWD,amount=1.0", "emotion": "neutral"}}
|
| 488 |
- "今日新聞" → {{"is_tool_call": true, "tool_name": "news_query:country=tw,language=zh", "emotion": "neutral"}}
|
| 489 |
- "科技新聞" → {{"is_tool_call": true, "tool_name": "news_query:query=科技,category=technology,language=zh", "emotion": "neutral"}}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
- "你好" → {{"is_tool_call": false, "tool_name": "", "emotion": "neutral"}}
|
| 491 |
- "我好難過..." → {{"is_tool_call": false, "tool_name": "", "emotion": "sad"}}
|
| 492 |
- "煩死了" → {{"is_tool_call": false, "tool_name": "", "emotion": "angry"}}"""
|
|
@@ -519,7 +627,25 @@ class MCPAgentBridge:
|
|
| 519 |
|
| 520 |
# Structured Outputs 保證返回有效JSON,直接解析
|
| 521 |
try:
|
| 522 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
logger.debug("解析後的意圖資料: %s", _safe_json(intent_data))
|
| 524 |
|
| 525 |
# 新的 schema 格式:is_tool_call, tool_name(包含參數)
|
|
@@ -545,6 +671,19 @@ class MCPAgentBridge:
|
|
| 545 |
# 解析參數
|
| 546 |
arguments = {}
|
| 547 |
if params_str.strip():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
for param_pair in params_str.split(","):
|
| 549 |
if "=" not in param_pair:
|
| 550 |
continue
|
|
@@ -556,19 +695,28 @@ class MCPAgentBridge:
|
|
| 556 |
if not key or not value:
|
| 557 |
continue
|
| 558 |
|
| 559 |
-
#
|
|
|
|
|
|
|
|
|
|
| 560 |
normalized_value = value
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
lower_value = value.lower()
|
| 565 |
if lower_value in ("true", "false"):
|
| 566 |
normalized_value = lower_value == "true"
|
| 567 |
-
|
| 568 |
-
try:
|
| 569 |
-
normalized_value = float(value)
|
| 570 |
-
except ValueError:
|
| 571 |
-
normalized_value = value
|
| 572 |
|
| 573 |
arguments[key] = normalized_value
|
| 574 |
|
|
@@ -944,7 +1092,11 @@ class MCPAgentBridge:
|
|
| 944 |
"""
|
| 945 |
# 策略1: 有工具卡片的工具,總是需要 AI 格式化為對話式回覆
|
| 946 |
# 因為簡短的結構化文字不適合語音播報和聊天顯示
|
| 947 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 948 |
if tool_name in always_format_for_conversation:
|
| 949 |
logger.debug(f"工具 {tool_name} 需要 AI 格式化為對話式回覆")
|
| 950 |
return True
|
|
|
|
| 143 |
flow="navigation",
|
| 144 |
)
|
| 145 |
)
|
| 146 |
+
# TDX 公車到站查詢(需要位置資訊)
|
| 147 |
+
register(
|
| 148 |
+
ToolMetadata(
|
| 149 |
+
name="tdx_bus_arrival",
|
| 150 |
+
requires_env={"lat", "lon", "city"},
|
| 151 |
+
enable_reformat=True,
|
| 152 |
+
)
|
| 153 |
+
)
|
| 154 |
+
# TDX YouBike 查詢(需要位置資訊)
|
| 155 |
+
register(
|
| 156 |
+
ToolMetadata(
|
| 157 |
+
name="tdx_youbike",
|
| 158 |
+
requires_env={"lat", "lon", "city"},
|
| 159 |
+
enable_reformat=True,
|
| 160 |
+
)
|
| 161 |
+
)
|
| 162 |
+
# TDX 捷運查詢(需要位置資訊)
|
| 163 |
+
register(
|
| 164 |
+
ToolMetadata(
|
| 165 |
+
name="tdx_metro",
|
| 166 |
+
requires_env={"lat", "lon", "city"},
|
| 167 |
+
enable_reformat=True,
|
| 168 |
+
)
|
| 169 |
+
)
|
| 170 |
+
# TDX 停車場查詢(需要位置資訊)
|
| 171 |
+
register(
|
| 172 |
+
ToolMetadata(
|
| 173 |
+
name="tdx_parking",
|
| 174 |
+
requires_env={"lat", "lon", "city"},
|
| 175 |
+
enable_reformat=True,
|
| 176 |
+
)
|
| 177 |
+
)
|
| 178 |
+
# TDX 台鐵查詢(需要位置資訊)
|
| 179 |
+
register(
|
| 180 |
+
ToolMetadata(
|
| 181 |
+
name="tdx_train",
|
| 182 |
+
requires_env={"lat", "lon", "city"},
|
| 183 |
+
enable_reformat=True,
|
| 184 |
+
)
|
| 185 |
+
)
|
| 186 |
+
# TDX 高鐵查詢(需要位置資訊)
|
| 187 |
+
register(
|
| 188 |
+
ToolMetadata(
|
| 189 |
+
name="tdx_thsr",
|
| 190 |
+
requires_env={"lat", "lon", "city"},
|
| 191 |
+
enable_reformat=True,
|
| 192 |
+
)
|
| 193 |
+
)
|
| 194 |
|
| 195 |
def _directions_failure_fallback(self, arguments: Dict[str, Any], exc: Exception) -> ToolResult:
|
| 196 |
labels = {
|
|
|
|
| 493 |
* 參數:query(關鍵詞)、country(國家,預設 tw)、category(分類,預設 top)、language(語言,預設 zh)
|
| 494 |
* 今日新聞、科技新聞、台灣新聞都應該調用此工具
|
| 495 |
|
| 496 |
+
- 公車查詢(重要!):
|
| 497 |
+
* 任何提到「公車」「巴士」「幾號公車」「什麼時候來」的請求都使用 tdx_bus_arrival
|
| 498 |
+
* **參數名稱是 route_name(不是 stop_name)**
|
| 499 |
+
* 從用戶訊息中提取路線號碼(如「137」「紅30」「307」)
|
| 500 |
+
* 範例:
|
| 501 |
+
- 「137公車什麼時候來」→ tdx_bus_arrival:route_name=137
|
| 502 |
+
- 「307還要多久」→ tdx_bus_arrival:route_name=307
|
| 503 |
+
- 「附近有什麼公車」→ tdx_bus_arrival(不需參數,系統自動用 GPS 查詢附近站點)
|
| 504 |
+
- 「紅30公車」→ tdx_bus_arrival:route_name=紅30
|
| 505 |
+
* ❌ 錯誤:tdx_bus_arrival:stop_name=137
|
| 506 |
+
* ✅ 正確:tdx_bus_arrival:route_name=137
|
| 507 |
+
|
| 508 |
+
- 台鐵/火車查詢(重要!):
|
| 509 |
+
* 任何提到「火車」「台鐵」「列車」「自強號」「莒光號」「區間車」的請求都使用 tdx_train
|
| 510 |
+
* **參數名稱是 origin_station(起站)和 destination_station(迄站)**
|
| 511 |
+
* **「往XX」「到XX」「去XX」表示目的地(destination_station),不是起點!**
|
| 512 |
+
* **「從XX」「在XX」表示起點(origin_station)**
|
| 513 |
+
* **如果用戶沒有明確說起點,就不要填 origin_station,讓系統用 GPS 自動找最近車站**
|
| 514 |
+
* 範例:
|
| 515 |
+
- 「往台北的火車」→ tdx_train:destination_station=台北(只填目的地,起點由 GPS 決定)
|
| 516 |
+
- 「到高雄的火車」→ tdx_train:destination_station=高雄(只填目的地)
|
| 517 |
+
- 「從桃園到台北」→ tdx_train:origin_station=桃園,destination_station=台北(明確說了起點)
|
| 518 |
+
- 「台北到台中的火車」→ tdx_train:origin_station=台北,destination_station=台中
|
| 519 |
+
- 「下一班火車」→ tdx_train(不需參數,系統自動用 GPS 查詢最近車站)
|
| 520 |
+
- 「自強號 123 次」→ tdx_train:train_no=123
|
| 521 |
+
* ❌ 錯誤:「往台北」解析為 origin_station=台灣,destination_station=台北(不要亂填起點!)
|
| 522 |
+
* ❌ 錯誤:「往台北」解析為 origin_station=台北(方向搞反了)
|
| 523 |
+
* ✅ 正確:「往台北」解析為 destination_station=台北(只填目的地)
|
| 524 |
+
|
| 525 |
+
- YouBike/共享單車查詢(重要!必須識別!):
|
| 526 |
+
* 任何提到以下關鍵詞的請求都使用 tdx_youbike:
|
| 527 |
+
- 「YouBike」「Youbike」「youbike」「YOUBIKE」
|
| 528 |
+
- 「UBike」「Ubike」「ubike」「UBIKE」
|
| 529 |
+
- 「微笑單車」「共享單車」「公共單車」
|
| 530 |
+
- 「腳踏車站」「單車站」「自行車站」
|
| 531 |
+
- 「借車」「還車」(在單車語境下)
|
| 532 |
+
- 「最近的站點」「附近站點」(在單車語境下)
|
| 533 |
+
* **不是 tdx_parking!YouBike 是單車,不是停車場**
|
| 534 |
+
* **不是一般聊天!這是工具調用!**
|
| 535 |
+
* 範例:
|
| 536 |
+
- 「附近的 YouBike」→ tdx_youbike(不需參數,系統自動用 GPS 查詢)
|
| 537 |
+
- 「離我最近的 Ubike 站點」→ tdx_youbike
|
| 538 |
+
- 「最近的Ubike站點」→ tdx_youbike
|
| 539 |
+
- 「Ubike在哪」→ tdx_youbike
|
| 540 |
+
- 「哪裡有Ubike」→ tdx_youbike
|
| 541 |
+
- 「市政府 YouBike 還有車嗎」→ tdx_youbike:station_name=市政府
|
| 542 |
+
* ❌ 錯誤:is_tool_call=false(這不是聊天!)
|
| 543 |
+
* ❌ 錯誤:tdx_parking:query=Ubike
|
| 544 |
+
* ✅ 正確:tdx_youbike
|
| 545 |
+
|
| 546 |
+
- 停車場查詢:
|
| 547 |
+
* 任何提到「停車場」「停車位」「汽車停車」的請求才使用 tdx_parking
|
| 548 |
+
* 範例:
|
| 549 |
+
- 「附近的停車場」→ tdx_parking
|
| 550 |
+
- 「市政府停車場」→ tdx_parking:parking_name=市政府
|
| 551 |
+
|
| 552 |
- 地點查詢與導航(重要!):
|
| 553 |
* **當前位置查詢**:
|
| 554 |
- 問「我在哪」「這是哪裡」「現在在哪」「我的位置」→ 使用 reverse_geocode(不需參數,系統自動用 GPS 座標)
|
|
|
|
| 591 |
- "美元匯率" → {{"is_tool_call": true, "tool_name": "exchange_query:from_currency=USD,to_currency=TWD,amount=1.0", "emotion": "neutral"}}
|
| 592 |
- "今日新聞" → {{"is_tool_call": true, "tool_name": "news_query:country=tw,language=zh", "emotion": "neutral"}}
|
| 593 |
- "科技新聞" → {{"is_tool_call": true, "tool_name": "news_query:query=科技,category=technology,language=zh", "emotion": "neutral"}}
|
| 594 |
+
- "最近的Ubike站點" → {{"is_tool_call": true, "tool_name": "tdx_youbike", "emotion": "neutral"}}
|
| 595 |
+
- "附近的YouBike" → {{"is_tool_call": true, "tool_name": "tdx_youbike", "emotion": "neutral"}}
|
| 596 |
+
- "Ubike在哪" → {{"is_tool_call": true, "tool_name": "tdx_youbike", "emotion": "neutral"}}
|
| 597 |
+
- "往台北的火車" → {{"is_tool_call": true, "tool_name": "tdx_train:destination_station=台北", "emotion": "neutral"}}
|
| 598 |
- "你好" → {{"is_tool_call": false, "tool_name": "", "emotion": "neutral"}}
|
| 599 |
- "我好難過..." → {{"is_tool_call": false, "tool_name": "", "emotion": "sad"}}
|
| 600 |
- "煩死了" → {{"is_tool_call": false, "tool_name": "", "emotion": "angry"}}"""
|
|
|
|
| 627 |
|
| 628 |
# Structured Outputs 保證返回有效JSON,直接解析
|
| 629 |
try:
|
| 630 |
+
response_text = response.strip()
|
| 631 |
+
|
| 632 |
+
# 處理 GPT 回應重複 JSON 的情況(如 {...}{...})
|
| 633 |
+
# 只取第一個完整的 JSON 物件
|
| 634 |
+
if response_text.startswith("{"):
|
| 635 |
+
brace_count = 0
|
| 636 |
+
end_idx = 0
|
| 637 |
+
for i, char in enumerate(response_text):
|
| 638 |
+
if char == "{":
|
| 639 |
+
brace_count += 1
|
| 640 |
+
elif char == "}":
|
| 641 |
+
brace_count -= 1
|
| 642 |
+
if brace_count == 0:
|
| 643 |
+
end_idx = i + 1
|
| 644 |
+
break
|
| 645 |
+
if end_idx > 0:
|
| 646 |
+
response_text = response_text[:end_idx]
|
| 647 |
+
|
| 648 |
+
intent_data = json.loads(response_text)
|
| 649 |
logger.debug("解析後的意圖資料: %s", _safe_json(intent_data))
|
| 650 |
|
| 651 |
# 新的 schema 格式:is_tool_call, tool_name(包含參數)
|
|
|
|
| 671 |
# 解析參數
|
| 672 |
arguments = {}
|
| 673 |
if params_str.strip():
|
| 674 |
+
# 獲取工具的 input schema 以確定參數類型
|
| 675 |
+
tool = self.mcp_server.tools.get(tool_name)
|
| 676 |
+
input_schema = {}
|
| 677 |
+
if tool and hasattr(tool, 'handler') and hasattr(tool.handler, '__self__'):
|
| 678 |
+
tool_class = tool.handler.__self__
|
| 679 |
+
if hasattr(tool_class, 'get_input_schema'):
|
| 680 |
+
try:
|
| 681 |
+
input_schema = tool_class.get_input_schema()
|
| 682 |
+
except:
|
| 683 |
+
pass
|
| 684 |
+
|
| 685 |
+
properties = input_schema.get('properties', {})
|
| 686 |
+
|
| 687 |
for param_pair in params_str.split(","):
|
| 688 |
if "=" not in param_pair:
|
| 689 |
continue
|
|
|
|
| 695 |
if not key or not value:
|
| 696 |
continue
|
| 697 |
|
| 698 |
+
# 根據 schema 中的類型定義來轉換值
|
| 699 |
+
param_schema = properties.get(key, {})
|
| 700 |
+
param_type = param_schema.get('type', 'string')
|
| 701 |
+
|
| 702 |
normalized_value = value
|
| 703 |
+
|
| 704 |
+
# 根據 schema 類型進行轉換
|
| 705 |
+
if param_type == 'integer':
|
| 706 |
+
try:
|
| 707 |
+
normalized_value = int(value)
|
| 708 |
+
except ValueError:
|
| 709 |
+
normalized_value = value
|
| 710 |
+
elif param_type == 'number':
|
| 711 |
+
try:
|
| 712 |
+
normalized_value = float(value)
|
| 713 |
+
except ValueError:
|
| 714 |
+
normalized_value = value
|
| 715 |
+
elif param_type == 'boolean':
|
| 716 |
lower_value = value.lower()
|
| 717 |
if lower_value in ("true", "false"):
|
| 718 |
normalized_value = lower_value == "true"
|
| 719 |
+
# 其他類型(包括 string)保持原樣
|
|
|
|
|
|
|
|
|
|
|
|
|
| 720 |
|
| 721 |
arguments[key] = normalized_value
|
| 722 |
|
|
|
|
| 1092 |
"""
|
| 1093 |
# 策略1: 有工具卡片的工具,總是需要 AI 格式化為對話式回覆
|
| 1094 |
# 因為簡短的結構化文字不適合語音播報和聊天顯示
|
| 1095 |
+
# 包含 TDX 交通工具,確保返回對話式回覆而非 JSON
|
| 1096 |
+
always_format_for_conversation = [
|
| 1097 |
+
'exchange_query', 'weather_query', 'healthkit_query', 'news_query',
|
| 1098 |
+
'tdx_youbike', 'tdx_train', 'tdx_thsr', 'tdx_bus_arrival', 'tdx_metro', 'tdx_parking'
|
| 1099 |
+
]
|
| 1100 |
if tool_name in always_format_for_conversation:
|
| 1101 |
logger.debug(f"工具 {tool_name} 需要 AI 格式化為對話式回覆")
|
| 1102 |
return True
|
features/mcp/auto_registry.py
CHANGED
|
@@ -23,14 +23,7 @@ class MCPAutoRegistry:
|
|
| 23 |
self.tools: Dict[str, Tool] = {}
|
| 24 |
self.config: Dict[str, Any] = {}
|
| 25 |
self.client_manager = MCPClientManager()
|
| 26 |
-
self._disabled_tools =
|
| 27 |
-
"tdx_bus_arrival",
|
| 28 |
-
"tdx_metro",
|
| 29 |
-
"tdx_parking",
|
| 30 |
-
"tdx_thsr",
|
| 31 |
-
"tdx_train",
|
| 32 |
-
"tdx_youbike",
|
| 33 |
-
}
|
| 34 |
|
| 35 |
# 載入配置
|
| 36 |
self._load_config()
|
|
@@ -61,8 +54,12 @@ class MCPAutoRegistry:
|
|
| 61 |
|
| 62 |
logger.info(f"掃描工具目錄: {tools_path}")
|
| 63 |
|
| 64 |
-
# 掃描所有 Python
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
tool_name = py_file.stem
|
| 67 |
module_name = f"{tools_dir}.{tool_name}"
|
| 68 |
|
|
@@ -158,11 +155,19 @@ class MCPAutoRegistry:
|
|
| 158 |
# 使用模組級別的execute函數
|
| 159 |
handler = module.execute
|
| 160 |
else:
|
| 161 |
-
#
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
# 創建工具實例,包含metadata
|
| 168 |
tool = Tool(
|
|
@@ -361,13 +366,23 @@ class MCPAutoRegistry:
|
|
| 361 |
# 外部工具已經在register_external_mcp_server中註冊到client_manager
|
| 362 |
# 這裡不需要額外處理,因為工具會在客戶端啟動時自動發現
|
| 363 |
|
| 364 |
-
# 去重 (
|
| 365 |
unique_tools = {}
|
| 366 |
for tool in all_tools:
|
| 367 |
if tool.name not in unique_tools:
|
| 368 |
unique_tools[tool.name] = tool
|
| 369 |
else:
|
| 370 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
|
| 372 |
# 添加外部工具
|
| 373 |
external_tools = self.client_manager.get_all_tools()
|
|
@@ -375,7 +390,7 @@ class MCPAutoRegistry:
|
|
| 375 |
if tool_name not in unique_tools:
|
| 376 |
unique_tools[tool_name] = tool
|
| 377 |
else:
|
| 378 |
-
logger.
|
| 379 |
|
| 380 |
final_tools = list(unique_tools.values())
|
| 381 |
logger.info(f"自動發現完成,總計 {len(final_tools)} 個工具")
|
|
|
|
| 23 |
self.tools: Dict[str, Tool] = {}
|
| 24 |
self.config: Dict[str, Any] = {}
|
| 25 |
self.client_manager = MCPClientManager()
|
| 26 |
+
self._disabled_tools = set()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
# 載入配置
|
| 29 |
self._load_config()
|
|
|
|
| 54 |
|
| 55 |
logger.info(f"掃描工具目錄: {tools_path}")
|
| 56 |
|
| 57 |
+
# 掃描所有 Python 文件(包含 *_tool.py 和 tdx_*.py)
|
| 58 |
+
tool_files = list(tools_path.glob("*_tool.py")) + list(tools_path.glob("tdx_*.py"))
|
| 59 |
+
# 去重(避免 tdx_*_tool.py 被掃描兩次)
|
| 60 |
+
tool_files = list(set(tool_files))
|
| 61 |
+
|
| 62 |
+
for py_file in tool_files:
|
| 63 |
tool_name = py_file.stem
|
| 64 |
module_name = f"{tools_dir}.{tool_name}"
|
| 65 |
|
|
|
|
| 155 |
# 使用模組級別的execute函數
|
| 156 |
handler = module.execute
|
| 157 |
else:
|
| 158 |
+
# 檢查 execute 是否為 classmethod
|
| 159 |
+
execute_method = getattr(tool_class, 'execute', None)
|
| 160 |
+
if execute_method and isinstance(inspect.getattr_static(tool_class, 'execute'), classmethod):
|
| 161 |
+
# execute 是 classmethod,直接調用類別方法
|
| 162 |
+
async def classmethod_wrapper(arguments):
|
| 163 |
+
return await tool_class.execute(arguments)
|
| 164 |
+
handler = classmethod_wrapper
|
| 165 |
+
else:
|
| 166 |
+
# 使用類別的execute方法(需要實例化)
|
| 167 |
+
async def instance_wrapper(arguments):
|
| 168 |
+
instance = tool_class()
|
| 169 |
+
return await instance.execute(arguments)
|
| 170 |
+
handler = instance_wrapper
|
| 171 |
|
| 172 |
# 創建工具實例,包含metadata
|
| 173 |
tool = Tool(
|
|
|
|
| 366 |
# 外部工具已經在register_external_mcp_server中註冊到client_manager
|
| 367 |
# 這裡不需要額外處理,因為工具會在客戶端啟動時自動發現
|
| 368 |
|
| 369 |
+
# 去重 (基於工具名稱,配置文件優先)
|
| 370 |
unique_tools = {}
|
| 371 |
for tool in all_tools:
|
| 372 |
if tool.name not in unique_tools:
|
| 373 |
unique_tools[tool.name] = tool
|
| 374 |
else:
|
| 375 |
+
# 如果是重複的,保留配置文件中的版本(通常在列表後面)
|
| 376 |
+
current_tool = unique_tools[tool.name]
|
| 377 |
+
if hasattr(current_tool, 'metadata') and current_tool.metadata:
|
| 378 |
+
if current_tool.metadata.get('source') == 'config_placeholder':
|
| 379 |
+
# 當前工具是系統占位符,替換為實際工具
|
| 380 |
+
unique_tools[tool.name] = tool
|
| 381 |
+
logger.debug(f"替換系統占位符工具: {tool.name}")
|
| 382 |
+
else:
|
| 383 |
+
logger.debug(f"保留現有工具定義: {tool.name}")
|
| 384 |
+
else:
|
| 385 |
+
logger.debug(f"保留現有工具定義: {tool.name}")
|
| 386 |
|
| 387 |
# 添加外部工具
|
| 388 |
external_tools = self.client_manager.get_all_tools()
|
|
|
|
| 390 |
if tool_name not in unique_tools:
|
| 391 |
unique_tools[tool_name] = tool
|
| 392 |
else:
|
| 393 |
+
logger.debug(f"外部工具已存在,跳過: {tool_name}")
|
| 394 |
|
| 395 |
final_tools = list(unique_tools.values())
|
| 396 |
logger.info(f"自動發現完成,總計 {len(final_tools)} 個工具")
|
features/mcp/coordinator.py
CHANGED
|
@@ -66,16 +66,29 @@ class ToolCoordinator:
|
|
| 66 |
) -> Dict[str, Any]:
|
| 67 |
merged = dict(metadata.defaults)
|
| 68 |
merged.update(arguments or {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
if metadata.requires_env and user_id:
|
| 71 |
env_ctx = await self._env_provider(user_id)
|
|
|
|
| 72 |
if env_ctx:
|
| 73 |
for field in metadata.requires_env:
|
| 74 |
if merged.get(field) is not None:
|
| 75 |
continue
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
return merged
|
| 80 |
|
| 81 |
async def _execute(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
| 66 |
) -> Dict[str, Any]:
|
| 67 |
merged = dict(metadata.defaults)
|
| 68 |
merged.update(arguments or {})
|
| 69 |
+
|
| 70 |
+
# 注入 user_id 到參數中,讓工具可以從 arguments 中讀取
|
| 71 |
+
if user_id:
|
| 72 |
+
merged["_user_id"] = user_id
|
| 73 |
+
|
| 74 |
+
logger.info(f"📦 [Coordinator] 準備參數: tool={metadata.name}, user_id={user_id}, requires_env={metadata.requires_env}")
|
| 75 |
|
| 76 |
if metadata.requires_env and user_id:
|
| 77 |
env_ctx = await self._env_provider(user_id)
|
| 78 |
+
logger.info(f"📦 [Coordinator] 環境資訊: {env_ctx}")
|
| 79 |
if env_ctx:
|
| 80 |
for field in metadata.requires_env:
|
| 81 |
if merged.get(field) is not None:
|
| 82 |
continue
|
| 83 |
+
env_value = env_ctx.get(field)
|
| 84 |
+
# 只注入非 None 的值,避免覆蓋工具的預設值或觸發 schema 驗證錯誤
|
| 85 |
+
if env_value is not None:
|
| 86 |
+
merged[field] = env_value
|
| 87 |
+
logger.info(f"📦 [Coordinator] 注入環境變數: {field}={env_value}")
|
| 88 |
+
elif not user_id:
|
| 89 |
+
logger.warning(f"⚠️ [Coordinator] user_id 為 None,無法注入環境變數")
|
| 90 |
+
|
| 91 |
+
logger.info(f"📦 [Coordinator] 最終參數: {merged}")
|
| 92 |
return merged
|
| 93 |
|
| 94 |
async def _execute(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
features/mcp/tools/base_tool.py
CHANGED
|
@@ -123,6 +123,11 @@ class MCPTool(ABC):
|
|
| 123 |
def validate_input(cls, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 124 |
"""驗證和標準化輸入參數"""
|
| 125 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
# 使用 JSON Schema 進行驗證
|
| 127 |
import jsonschema
|
| 128 |
|
|
@@ -179,7 +184,9 @@ class MCPTool(ABC):
|
|
| 179 |
except ToolError:
|
| 180 |
raise # 重新拋出工具錯誤
|
| 181 |
except Exception as e:
|
|
|
|
| 182 |
logger.error(f"工具 {cls.NAME} 執行失敗: {e}")
|
|
|
|
| 183 |
raise ExecutionError(f"工具執行失敗: {str(e)}", e)
|
| 184 |
|
| 185 |
@classmethod
|
|
|
|
| 123 |
def validate_input(cls, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 124 |
"""驗證和標準化輸入參數"""
|
| 125 |
try:
|
| 126 |
+
# 修復:確保 arguments 是 dict 類型
|
| 127 |
+
if not isinstance(arguments, dict):
|
| 128 |
+
logger.error(f"工具 {cls.NAME} 收到非 dict 類型的參數: {type(arguments).__name__} = {arguments}")
|
| 129 |
+
raise ValidationError("arguments", f"參數必須是 dict 類型,但收到: {type(arguments).__name__}")
|
| 130 |
+
|
| 131 |
# 使用 JSON Schema 進行驗證
|
| 132 |
import jsonschema
|
| 133 |
|
|
|
|
| 184 |
except ToolError:
|
| 185 |
raise # 重新拋出工具錯誤
|
| 186 |
except Exception as e:
|
| 187 |
+
import traceback
|
| 188 |
logger.error(f"工具 {cls.NAME} 執行失敗: {e}")
|
| 189 |
+
logger.error(f"完整 traceback:\n{traceback.format_exc()}")
|
| 190 |
raise ExecutionError(f"工具執行失敗: {str(e)}", e)
|
| 191 |
|
| 192 |
@classmethod
|
features/mcp/tools/geocode_tool.py
CHANGED
|
@@ -105,126 +105,151 @@ class ReverseGeocodeTool(MCPTool):
|
|
| 105 |
"format": "jsonv2",
|
| 106 |
"lat": lat,
|
| 107 |
"lon": lon,
|
| 108 |
-
"zoom": 18,
|
| 109 |
"addressdetails": 1,
|
| 110 |
-
"extratags": 1,
|
| 111 |
-
"namedetails": 1
|
| 112 |
}
|
| 113 |
headers = {
|
| 114 |
"User-Agent": "BloomWare/1.0 (contact@example.com)"
|
| 115 |
}
|
| 116 |
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
# 回寫快取
|
| 230 |
await db_cache.set_geo_cache(geokey, payload)
|
|
|
|
| 105 |
"format": "jsonv2",
|
| 106 |
"lat": lat,
|
| 107 |
"lon": lon,
|
| 108 |
+
"zoom": 18,
|
| 109 |
"addressdetails": 1,
|
| 110 |
+
"extratags": 1,
|
| 111 |
+
"namedetails": 1
|
| 112 |
}
|
| 113 |
headers = {
|
| 114 |
"User-Agent": "BloomWare/1.0 (contact@example.com)"
|
| 115 |
}
|
| 116 |
|
| 117 |
+
# 呼叫 Nominatim API
|
| 118 |
+
data = None
|
| 119 |
+
try:
|
| 120 |
+
async with aiohttp.ClientSession(headers=headers) as session:
|
| 121 |
+
async with session.get(url, params=params, timeout=10) as resp:
|
| 122 |
+
if resp.status != 200:
|
| 123 |
+
raise ExecutionError(f"Nominatim 失敗: HTTP {resp.status}")
|
| 124 |
+
|
| 125 |
+
response_text = await resp.text()
|
| 126 |
+
if not response_text or response_text.strip() == "":
|
| 127 |
+
raise ExecutionError("Nominatim 回應為空")
|
| 128 |
+
|
| 129 |
+
import json
|
| 130 |
+
try:
|
| 131 |
+
data = json.loads(response_text)
|
| 132 |
+
except json.JSONDecodeError:
|
| 133 |
+
raise ExecutionError(f"Nominatim 回應非 JSON: {response_text[:200]}")
|
| 134 |
+
except aiohttp.ClientError as e:
|
| 135 |
+
raise ExecutionError(f"Nominatim 網路錯誤: {e}")
|
| 136 |
+
except asyncio.TimeoutError:
|
| 137 |
+
raise ExecutionError("Nominatim 請求逾時")
|
| 138 |
+
|
| 139 |
+
# 驗證回應
|
| 140 |
+
if data is None:
|
| 141 |
+
raise ExecutionError("Nominatim 回應為 null")
|
| 142 |
+
if not isinstance(data, dict):
|
| 143 |
+
raise ExecutionError(f"Nominatim 回應格式錯誤: {type(data)}")
|
| 144 |
+
if "error" in data:
|
| 145 |
+
raise ExecutionError(f"Nominatim 錯誤: {data.get('error')}")
|
| 146 |
+
|
| 147 |
+
# 解析地址資訊
|
| 148 |
+
addr = data.get("address") or {}
|
| 149 |
+
extratags = data.get("extratags") or {}
|
| 150 |
+
|
| 151 |
+
# 基本地址組件
|
| 152 |
+
road = addr.get("road") or addr.get("pedestrian") or addr.get("footway") or addr.get("cycleway") or ""
|
| 153 |
+
house_number = addr.get("house_number") or ""
|
| 154 |
+
suburb = addr.get("suburb") or addr.get("neighbourhood") or addr.get("quarter") or ""
|
| 155 |
+
city_district = addr.get("city_district") or ""
|
| 156 |
+
city = addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county") or ""
|
| 157 |
+
admin = addr.get("state") or addr.get("county") or ""
|
| 158 |
+
country_code = (addr.get("country_code") or "").upper()
|
| 159 |
+
postcode = addr.get("postcode") or ""
|
| 160 |
+
|
| 161 |
+
# POI 資訊(商店、建築物、設施等)
|
| 162 |
+
amenity = addr.get("amenity") or extratags.get("amenity") or ""
|
| 163 |
+
shop = addr.get("shop") or extratags.get("shop") or ""
|
| 164 |
+
building = addr.get("building") or extratags.get("building") or ""
|
| 165 |
+
office = addr.get("office") or extratags.get("office") or ""
|
| 166 |
+
leisure = addr.get("leisure") or extratags.get("leisure") or ""
|
| 167 |
+
tourism = addr.get("tourism") or extratags.get("tourism") or ""
|
| 168 |
+
|
| 169 |
+
# 地點名稱(優先使用繁中)
|
| 170 |
+
name = data.get("name") or ""
|
| 171 |
+
namedetails = data.get("namedetails", {})
|
| 172 |
+
name_zh = namedetails.get("name:zh") or namedetails.get("name:zh-TW") or name
|
| 173 |
+
|
| 174 |
+
display_name = data.get("display_name") or ""
|
| 175 |
+
|
| 176 |
+
# 組裝精確標籤(優先顯示最精確的資訊)
|
| 177 |
+
label_parts = []
|
| 178 |
+
|
| 179 |
+
# 1. POI 名稱(如「7-11 明倫門市」「台北101」)
|
| 180 |
+
if name_zh and name_zh != road:
|
| 181 |
+
label_parts.append(name_zh)
|
| 182 |
+
|
| 183 |
+
# 2. 門牌號碼 + 路名(如「中正路123號」)
|
| 184 |
+
if road and house_number:
|
| 185 |
+
label_parts.append(f"{road}{house_number}號")
|
| 186 |
+
elif road:
|
| 187 |
+
# 如果沒有門牌,但有路口資訊
|
| 188 |
+
if "路口" in road or "交叉口" in road or "intersection" in road.lower():
|
| 189 |
+
label_parts.append(road)
|
| 190 |
+
else:
|
| 191 |
+
# 嘗試從附近找路口
|
| 192 |
+
label_parts.append(road)
|
| 193 |
+
|
| 194 |
+
# 3. 郵遞區號(如「100」)
|
| 195 |
+
if postcode and len(label_parts) > 0:
|
| 196 |
+
label_parts[0] = f"〒{postcode} {label_parts[0]}"
|
| 197 |
+
|
| 198 |
+
# 4. 區域(如「大安區」)
|
| 199 |
+
if city_district and city_district not in label_parts:
|
| 200 |
+
label_parts.append(city_district)
|
| 201 |
+
elif suburb and suburb not in label_parts:
|
| 202 |
+
label_parts.append(suburb)
|
| 203 |
+
|
| 204 |
+
# 5. 城市(如「台北市」)
|
| 205 |
+
if city and city not in label_parts:
|
| 206 |
+
label_parts.append(city)
|
| 207 |
+
|
| 208 |
+
# 6. 省份/州(如「台灣」)
|
| 209 |
+
if admin and admin not in city and admin not in label_parts:
|
| 210 |
+
label_parts.append(admin)
|
| 211 |
+
|
| 212 |
+
label = ", ".join(filter(None, label_parts))
|
| 213 |
+
|
| 214 |
+
# 組裝詳細地址(用於 AI 顯示)
|
| 215 |
+
detailed_address_parts = []
|
| 216 |
+
if name_zh:
|
| 217 |
+
detailed_address_parts.append(f"地點: {name_zh}")
|
| 218 |
+
if road and house_number:
|
| 219 |
+
detailed_address_parts.append(f"地址: {road}{house_number}號")
|
| 220 |
+
elif road:
|
| 221 |
+
detailed_address_parts.append(f"路段: {road}")
|
| 222 |
+
if suburb:
|
| 223 |
+
detailed_address_parts.append(f"區域: {suburb}")
|
| 224 |
+
if city:
|
| 225 |
+
detailed_address_parts.append(f"城市: {city}")
|
| 226 |
+
if postcode:
|
| 227 |
+
detailed_address_parts.append(f"郵遞區號: {postcode}")
|
| 228 |
+
|
| 229 |
+
detailed_address = " | ".join(detailed_address_parts) if detailed_address_parts else label
|
| 230 |
+
|
| 231 |
+
payload = {
|
| 232 |
+
"lat": lat,
|
| 233 |
+
"lon": lon,
|
| 234 |
+
"city": city or "",
|
| 235 |
+
"admin": admin or "",
|
| 236 |
+
"country_code": country_code,
|
| 237 |
+
"display_name": display_name,
|
| 238 |
+
"label": label or display_name,
|
| 239 |
+
"detailed_address": detailed_address,
|
| 240 |
+
"road": road,
|
| 241 |
+
"house_number": house_number,
|
| 242 |
+
"suburb": suburb,
|
| 243 |
+
"city_district": city_district,
|
| 244 |
+
"postcode": postcode,
|
| 245 |
+
"amenity": amenity,
|
| 246 |
+
"shop": shop,
|
| 247 |
+
"building": building,
|
| 248 |
+
"office": office,
|
| 249 |
+
"leisure": leisure,
|
| 250 |
+
"tourism": tourism,
|
| 251 |
+
"name": name_zh or name,
|
| 252 |
+
}
|
| 253 |
|
| 254 |
# 回寫快取
|
| 255 |
await db_cache.set_geo_cache(geokey, payload)
|
features/mcp/tools/tdx_base.py
CHANGED
|
@@ -1,6 +1,34 @@
|
|
| 1 |
"""
|
| 2 |
TDX 基礎工具類
|
| 3 |
提供 OAuth 認證、API 呼叫、快取等共用功能
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
import os
|
|
@@ -16,7 +44,10 @@ from core.database.cache import db_cache
|
|
| 16 |
|
| 17 |
logger = logging.getLogger("mcp.tools.tdx")
|
| 18 |
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
TDX_CLIENT_ID = os.getenv("TDX_CLIENT_ID", "")
|
| 21 |
TDX_CLIENT_SECRET = os.getenv("TDX_CLIENT_SECRET", "")
|
| 22 |
|
|
@@ -28,21 +59,25 @@ class TDXBaseAPI:
|
|
| 28 |
|
| 29 |
@classmethod
|
| 30 |
async def get_access_token(cls) -> str:
|
| 31 |
-
"""獲取 TDX Access Token
|
| 32 |
# 檢查快取
|
| 33 |
if cls._token_cache.get("token") and cls._token_cache.get("expires_at"):
|
| 34 |
if datetime.now() < cls._token_cache["expires_at"]:
|
| 35 |
return cls._token_cache["token"]
|
| 36 |
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
raise ExecutionError("未設定 TDX_CLIENT_ID 或 TDX_CLIENT_SECRET 環境變數")
|
| 39 |
|
| 40 |
# 請求新 token
|
| 41 |
auth_url = "https://tdx.transportdata.tw/auth/realms/TDXConnect/protocol/openid-connect/token"
|
| 42 |
data = {
|
| 43 |
"grant_type": "client_credentials",
|
| 44 |
-
"client_id":
|
| 45 |
-
"client_secret":
|
| 46 |
}
|
| 47 |
|
| 48 |
try:
|
|
@@ -72,52 +107,121 @@ class TDXBaseAPI:
|
|
| 72 |
raise ExecutionError(f"TDX 認證網路錯誤: {e}")
|
| 73 |
|
| 74 |
@classmethod
|
| 75 |
-
async def call_api(
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
access_token = await cls.get_access_token()
|
| 79 |
|
| 80 |
-
|
|
|
|
| 81 |
headers = {
|
| 82 |
"Authorization": f"Bearer {access_token}",
|
| 83 |
"Accept": "application/json"
|
| 84 |
}
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
# 生成快取鍵
|
| 87 |
-
cache_key = f"tdx:{endpoint}:{json.dumps(params
|
| 88 |
|
| 89 |
# 檢查快取
|
| 90 |
if cache_ttl > 0:
|
| 91 |
cached = await db_cache.get_tdx_cached(cache_key)
|
| 92 |
-
if cached:
|
| 93 |
logger.debug(f"📦 TDX 快取命中: {endpoint}")
|
| 94 |
return cached
|
| 95 |
|
| 96 |
# 呼叫 API
|
|
|
|
|
|
|
|
|
|
| 97 |
try:
|
| 98 |
-
async with aiohttp.ClientSession(
|
| 99 |
-
async with session.get(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
if resp.status == 304:
|
| 101 |
logger.info("TDX 資料未變更 (304)")
|
| 102 |
return cached if cached else []
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
if resp.status != 200:
|
| 105 |
-
|
| 106 |
-
|
|
|
|
| 107 |
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
# 快取結果
|
| 111 |
-
if cache_ttl > 0:
|
| 112 |
await db_cache.set_tdx_cache(cache_key, data, ttl=cache_ttl)
|
| 113 |
|
| 114 |
-
logger.info(f"✅ TDX API 成功: {endpoint}")
|
| 115 |
return data
|
| 116 |
|
| 117 |
except asyncio.TimeoutError:
|
| 118 |
-
|
|
|
|
|
|
|
| 119 |
except aiohttp.ClientError as e:
|
| 120 |
-
|
|
|
|
|
|
|
| 121 |
|
| 122 |
@staticmethod
|
| 123 |
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
@@ -136,12 +240,21 @@ class TDXBaseAPI:
|
|
| 136 |
|
| 137 |
@staticmethod
|
| 138 |
def format_datetime(dt_str: str) -> str:
|
| 139 |
-
"""格式化 TDX
|
| 140 |
if not dt_str:
|
| 141 |
return "未知"
|
| 142 |
try:
|
| 143 |
-
# TDX 格式: 2024-11-01T14:30:00+08:00
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
except:
|
| 147 |
return dt_str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
TDX 基礎工具類
|
| 3 |
提供 OAuth 認證、API 呼叫、快取等共用功能
|
| 4 |
+
|
| 5 |
+
TDX API 文件: https://tdx.transportdata.tw/api-service/swagger
|
| 6 |
+
|
| 7 |
+
API 版本說明:
|
| 8 |
+
- v2: 公車(Bus)、自行車(Bike)、停車場(Parking)、高鐵(THSR)、捷運(Metro)
|
| 9 |
+
- v3: 台鐵(TRA)
|
| 10 |
+
|
| 11 |
+
正確端點範例:
|
| 12 |
+
- 公車: /v2/Bus/EstimatedTimeOfArrival/City/{City}/{RouteName}
|
| 13 |
+
- 公車站: /v2/Bus/Stop/City/{City}
|
| 14 |
+
- 自行車站: /v2/Bike/Station/City/{City}
|
| 15 |
+
- 自行車即時: /v2/Bike/Availability/City/{City}
|
| 16 |
+
- 停車場: /v2/Parking/OffStreet/CarPark/City/{City}
|
| 17 |
+
- 高鐵時刻: /v2/Rail/THSR/DailyTimetable/TrainDates/{TrainDate}
|
| 18 |
+
- 高鐵車站: /v2/Rail/THSR/Station
|
| 19 |
+
- 捷運車站: /v2/Rail/Metro/Station/{Operator}
|
| 20 |
+
- 捷運即時: /v2/Rail/Metro/LiveBoard/{Operator}
|
| 21 |
+
- 台鐵時刻: /v3/Rail/TRA/DailyTrainTimetable/Today
|
| 22 |
+
- 台鐵車站: /v3/Rail/TRA/Station
|
| 23 |
+
|
| 24 |
+
OData 查詢參數:
|
| 25 |
+
- $select: 選擇欄位
|
| 26 |
+
- $filter: 過濾條件
|
| 27 |
+
- $orderby: 排序
|
| 28 |
+
- $top: 取前 N 筆
|
| 29 |
+
- $skip: 跳過 N 筆
|
| 30 |
+
- $spatialFilter: 空間過濾 nearby(lat, lon, distance_m)
|
| 31 |
+
- $format: 回傳格式 (JSON)
|
| 32 |
"""
|
| 33 |
|
| 34 |
import os
|
|
|
|
| 44 |
|
| 45 |
logger = logging.getLogger("mcp.tools.tdx")
|
| 46 |
|
| 47 |
+
# TDX API 基礎 URL
|
| 48 |
+
TDX_BASE_URL = "https://tdx.transportdata.tw/api/basic"
|
| 49 |
+
|
| 50 |
+
# 從環境變數讀取(需要在 app.py 中先 load_dotenv)
|
| 51 |
TDX_CLIENT_ID = os.getenv("TDX_CLIENT_ID", "")
|
| 52 |
TDX_CLIENT_SECRET = os.getenv("TDX_CLIENT_SECRET", "")
|
| 53 |
|
|
|
|
| 59 |
|
| 60 |
@classmethod
|
| 61 |
async def get_access_token(cls) -> str:
|
| 62 |
+
"""獲取 TDX Access Token(快取至過期前 60 秒)"""
|
| 63 |
# 檢查快取
|
| 64 |
if cls._token_cache.get("token") and cls._token_cache.get("expires_at"):
|
| 65 |
if datetime.now() < cls._token_cache["expires_at"]:
|
| 66 |
return cls._token_cache["token"]
|
| 67 |
|
| 68 |
+
# 重新讀取環境變數(確保 load_dotenv 後能取得)
|
| 69 |
+
client_id = os.getenv("TDX_CLIENT_ID", "") or TDX_CLIENT_ID
|
| 70 |
+
client_secret = os.getenv("TDX_CLIENT_SECRET", "") or TDX_CLIENT_SECRET
|
| 71 |
+
|
| 72 |
+
if not client_id or not client_secret:
|
| 73 |
raise ExecutionError("未設定 TDX_CLIENT_ID 或 TDX_CLIENT_SECRET 環境變數")
|
| 74 |
|
| 75 |
# 請求新 token
|
| 76 |
auth_url = "https://tdx.transportdata.tw/auth/realms/TDXConnect/protocol/openid-connect/token"
|
| 77 |
data = {
|
| 78 |
"grant_type": "client_credentials",
|
| 79 |
+
"client_id": client_id,
|
| 80 |
+
"client_secret": client_secret
|
| 81 |
}
|
| 82 |
|
| 83 |
try:
|
|
|
|
| 107 |
raise ExecutionError(f"TDX 認證網路錯誤: {e}")
|
| 108 |
|
| 109 |
@classmethod
|
| 110 |
+
async def call_api(
|
| 111 |
+
cls,
|
| 112 |
+
endpoint: str,
|
| 113 |
+
params: Optional[Dict[str, Any]] = None,
|
| 114 |
+
cache_ttl: int = 60,
|
| 115 |
+
api_version: str = "v2"
|
| 116 |
+
) -> Any:
|
| 117 |
+
"""
|
| 118 |
+
呼叫 TDX API 並處理快取
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
endpoint: API 端點路徑(不含版本號,如 "Bus/Route/City/Taipei")
|
| 122 |
+
params: OData 查詢參數
|
| 123 |
+
cache_ttl: 快取時間(秒),0 表示不快取
|
| 124 |
+
api_version: API 版本(v2 或 v3)
|
| 125 |
+
|
| 126 |
+
Returns:
|
| 127 |
+
API 回應資料(通常是 list 或 dict)
|
| 128 |
+
"""
|
| 129 |
access_token = await cls.get_access_token()
|
| 130 |
|
| 131 |
+
# 組合完整 URL
|
| 132 |
+
url = f"{TDX_BASE_URL}/{api_version}/{endpoint}"
|
| 133 |
headers = {
|
| 134 |
"Authorization": f"Bearer {access_token}",
|
| 135 |
"Accept": "application/json"
|
| 136 |
}
|
| 137 |
|
| 138 |
+
# 確保有 $format 參數
|
| 139 |
+
if params is None:
|
| 140 |
+
params = {}
|
| 141 |
+
if "$format" not in params:
|
| 142 |
+
params["$format"] = "JSON"
|
| 143 |
+
|
| 144 |
# 生成快取鍵
|
| 145 |
+
cache_key = f"tdx:{api_version}:{endpoint}:{json.dumps(params, sort_keys=True)}"
|
| 146 |
|
| 147 |
# 檢查快取
|
| 148 |
if cache_ttl > 0:
|
| 149 |
cached = await db_cache.get_tdx_cached(cache_key)
|
| 150 |
+
if cached is not None:
|
| 151 |
logger.debug(f"📦 TDX 快取命中: {endpoint}")
|
| 152 |
return cached
|
| 153 |
|
| 154 |
# 呼叫 API
|
| 155 |
+
logger.info(f"🌐 TDX API 請求: {url}")
|
| 156 |
+
logger.info(f" 參數: {params}")
|
| 157 |
+
|
| 158 |
try:
|
| 159 |
+
async with aiohttp.ClientSession() as session:
|
| 160 |
+
async with session.get(
|
| 161 |
+
url,
|
| 162 |
+
headers=headers,
|
| 163 |
+
params=params,
|
| 164 |
+
timeout=aiohttp.ClientTimeout(total=30)
|
| 165 |
+
) as resp:
|
| 166 |
+
response_text = await resp.text()
|
| 167 |
+
|
| 168 |
+
# 記錄完整回應(用於除錯)
|
| 169 |
+
logger.info(f"📥 TDX API 回應: HTTP {resp.status}")
|
| 170 |
+
if resp.status != 200:
|
| 171 |
+
logger.error(f"❌ TDX API 錯誤回應:")
|
| 172 |
+
logger.error(f" URL: {url}")
|
| 173 |
+
logger.error(f" 參數: {params}")
|
| 174 |
+
logger.error(f" 狀態碼: {resp.status}")
|
| 175 |
+
logger.error(f" 回應內容: {response_text[:1000]}")
|
| 176 |
+
|
| 177 |
if resp.status == 304:
|
| 178 |
logger.info("TDX 資料未變更 (304)")
|
| 179 |
return cached if cached else []
|
| 180 |
|
| 181 |
+
if resp.status == 401:
|
| 182 |
+
# Token 過期,清除快取重試
|
| 183 |
+
cls._token_cache = {}
|
| 184 |
+
error_msg = f"TDX Token 已過期,請重試\n[API] {url}\n[回應] {response_text[:500]}"
|
| 185 |
+
raise ExecutionError(error_msg)
|
| 186 |
+
|
| 187 |
+
if resp.status == 404:
|
| 188 |
+
error_msg = f"TDX API 找不到資源 (404)\n[API] {url}\n[參數] {params}\n[回應] {response_text[:500]}"
|
| 189 |
+
logger.error(error_msg)
|
| 190 |
+
raise ExecutionError(error_msg)
|
| 191 |
+
|
| 192 |
if resp.status != 200:
|
| 193 |
+
error_msg = f"TDX API 錯誤: HTTP {resp.status}\n[API] {url}\n[參數] {params}\n[回應] {response_text[:500]}"
|
| 194 |
+
logger.error(error_msg)
|
| 195 |
+
raise ExecutionError(error_msg)
|
| 196 |
|
| 197 |
+
try:
|
| 198 |
+
data = json.loads(response_text)
|
| 199 |
+
except json.JSONDecodeError:
|
| 200 |
+
error_msg = f"TDX API 回應非 JSON 格式\n[API] {url}\n[回應] {response_text[:500]}"
|
| 201 |
+
raise ExecutionError(error_msg)
|
| 202 |
+
|
| 203 |
+
# 記錄回應資料筆數
|
| 204 |
+
data_count = len(data) if isinstance(data, list) else 1
|
| 205 |
+
logger.info(f"✅ TDX API 成功: {endpoint} (共 {data_count} 筆)")
|
| 206 |
+
|
| 207 |
+
# 如果回應是空陣列,記錄警告
|
| 208 |
+
if isinstance(data, list) and len(data) == 0:
|
| 209 |
+
logger.warning(f"⚠️ TDX API 回應空陣列: {url}")
|
| 210 |
|
| 211 |
# 快取結果
|
| 212 |
+
if cache_ttl > 0 and data:
|
| 213 |
await db_cache.set_tdx_cache(cache_key, data, ttl=cache_ttl)
|
| 214 |
|
|
|
|
| 215 |
return data
|
| 216 |
|
| 217 |
except asyncio.TimeoutError:
|
| 218 |
+
error_msg = f"TDX API 逾時\n[API] {url}\n[參數] {params}"
|
| 219 |
+
logger.error(error_msg)
|
| 220 |
+
raise ExecutionError(error_msg)
|
| 221 |
except aiohttp.ClientError as e:
|
| 222 |
+
error_msg = f"TDX API 網路錯誤: {e}\n[API] {url}"
|
| 223 |
+
logger.error(error_msg)
|
| 224 |
+
raise ExecutionError(error_msg)
|
| 225 |
|
| 226 |
@staticmethod
|
| 227 |
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
|
|
| 240 |
|
| 241 |
@staticmethod
|
| 242 |
def format_datetime(dt_str: str) -> str:
|
| 243 |
+
"""格式化 TDX 時間字串為 HH:MM"""
|
| 244 |
if not dt_str:
|
| 245 |
return "未知"
|
| 246 |
try:
|
| 247 |
+
# TDX 格式: 2024-11-01T14:30:00+08:00 或 14:30:00
|
| 248 |
+
if "T" in dt_str:
|
| 249 |
+
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
|
| 250 |
+
return dt.strftime("%H:%M")
|
| 251 |
+
elif ":" in dt_str:
|
| 252 |
+
return dt_str[:5]
|
| 253 |
+
return dt_str
|
| 254 |
except:
|
| 255 |
return dt_str
|
| 256 |
+
|
| 257 |
+
@staticmethod
|
| 258 |
+
def get_today_date() -> str:
|
| 259 |
+
"""取得今日日期字串 (YYYY-MM-DD)"""
|
| 260 |
+
return datetime.now().strftime("%Y-%m-%d")
|
features/mcp/tools/tdx_bus_arrival.py
CHANGED
|
@@ -1,6 +1,13 @@
|
|
| 1 |
"""
|
| 2 |
TDX 公車即時到站工具
|
| 3 |
查詢附近公車站、特定路線到站時間
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
import logging
|
|
@@ -27,6 +34,15 @@ class TDXBusArrivalTool(MCPTool):
|
|
| 27 |
"指定城市: 「台北 307」「高雄紅30」"
|
| 28 |
]
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
@classmethod
|
| 31 |
def get_input_schema(cls) -> Dict[str, Any]:
|
| 32 |
return StandardToolSchemas.create_input_schema({
|
|
@@ -36,17 +52,20 @@ class TDXBusArrivalTool(MCPTool):
|
|
| 36 |
},
|
| 37 |
"city": {
|
| 38 |
"type": "string",
|
| 39 |
-
"description": "
|
| 40 |
-
"enum": ["Taipei", "NewTaipei", "Taoyuan", "Taichung", "Tainan", "Kaohsiung",
|
| 41 |
-
"Keelung", "Hsinchu", "HsinchuCounty", "MiaoliCounty", "ChanghuaCounty",
|
| 42 |
-
"NantouCounty", "YunlinCounty", "ChiayiCounty", "Chiayi", "PingtungCounty",
|
| 43 |
-
"YilanCounty", "HualienCounty", "TaitungCounty", "KinmenCounty", "PenghuCounty",
|
| 44 |
-
"LienchiangCounty"]
|
| 45 |
},
|
| 46 |
"limit": {
|
| 47 |
"type": "integer",
|
| 48 |
"description": "返回結果數量上限",
|
| 49 |
"default": 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
}
|
| 51 |
}, required=[])
|
| 52 |
|
|
@@ -61,7 +80,7 @@ class TDXBusArrivalTool(MCPTool):
|
|
| 61 |
"properties": {
|
| 62 |
"route_name": {"type": "string"},
|
| 63 |
"stop_name": {"type": "string"},
|
| 64 |
-
"direction": {"type": "
|
| 65 |
"estimate_time": {"type": "integer"},
|
| 66 |
"status": {"type": "string"}
|
| 67 |
}
|
|
@@ -71,162 +90,309 @@ class TDXBusArrivalTool(MCPTool):
|
|
| 71 |
return schema
|
| 72 |
|
| 73 |
@classmethod
|
| 74 |
-
async def execute(cls, arguments: Dict[str, Any]
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
| 77 |
limit = min(int(arguments.get("limit", 5)), 20)
|
| 78 |
|
| 79 |
-
# 1.
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
-
#
|
| 92 |
-
if not
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
if route_name:
|
| 97 |
-
|
| 98 |
-
result = await cls._query_route_arrival(route_name, city, user_lat, user_lon, limit)
|
| 99 |
else:
|
| 100 |
-
|
| 101 |
-
if not user_lat or not user_lon:
|
| 102 |
-
raise ExecutionError("查詢附近公車需要定位權限")
|
| 103 |
-
result = await cls._query_nearby_stops(user_lat, user_lon, city, limit)
|
| 104 |
-
|
| 105 |
-
return result
|
| 106 |
|
| 107 |
@classmethod
|
| 108 |
-
async def _query_route_arrival(
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
}
|
| 119 |
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
-
|
| 123 |
-
|
|
|
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
"$filter": f"RouteUID eq '{route_uid}'",
|
| 133 |
-
"$format": "JSON"
|
| 134 |
-
}
|
| 135 |
|
| 136 |
-
|
|
|
|
|
|
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
-
# 3.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
if user_lat and user_lon:
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
)
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
| 165 |
|
| 166 |
-
#
|
| 167 |
arrivals = []
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
}
|
| 177 |
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
"status": status_text,
|
| 203 |
-
"distance_m": stop.get("distance_m", 0)
|
| 204 |
-
})
|
| 205 |
-
|
| 206 |
-
# 5. 格式化回覆
|
| 207 |
content = cls._format_arrival_result(arrivals, full_route_name, user_lat is not None)
|
|
|
|
| 208 |
|
| 209 |
return cls.create_success_response(
|
| 210 |
content=content,
|
| 211 |
-
data={"arrivals": arrivals, "route_name": full_route_name}
|
| 212 |
)
|
| 213 |
|
| 214 |
@classmethod
|
| 215 |
-
async def _query_nearby_stops(
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
endpoint = f"Bus/Stop/City/{city}"
|
| 219 |
params = {
|
| 220 |
-
"$spatialFilter": f"nearby({lat}, {lon},
|
| 221 |
-
"$
|
| 222 |
-
"$
|
| 223 |
}
|
| 224 |
|
| 225 |
stops = await TDXBaseAPI.call_api(endpoint, params, cache_ttl=1800)
|
| 226 |
|
| 227 |
if not stops:
|
| 228 |
return cls.create_success_response(
|
| 229 |
-
content="附近
|
| 230 |
data={"stops": []}
|
| 231 |
)
|
| 232 |
|
|
@@ -239,23 +405,29 @@ class TDXBusArrivalTool(MCPTool):
|
|
| 239 |
pos["PositionLat"], pos["PositionLon"]
|
| 240 |
)
|
| 241 |
|
| 242 |
-
stops = [s for s in stops if "distance_m"
|
| 243 |
stops.sort(key=lambda x: x["distance_m"])
|
| 244 |
-
stops = stops[:limit]
|
| 245 |
|
| 246 |
-
#
|
| 247 |
results = []
|
|
|
|
|
|
|
| 248 |
for stop in stops:
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
|
|
|
| 252 |
|
|
|
|
| 253 |
results.append({
|
| 254 |
-
"stop_name":
|
| 255 |
"distance_m": int(distance),
|
| 256 |
-
"walking_time_min":
|
| 257 |
"stop_uid": stop.get("StopUID")
|
| 258 |
})
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
content = cls._format_nearby_result(results)
|
| 261 |
|
|
@@ -264,59 +436,213 @@ class TDXBusArrivalTool(MCPTool):
|
|
| 264 |
data={"stops": results}
|
| 265 |
)
|
| 266 |
|
| 267 |
-
@
|
| 268 |
-
def
|
| 269 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
city_map = {
|
| 271 |
-
"台北": "Taipei", "臺北": "Taipei",
|
| 272 |
"新北": "NewTaipei", "新北市": "NewTaipei",
|
| 273 |
-
"桃園": "Taoyuan",
|
| 274 |
-
"台中": "Taichung", "臺中": "Taichung",
|
| 275 |
-
"台南": "Tainan", "臺南": "Tainan",
|
| 276 |
-
"
|
| 277 |
-
"基隆": "Keelung",
|
| 278 |
-
"新竹": "Hsinchu",
|
| 279 |
-
"嘉義": "Chiayi"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
}
|
| 281 |
|
| 282 |
-
|
| 283 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
return value
|
| 285 |
|
| 286 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
|
| 288 |
@staticmethod
|
| 289 |
def _format_arrival_result(arrivals: List[Dict], route_name: str, has_location: bool) -> str:
|
| 290 |
-
"""
|
| 291 |
if not arrivals:
|
| 292 |
return f"路線 {route_name} 目前無即時到站資訊"
|
| 293 |
-
|
| 294 |
-
lines = [f"🚌 {route_name}
|
| 295 |
-
|
| 296 |
-
# 按站點分組
|
| 297 |
-
stops_dict = {}
|
| 298 |
for arr in arrivals:
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
dist_info = ""
|
| 306 |
-
if has_location and
|
| 307 |
-
dist =
|
| 308 |
-
walk_time = int(dist / 80)
|
| 309 |
-
dist_info = f"
|
| 310 |
-
|
| 311 |
-
lines.append(
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
|
|
|
|
|
|
|
|
|
| 317 |
lines.append("")
|
| 318 |
-
|
| 319 |
-
return "\n".join(lines)
|
| 320 |
|
| 321 |
@staticmethod
|
| 322 |
def _format_nearby_result(stops: List[Dict]) -> str:
|
|
@@ -329,9 +655,9 @@ class TDXBusArrivalTool(MCPTool):
|
|
| 329 |
for i, stop in enumerate(stops, 1):
|
| 330 |
lines.append(
|
| 331 |
f"{i}. 🚏 {stop['stop_name']}\n"
|
| 332 |
-
f" 步行 {stop['walking_time_min']} 分鐘 ({stop['distance_m']}m)
|
| 333 |
)
|
| 334 |
|
| 335 |
-
lines.append("💡
|
| 336 |
|
| 337 |
return "\n".join(lines)
|
|
|
|
| 1 |
"""
|
| 2 |
TDX 公車即時到站工具
|
| 3 |
查詢附近公車站、特定路線到站時間
|
| 4 |
+
|
| 5 |
+
TDX CityBus API (v2):
|
| 6 |
+
- GET /v2/Bus/EstimatedTimeOfArrival/City/{City}/{RouteName} - 指定路線預估到站
|
| 7 |
+
- GET /v2/Bus/Stop/City/{City} - 市區公車站牌資料(支援 $spatialFilter)
|
| 8 |
+
- GET /v2/Bus/Route/City/{City}/{RouteName} - 指定路線資料
|
| 9 |
+
|
| 10 |
+
API 文件: https://tdx.transportdata.tw/api-service/swagger#/CityBus
|
| 11 |
"""
|
| 12 |
|
| 13 |
import logging
|
|
|
|
| 34 |
"指定城市: 「台北 307」「高雄紅30」"
|
| 35 |
]
|
| 36 |
|
| 37 |
+
# TDX 城市代碼
|
| 38 |
+
VALID_CITIES = {
|
| 39 |
+
"Taipei", "NewTaipei", "Taoyuan", "Taichung", "Tainan", "Kaohsiung",
|
| 40 |
+
"Keelung", "Hsinchu", "HsinchuCounty", "MiaoliCounty", "ChanghuaCounty",
|
| 41 |
+
"NantouCounty", "YunlinCounty", "ChiayiCounty", "Chiayi", "PingtungCounty",
|
| 42 |
+
"YilanCounty", "HualienCounty", "TaitungCounty", "KinmenCounty",
|
| 43 |
+
"PenghuCounty", "LienchiangCounty"
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
@classmethod
|
| 47 |
def get_input_schema(cls) -> Dict[str, Any]:
|
| 48 |
return StandardToolSchemas.create_input_schema({
|
|
|
|
| 52 |
},
|
| 53 |
"city": {
|
| 54 |
"type": "string",
|
| 55 |
+
"description": "城市(預設從環境感知自動判斷,支援中文或英文代碼)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
},
|
| 57 |
"limit": {
|
| 58 |
"type": "integer",
|
| 59 |
"description": "返回結果數量上限",
|
| 60 |
"default": 5
|
| 61 |
+
},
|
| 62 |
+
"lat": {
|
| 63 |
+
"type": "number",
|
| 64 |
+
"description": "用戶緯度(由系統自動注入)"
|
| 65 |
+
},
|
| 66 |
+
"lon": {
|
| 67 |
+
"type": "number",
|
| 68 |
+
"description": "用戶經度(由系統自動注入)"
|
| 69 |
}
|
| 70 |
}, required=[])
|
| 71 |
|
|
|
|
| 80 |
"properties": {
|
| 81 |
"route_name": {"type": "string"},
|
| 82 |
"stop_name": {"type": "string"},
|
| 83 |
+
"direction": {"type": "integer"},
|
| 84 |
"estimate_time": {"type": "integer"},
|
| 85 |
"status": {"type": "string"}
|
| 86 |
}
|
|
|
|
| 90 |
return schema
|
| 91 |
|
| 92 |
@classmethod
|
| 93 |
+
async def execute(cls, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 94 |
+
# 從 arguments 中讀取 user_id(由 coordinator 注入)
|
| 95 |
+
user_id = arguments.get("_user_id")
|
| 96 |
+
|
| 97 |
+
route_name = str(arguments.get("route_name", "")).strip()
|
| 98 |
limit = min(int(arguments.get("limit", 5)), 20)
|
| 99 |
|
| 100 |
+
# 1. 取得用戶位置和城市
|
| 101 |
+
user_lat = arguments.get("lat")
|
| 102 |
+
user_lon = arguments.get("lon")
|
| 103 |
+
city_param = str(arguments.get("city", "")).strip()
|
| 104 |
+
|
| 105 |
+
print(f"🚌 [TDX] tdx_bus_arrival 輸入: route={route_name}, lat={user_lat}, lon={user_lon}, city={city_param}, user_id={user_id}")
|
| 106 |
+
|
| 107 |
+
# 從資料庫補充位置和城市(僅當 coordinator 沒有注入時)
|
| 108 |
+
if user_id and (user_lat is None or user_lon is None):
|
| 109 |
+
try:
|
| 110 |
+
env_ctx = await get_user_env_current(user_id)
|
| 111 |
+
print(f"📍 [TDX] 資料庫環境查詢結果: {env_ctx}")
|
| 112 |
+
if env_ctx and env_ctx.get("success"):
|
| 113 |
+
ctx = env_ctx.get("context", {})
|
| 114 |
+
# 補充缺失的位置資訊
|
| 115 |
+
if user_lat is None:
|
| 116 |
+
user_lat = ctx.get("lat")
|
| 117 |
+
print(f"📍 [TDX] 從資料庫補充 lat: {user_lat}")
|
| 118 |
+
if user_lon is None:
|
| 119 |
+
user_lon = ctx.get("lon")
|
| 120 |
+
print(f"📍 [TDX] 從資料庫補充 lon: {user_lon}")
|
| 121 |
+
# 優先使用環境中的城市(如果參數沒有指定)
|
| 122 |
+
if not city_param:
|
| 123 |
+
city_param = ctx.get("city", "")
|
| 124 |
+
print(f"📍 [TDX] 從資料庫補充 city: {city_param}")
|
| 125 |
+
except Exception as e:
|
| 126 |
+
print(f"⚠️ [TDX] 資料庫環境查詢失敗: {e}")
|
| 127 |
+
|
| 128 |
+
print(f"🚌 [TDX] 補充後: lat={user_lat}, lon={user_lon}, city={city_param}")
|
| 129 |
+
|
| 130 |
+
# 檢查必要條件
|
| 131 |
+
if not route_name and (user_lat is None or user_lon is None):
|
| 132 |
+
raise ExecutionError("無法取得您的位置,請提供路線名稱或開啟定位權限")
|
| 133 |
+
|
| 134 |
+
# 2. 判斷城市代碼
|
| 135 |
+
# 優先順序:即時反向地理編碼 > 環境參數 > 經緯度範圍推斷 > 預設值
|
| 136 |
+
city_source = "預設"
|
| 137 |
+
final_city = None
|
| 138 |
|
| 139 |
+
# 2a. 如果有經緯度,嘗試即時反向地理編碼取得精確城市
|
| 140 |
+
if user_lat is not None and user_lon is not None:
|
| 141 |
+
print(f"🗺️ [TDX] 嘗試反向地理編碼: ({user_lat}, {user_lon})")
|
| 142 |
+
geocoded_city = await cls._reverse_geocode_city(user_lat, user_lon)
|
| 143 |
+
print(f"🗺️ [TDX] 反向地理編碼結果: {geocoded_city}")
|
| 144 |
+
if geocoded_city:
|
| 145 |
+
final_city = geocoded_city
|
| 146 |
+
city_source = "反向地理編碼"
|
| 147 |
|
| 148 |
+
# 2b. 如果反向地理編碼失敗,使用環境參數
|
| 149 |
+
if not final_city and city_param:
|
| 150 |
+
final_city = city_param
|
| 151 |
+
city_source = "環境參數"
|
| 152 |
+
print(f"📍 [TDX] 使用環境參數城市: {city_param}")
|
| 153 |
+
|
| 154 |
+
# 2c. 如果還是沒有,使用經緯度範圍推斷
|
| 155 |
+
if not final_city and user_lat is not None and user_lon is not None:
|
| 156 |
+
guessed_city = cls._guess_city_from_location(user_lat, user_lon)
|
| 157 |
+
print(f"📐 [TDX] 經緯度推斷結果: {guessed_city}")
|
| 158 |
+
if guessed_city:
|
| 159 |
+
final_city = guessed_city
|
| 160 |
+
city_source = "經緯度推斷"
|
| 161 |
+
|
| 162 |
+
# 2d. 轉換為 TDX 城市代碼
|
| 163 |
+
city = cls._resolve_city(final_city or "")
|
| 164 |
+
print(f"🏙️ [TDX] 最終城市: {city} (來源={city_source}, 原始={final_city})")
|
| 165 |
+
|
| 166 |
+
# 3. 執行查詢
|
| 167 |
if route_name:
|
| 168 |
+
return await cls._query_route_arrival(route_name, city, user_lat, user_lon, limit)
|
|
|
|
| 169 |
else:
|
| 170 |
+
return await cls._query_nearby_stops(user_lat, user_lon, city, limit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
@classmethod
|
| 173 |
+
async def _query_route_arrival(
|
| 174 |
+
cls,
|
| 175 |
+
route_name: str,
|
| 176 |
+
city: str,
|
| 177 |
+
user_lat: Optional[float],
|
| 178 |
+
user_lon: Optional[float],
|
| 179 |
+
limit: int
|
| 180 |
+
) -> Dict[str, Any]:
|
| 181 |
+
"""
|
| 182 |
+
查詢特定路線的即時到站(含公車目前位置)
|
|
|
|
| 183 |
|
| 184 |
+
APIs:
|
| 185 |
+
- GET /v2/Bus/EstimatedTimeOfArrival/City/{City}/{RouteName} - 預估到站時間
|
| 186 |
+
- GET /v2/Bus/RealTimeNearStop/City/{City}/{RouteName} - 公車目前在哪站
|
| 187 |
+
"""
|
| 188 |
+
print(f"🚌 [TDX] 查詢公車到站: 路線={route_name}, 城市={city}")
|
| 189 |
|
| 190 |
+
# 1. 查詢預估到站時間
|
| 191 |
+
eta_endpoint = f"Bus/EstimatedTimeOfArrival/City/{city}/{route_name}"
|
| 192 |
+
eta_params = {"$orderby": "StopSequence", "$format": "JSON"}
|
| 193 |
|
| 194 |
+
try:
|
| 195 |
+
print(f"🌐 [TDX] 呼叫 API: {eta_endpoint}")
|
| 196 |
+
arrival_data = await TDXBaseAPI.call_api(eta_endpoint, eta_params, cache_ttl=30)
|
| 197 |
+
print(f"✅ [TDX] API 回應: {len(arrival_data) if arrival_data else 0} 筆資料")
|
| 198 |
+
if arrival_data and len(arrival_data) > 0:
|
| 199 |
+
print(f"📋 [TDX] 第一筆: {arrival_data[0].get('StopName', {}).get('Zh_tw')}")
|
| 200 |
+
except ExecutionError as e:
|
| 201 |
+
error_detail = str(e)
|
| 202 |
+
print(f"❌ [TDX] API 錯誤: {error_detail}")
|
| 203 |
+
if "404" in error_detail:
|
| 204 |
+
raise ExecutionError(f"找不到路線「{route_name}」,請確認路線名稱與城市")
|
| 205 |
+
raise ExecutionError(f"查詢路線「{route_name}」失敗: {error_detail}")
|
| 206 |
|
| 207 |
+
if not arrival_data:
|
| 208 |
+
print(f"⚠️ [TDX] 無資料,拋出錯誤")
|
| 209 |
+
raise ExecutionError(f"路線「{route_name}」目前無班次資訊")
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
+
# 2. 查詢公車即時位置(目前在哪站)
|
| 212 |
+
realtime_endpoint = f"Bus/RealTimeNearStop/City/{city}/{route_name}"
|
| 213 |
+
realtime_params = {"$format": "JSON"}
|
| 214 |
|
| 215 |
+
bus_positions = {} # {direction: [{plate, stop_name, stop_sequence, event_type}]}
|
| 216 |
+
try:
|
| 217 |
+
realtime_data = await TDXBaseAPI.call_api(realtime_endpoint, realtime_params, cache_ttl=15)
|
| 218 |
+
if realtime_data:
|
| 219 |
+
for bus in realtime_data:
|
| 220 |
+
direction = bus.get("Direction", 0)
|
| 221 |
+
plate = bus.get("PlateNumb", "")
|
| 222 |
+
stop_name = bus.get("StopName", {}).get("Zh_tw", "")
|
| 223 |
+
stop_sequence = bus.get("StopSequence", 0) # 公車目前站序
|
| 224 |
+
event_type = bus.get("A2EventType", 0) # 0=離站, 1=進站
|
| 225 |
+
|
| 226 |
+
if direction not in bus_positions:
|
| 227 |
+
bus_positions[direction] = []
|
| 228 |
+
bus_positions[direction].append({
|
| 229 |
+
"plate": plate,
|
| 230 |
+
"current_stop": stop_name,
|
| 231 |
+
"stop_sequence": stop_sequence, # 新增站序
|
| 232 |
+
"event": "進站中" if event_type == 1 else "已離站"
|
| 233 |
+
})
|
| 234 |
+
except Exception as e:
|
| 235 |
+
logger.warning(f"⚠️ 無法取得公車即時位置: {e}")
|
| 236 |
|
| 237 |
+
# 3. 取得路線全名
|
| 238 |
+
route_obj = arrival_data[0].get("RouteName", {})
|
| 239 |
+
full_route_name = route_obj.get("Zh_tw") or route_obj.get("En") or route_name
|
| 240 |
+
|
| 241 |
+
# 4. 查詢站點座標、終點站資訊,並計算距離
|
| 242 |
+
# EstimatedTimeOfArrival 不含座標,需查詢 StopOfRoute API 取得站序和座標
|
| 243 |
+
destination_stations = {} # {direction: destination_name}
|
| 244 |
+
|
| 245 |
if user_lat and user_lon:
|
| 246 |
+
try:
|
| 247 |
+
# 使用 StopOfRoute API 取得該路線所有站點的座標
|
| 248 |
+
stop_route_endpoint = f"Bus/StopOfRoute/City/{city}/{route_name}"
|
| 249 |
+
stop_route_params = {"$format": "JSON"}
|
| 250 |
+
stops_of_route = await TDXBaseAPI.call_api(stop_route_endpoint, stop_route_params, cache_ttl=3600)
|
| 251 |
+
|
| 252 |
+
# 建立 StopUID -> 座標 的映射,並提取終點站資訊
|
| 253 |
+
stop_positions = {}
|
| 254 |
+
destination_stations = {} # {direction: destination_name}
|
| 255 |
+
if stops_of_route:
|
| 256 |
+
for route_dir in stops_of_route:
|
| 257 |
+
direction = route_dir.get("Direction", 0)
|
| 258 |
+
stops = route_dir.get("Stops", [])
|
| 259 |
+
|
| 260 |
+
# 提取終點站(Stops 陣列的最後一個站點)
|
| 261 |
+
if stops:
|
| 262 |
+
last_stop = stops[-1]
|
| 263 |
+
dest_name = last_stop.get("StopName", {}).get("Zh_tw", "")
|
| 264 |
+
if dest_name:
|
| 265 |
+
destination_stations[direction] = dest_name
|
| 266 |
+
|
| 267 |
+
# 建立座標映射
|
| 268 |
+
for stop in stops:
|
| 269 |
+
stop_uid = stop.get("StopUID")
|
| 270 |
+
pos = stop.get("StopPosition", {})
|
| 271 |
+
if stop_uid and pos.get("PositionLat") and pos.get("PositionLon"):
|
| 272 |
+
stop_positions[stop_uid] = (pos["PositionLat"], pos["PositionLon"])
|
| 273 |
+
|
| 274 |
+
print(f"📍 [TDX] 從 StopOfRoute 取得 {len(stop_positions)} 個站點座標")
|
| 275 |
+
print(f"🎯 [TDX] 終點站資訊: {destination_stations}")
|
| 276 |
+
|
| 277 |
+
# 為每筆到站資料計算「用戶位置」到「站牌」的距離
|
| 278 |
+
for arr in arrival_data:
|
| 279 |
+
stop_uid = arr.get("StopUID")
|
| 280 |
+
if stop_uid and stop_uid in stop_positions:
|
| 281 |
+
stop_lat, stop_lon = stop_positions[stop_uid]
|
| 282 |
+
arr["distance_m"] = TDXBaseAPI.haversine_distance(
|
| 283 |
+
user_lat, user_lon, stop_lat, stop_lon
|
| 284 |
)
|
| 285 |
+
arr["stop_lat"] = stop_lat
|
| 286 |
+
arr["stop_lon"] = stop_lon
|
| 287 |
+
|
| 288 |
+
# 按距離排序(找出離用戶最近的站牌)
|
| 289 |
+
arrival_data_with_dist = [a for a in arrival_data if a.get("distance_m") is not None]
|
| 290 |
+
if arrival_data_with_dist:
|
| 291 |
+
arrival_data = sorted(arrival_data_with_dist, key=lambda x: x["distance_m"])
|
| 292 |
+
nearest = arrival_data[0]
|
| 293 |
+
print(f"📍 [TDX] 按距離排序完成,最近站: {nearest.get('StopName', {}).get('Zh_tw')} ({int(nearest['distance_m'])}m)")
|
| 294 |
+
else:
|
| 295 |
+
print(f"⚠️ [TDX] 無法計算距離,stop_positions={len(stop_positions)}, arrival_data={len(arrival_data)}")
|
| 296 |
+
|
| 297 |
+
except Exception as e:
|
| 298 |
+
print(f"⚠️ [TDX] 查詢站點座標失敗: {e}")
|
| 299 |
+
import traceback
|
| 300 |
+
traceback.print_exc()
|
| 301 |
|
| 302 |
+
# 5. 處理到站資訊(只顯示最近的站牌,分去程/返程)
|
| 303 |
arrivals = []
|
| 304 |
+
seen_directions = set()
|
| 305 |
+
|
| 306 |
+
for arr in arrival_data:
|
| 307 |
+
direction = arr.get("Direction", 0)
|
| 308 |
|
| 309 |
+
# 每個方向只取最近的一個站
|
| 310 |
+
if direction in seen_directions:
|
| 311 |
+
continue
|
| 312 |
+
seen_directions.add(direction)
|
|
|
|
| 313 |
|
| 314 |
+
stop_name = arr.get("StopName", {}).get("Zh_tw", "未知")
|
| 315 |
+
estimate_time = arr.get("EstimateTime")
|
| 316 |
+
stop_status = arr.get("StopStatus", 0)
|
| 317 |
+
next_bus_time = arr.get("NextBusTime")
|
| 318 |
+
user_stop_sequence = arr.get("StopSequence", 0)
|
| 319 |
+
|
| 320 |
+
# 取得該方向的公車位置
|
| 321 |
+
buses = bus_positions.get(direction, [])
|
| 322 |
+
bus_info = buses[0] if buses else None
|
| 323 |
+
|
| 324 |
+
# 判斷公車是否已過站
|
| 325 |
+
bus_passed = False
|
| 326 |
+
if bus_info and bus_info.get("stop_sequence"):
|
| 327 |
+
bus_sequence = bus_info["stop_sequence"]
|
| 328 |
+
# 如果公車已離站且站序 > 用戶站序,表示已過站
|
| 329 |
+
if bus_info["event"] == "已離站" and bus_sequence > user_stop_sequence:
|
| 330 |
+
bus_passed = True
|
| 331 |
+
print(f"🚫 [TDX] 公車已過站: 公車在第 {bus_sequence} 站 > 用戶在第 {user_stop_sequence} 站")
|
| 332 |
+
|
| 333 |
+
status_text = cls._get_status_text(stop_status, estimate_time, next_bus_time)
|
| 334 |
+
|
| 335 |
+
# 如果公車已過站,標註或修改狀態
|
| 336 |
+
if bus_passed:
|
| 337 |
+
status_text = "已過站(等下一班)"
|
| 338 |
+
# 清除公車位置資訊,因為這班已過站
|
| 339 |
+
bus_info = None
|
| 340 |
|
| 341 |
+
arrivals.append({
|
| 342 |
+
"route_name": full_route_name,
|
| 343 |
+
"stop_name": stop_name,
|
| 344 |
+
"direction": direction,
|
| 345 |
+
"destination_station": destination_stations.get(direction, ""), # 終點站
|
| 346 |
+
"estimate_time": estimate_time,
|
| 347 |
+
"next_bus_time": next_bus_time,
|
| 348 |
+
"status": status_text,
|
| 349 |
+
"distance_m": int(arr.get("distance_m", 0)),
|
| 350 |
+
"stop_sequence": arr.get("StopSequence", 0),
|
| 351 |
+
"bus_current_stop": bus_info["current_stop"] if bus_info else None,
|
| 352 |
+
"bus_event": bus_info["event"] if bus_info else None,
|
| 353 |
+
"bus_plate": bus_info["plate"] if bus_info else None
|
| 354 |
+
})
|
| 355 |
+
|
| 356 |
+
if len(arrivals) >= limit:
|
| 357 |
+
break
|
| 358 |
+
|
| 359 |
+
print(f"📊 [TDX] 最終結果: {len(arrivals)} 筆到站資訊")
|
| 360 |
+
for arr in arrivals:
|
| 361 |
+
print(f" - {arr['stop_name']} ({arr['status']})")
|
| 362 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
content = cls._format_arrival_result(arrivals, full_route_name, user_lat is not None)
|
| 364 |
+
print(f"📝 [TDX] 格式化內容:\n{content}")
|
| 365 |
|
| 366 |
return cls.create_success_response(
|
| 367 |
content=content,
|
| 368 |
+
data={"arrivals": arrivals, "route_name": full_route_name, "bus_positions": bus_positions}
|
| 369 |
)
|
| 370 |
|
| 371 |
@classmethod
|
| 372 |
+
async def _query_nearby_stops(
|
| 373 |
+
cls,
|
| 374 |
+
lat: float,
|
| 375 |
+
lon: float,
|
| 376 |
+
city: str,
|
| 377 |
+
limit: int
|
| 378 |
+
) -> Dict[str, Any]:
|
| 379 |
+
"""
|
| 380 |
+
查詢附近公車站
|
| 381 |
+
|
| 382 |
+
API: GET /v2/Bus/Stop/City/{City}?$spatialFilter=nearby(lat, lon, distance)
|
| 383 |
+
"""
|
| 384 |
endpoint = f"Bus/Stop/City/{city}"
|
| 385 |
params = {
|
| 386 |
+
"$spatialFilter": f"nearby({lat}, {lon}, 500)",
|
| 387 |
+
"$top": limit * 3,
|
| 388 |
+
"$format": "JSON"
|
| 389 |
}
|
| 390 |
|
| 391 |
stops = await TDXBaseAPI.call_api(endpoint, params, cache_ttl=1800)
|
| 392 |
|
| 393 |
if not stops:
|
| 394 |
return cls.create_success_response(
|
| 395 |
+
content="附近 500 公尺內沒有公車站,請擴大範圍或移動位置",
|
| 396 |
data={"stops": []}
|
| 397 |
)
|
| 398 |
|
|
|
|
| 405 |
pos["PositionLat"], pos["PositionLon"]
|
| 406 |
)
|
| 407 |
|
| 408 |
+
stops = [s for s in stops if s.get("distance_m") is not None]
|
| 409 |
stops.sort(key=lambda x: x["distance_m"])
|
|
|
|
| 410 |
|
| 411 |
+
# 去重複站名
|
| 412 |
results = []
|
| 413 |
+
seen_names = set()
|
| 414 |
+
|
| 415 |
for stop in stops:
|
| 416 |
+
name = stop.get("StopName", {}).get("Zh_tw") or stop.get("StopName", {}).get("En") or "未知"
|
| 417 |
+
if name in seen_names:
|
| 418 |
+
continue
|
| 419 |
+
seen_names.add(name)
|
| 420 |
|
| 421 |
+
distance = stop["distance_m"]
|
| 422 |
results.append({
|
| 423 |
+
"stop_name": name,
|
| 424 |
"distance_m": int(distance),
|
| 425 |
+
"walking_time_min": int(distance / 80),
|
| 426 |
"stop_uid": stop.get("StopUID")
|
| 427 |
})
|
| 428 |
+
|
| 429 |
+
if len(results) >= limit:
|
| 430 |
+
break
|
| 431 |
|
| 432 |
content = cls._format_nearby_result(results)
|
| 433 |
|
|
|
|
| 436 |
data={"stops": results}
|
| 437 |
)
|
| 438 |
|
| 439 |
+
@classmethod
|
| 440 |
+
async def _reverse_geocode_city(cls, lat: float, lon: float) -> Optional[str]:
|
| 441 |
+
"""使用 Nominatim 反向地理編碼取得精確城市名稱"""
|
| 442 |
+
import aiohttp
|
| 443 |
+
|
| 444 |
+
url = "https://nominatim.openstreetmap.org/reverse"
|
| 445 |
+
params = {
|
| 446 |
+
"format": "jsonv2",
|
| 447 |
+
"lat": lat,
|
| 448 |
+
"lon": lon,
|
| 449 |
+
"zoom": 10, # 城市級別
|
| 450 |
+
"addressdetails": 1
|
| 451 |
+
}
|
| 452 |
+
headers = {"User-Agent": "BloomWare/1.0"}
|
| 453 |
+
|
| 454 |
+
try:
|
| 455 |
+
async with aiohttp.ClientSession() as session:
|
| 456 |
+
async with session.get(url, params=params, headers=headers, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
| 457 |
+
if resp.status != 200:
|
| 458 |
+
logger.warning(f"反向地理編碼失敗: HTTP {resp.status}")
|
| 459 |
+
return None
|
| 460 |
+
|
| 461 |
+
data = await resp.json()
|
| 462 |
+
if not data or not isinstance(data, dict):
|
| 463 |
+
return None
|
| 464 |
+
|
| 465 |
+
addr = data.get("address", {})
|
| 466 |
+
# 優先使用 city,其次 county
|
| 467 |
+
city = addr.get("city") or addr.get("county") or addr.get("town") or ""
|
| 468 |
+
|
| 469 |
+
# 移除「市」「縣」後綴以便匹配
|
| 470 |
+
city = city.replace("市", "").replace("縣", "").strip()
|
| 471 |
+
|
| 472 |
+
logger.debug(f"Nominatim 回應城市: {city}")
|
| 473 |
+
return city if city else None
|
| 474 |
+
|
| 475 |
+
except Exception as e:
|
| 476 |
+
logger.warning(f"反向地理編碼異常: {e}")
|
| 477 |
+
return None
|
| 478 |
+
|
| 479 |
+
@classmethod
|
| 480 |
+
def _guess_city_from_location(cls, lat: float, lon: float) -> str:
|
| 481 |
+
"""根據經緯度推斷城市(台灣主要城市範圍)- 備用方案
|
| 482 |
+
|
| 483 |
+
注意:順序很重要!較小範圍的城市要放在前面,避免被大範圍城市覆蓋
|
| 484 |
+
"""
|
| 485 |
+
# 台灣主要城市的大致經緯度範圍
|
| 486 |
+
# 順序:小範圍城市優先,大範圍城市(新北)放最後
|
| 487 |
+
city_bounds = [
|
| 488 |
+
# (城市名, 緯度下限, 緯度上限, 經度下限, 經度上限)
|
| 489 |
+
# 桃園市(擴大範圍到經度 121.40,涵蓋桃園全境)
|
| 490 |
+
("桃園", 24.73, 25.12, 120.90, 121.40),
|
| 491 |
+
# 台北市(市中心區域)
|
| 492 |
+
("台北", 24.95, 25.10, 121.45, 121.62),
|
| 493 |
+
# 基隆市
|
| 494 |
+
("基隆", 25.08, 25.20, 121.69, 121.82),
|
| 495 |
+
# 新北市(範圍較大,放在最後)
|
| 496 |
+
("新北", 24.67, 25.30, 121.35, 122.01),
|
| 497 |
+
# 新竹市/縣
|
| 498 |
+
("新竹", 24.68, 24.90, 120.90, 121.10),
|
| 499 |
+
# 苗栗縣
|
| 500 |
+
("苗栗", 24.30, 24.75, 120.65, 121.20),
|
| 501 |
+
# 台中市
|
| 502 |
+
("台中", 24.00, 24.45, 120.45, 121.05),
|
| 503 |
+
# 彰化縣
|
| 504 |
+
("彰化", 23.80, 24.20, 120.25, 120.70),
|
| 505 |
+
# 南投縣
|
| 506 |
+
("南投", 23.45, 24.25, 120.55, 121.35),
|
| 507 |
+
# 雲林縣
|
| 508 |
+
("雲林", 23.50, 23.90, 120.05, 120.60),
|
| 509 |
+
# 嘉義市/縣
|
| 510 |
+
("嘉義", 23.25, 23.65, 120.15, 120.70),
|
| 511 |
+
("台南", 22.85, 23.40, 120.00, 120.55),
|
| 512 |
+
("高雄", 22.45, 23.15, 120.15, 120.80),
|
| 513 |
+
("屏東", 21.90, 22.90, 120.35, 120.95),
|
| 514 |
+
("宜蘭", 24.30, 24.85, 121.55, 122.00),
|
| 515 |
+
("花蓮", 23.50, 24.35, 121.20, 121.70),
|
| 516 |
+
("台東", 22.35, 23.55, 120.75, 121.55),
|
| 517 |
+
]
|
| 518 |
+
|
| 519 |
+
for city_name, lat_min, lat_max, lon_min, lon_max in city_bounds:
|
| 520 |
+
in_lat = lat_min <= lat <= lat_max
|
| 521 |
+
in_lon = lon_min <= lon <= lon_max
|
| 522 |
+
if in_lat and in_lon:
|
| 523 |
+
logger.info(f"🗺️ 座標 ({lat}, {lon}) 匹配城市: {city_name}")
|
| 524 |
+
return city_name
|
| 525 |
+
|
| 526 |
+
# 無法匹配,記錄詳細資訊
|
| 527 |
+
logger.warning(f"⚠️ 座標 ({lat}, {lon}) 無法匹配任何城市範圍")
|
| 528 |
+
return ""
|
| 529 |
+
|
| 530 |
+
@classmethod
|
| 531 |
+
def _resolve_city(cls, city_param: str) -> str:
|
| 532 |
+
"""解析城市參數為 TDX 城市代碼"""
|
| 533 |
+
if not city_param:
|
| 534 |
+
logger.warning("⚠️ 無法判斷城市,使用預設值 Taipei")
|
| 535 |
+
return "Taipei"
|
| 536 |
+
|
| 537 |
+
# 已經是有效代碼
|
| 538 |
+
if city_param in cls.VALID_CITIES:
|
| 539 |
+
return city_param
|
| 540 |
+
|
| 541 |
+
# 中文轉換
|
| 542 |
city_map = {
|
| 543 |
+
"台北": "Taipei", "臺北": "Taipei", "台北市": "Taipei", "臺北市": "Taipei",
|
| 544 |
"新北": "NewTaipei", "新北市": "NewTaipei",
|
| 545 |
+
"桃園": "Taoyuan", "桃園市": "Taoyuan",
|
| 546 |
+
"台中": "Taichung", "臺中": "Taichung", "台中市": "Taichung", "臺中市": "Taichung",
|
| 547 |
+
"台南": "Tainan", "臺南": "Tainan", "台南市": "Tainan", "臺南市": "Tainan",
|
| 548 |
+
"高雄": "Kaohsiung", "高雄市": "Kaohsiung",
|
| 549 |
+
"基隆": "Keelung", "基隆市": "Keelung",
|
| 550 |
+
"新竹": "Hsinchu", "新竹市": "Hsinchu", "新竹縣": "HsinchuCounty",
|
| 551 |
+
"嘉義": "Chiayi", "嘉義市": "Chiayi", "嘉義縣": "ChiayiCounty",
|
| 552 |
+
"苗栗": "MiaoliCounty", "苗栗縣": "MiaoliCounty",
|
| 553 |
+
"彰化": "ChanghuaCounty", "彰化縣": "ChanghuaCounty",
|
| 554 |
+
"南投": "NantouCounty", "南投縣": "NantouCounty",
|
| 555 |
+
"雲林": "YunlinCounty", "雲林縣": "YunlinCounty",
|
| 556 |
+
"屏東": "PingtungCounty", "屏東縣": "PingtungCounty",
|
| 557 |
+
"宜蘭": "YilanCounty", "宜蘭縣": "YilanCounty",
|
| 558 |
+
"花蓮": "HualienCounty", "花蓮縣": "HualienCounty",
|
| 559 |
+
"台東": "TaitungCounty", "臺東": "TaitungCounty",
|
| 560 |
+
"台東縣": "TaitungCounty", "臺東縣": "TaitungCounty",
|
| 561 |
+
"金門": "KinmenCounty", "金門縣": "KinmenCounty",
|
| 562 |
+
"澎湖": "PenghuCounty", "澎湖縣": "PenghuCounty",
|
| 563 |
+
"連江": "LienchiangCounty", "連江縣": "LienchiangCounty", "馬祖": "LienchiangCounty"
|
| 564 |
}
|
| 565 |
|
| 566 |
+
# 精確匹配
|
| 567 |
+
if city_param in city_map:
|
| 568 |
+
return city_map[city_param]
|
| 569 |
+
|
| 570 |
+
# 部分匹配
|
| 571 |
+
for key, value in sorted(city_map.items(), key=lambda x: -len(x[0])):
|
| 572 |
+
if key in city_param:
|
| 573 |
return value
|
| 574 |
|
| 575 |
+
logger.warning(f"無法識別城市: {city_param},使用預設值 Taipei")
|
| 576 |
+
return "Taipei"
|
| 577 |
+
|
| 578 |
+
@staticmethod
|
| 579 |
+
def _get_status_text(stop_status: int, estimate_time: Optional[int], next_bus_time: Optional[str] = None) -> str:
|
| 580 |
+
"""根據狀態碼、預估時間和下一班發車時間產生狀態文字"""
|
| 581 |
+
from datetime import datetime
|
| 582 |
+
|
| 583 |
+
if stop_status == 0: # 正常
|
| 584 |
+
if estimate_time is not None:
|
| 585 |
+
minutes = estimate_time // 60
|
| 586 |
+
if minutes <= 1:
|
| 587 |
+
return "即將進站"
|
| 588 |
+
return f"{minutes} 分鐘"
|
| 589 |
+
return "進站中"
|
| 590 |
+
elif stop_status == 1: # 尚未發車
|
| 591 |
+
# 如果有下一班發車時間,顯示預計發車時間
|
| 592 |
+
if next_bus_time:
|
| 593 |
+
try:
|
| 594 |
+
# 解析 ISO 格式時間: 2025-11-28T15:23:00+08:00
|
| 595 |
+
next_time = datetime.fromisoformat(next_bus_time.replace('Z', '+00:00'))
|
| 596 |
+
time_str = next_time.strftime("%H:%M")
|
| 597 |
+
return f"預計 {time_str} 發車"
|
| 598 |
+
except Exception:
|
| 599 |
+
pass
|
| 600 |
+
return "尚未發車"
|
| 601 |
+
elif stop_status == 2:
|
| 602 |
+
return "交管不停靠"
|
| 603 |
+
elif stop_status == 3:
|
| 604 |
+
return "末班車已過"
|
| 605 |
+
elif stop_status == 4:
|
| 606 |
+
return "今日未營運"
|
| 607 |
+
return "未知"
|
| 608 |
|
| 609 |
@staticmethod
|
| 610 |
def _format_arrival_result(arrivals: List[Dict], route_name: str, has_location: bool) -> str:
|
| 611 |
+
"""格式化到站結果(含公車目前位置和終點站)"""
|
| 612 |
if not arrivals:
|
| 613 |
return f"路線 {route_name} 目前無即時到站資訊"
|
| 614 |
+
|
| 615 |
+
lines = [f"🚌 {route_name} 即時資訊:\n"]
|
| 616 |
+
|
|
|
|
|
|
|
| 617 |
for arr in arrivals:
|
| 618 |
+
direction_text = "去程" if arr["direction"] == 0 else "返程"
|
| 619 |
+
|
| 620 |
+
# 終點站資訊
|
| 621 |
+
destination = arr.get("destination_station", "")
|
| 622 |
+
if destination:
|
| 623 |
+
direction_label = f"【{direction_text} → {destination}】"
|
| 624 |
+
else:
|
| 625 |
+
direction_label = f"【{direction_text}】"
|
| 626 |
+
|
| 627 |
+
# 最近站牌資訊
|
| 628 |
dist_info = ""
|
| 629 |
+
if has_location and arr.get("distance_m"):
|
| 630 |
+
dist = arr["distance_m"]
|
| 631 |
+
walk_time = max(1, int(dist / 80))
|
| 632 |
+
dist_info = f"(步行 {walk_time} 分鐘)"
|
| 633 |
+
|
| 634 |
+
lines.append(direction_label)
|
| 635 |
+
lines.append(f"📍 最近站牌: {arr['stop_name']} {dist_info}")
|
| 636 |
+
|
| 637 |
+
# 公車目前位置
|
| 638 |
+
if arr.get("bus_current_stop"):
|
| 639 |
+
lines.append(f"🚌 公車位置: {arr['bus_current_stop']}({arr.get('bus_event', '')})")
|
| 640 |
+
|
| 641 |
+
# 預估到站時間
|
| 642 |
+
lines.append(f"⏱️ 預估到站: {arr['status']}")
|
| 643 |
lines.append("")
|
| 644 |
+
|
| 645 |
+
return "\n".join(lines).strip()
|
| 646 |
|
| 647 |
@staticmethod
|
| 648 |
def _format_nearby_result(stops: List[Dict]) -> str:
|
|
|
|
| 655 |
for i, stop in enumerate(stops, 1):
|
| 656 |
lines.append(
|
| 657 |
f"{i}. 🚏 {stop['stop_name']}\n"
|
| 658 |
+
f" 步行 {stop['walking_time_min']} 分鐘 ({stop['distance_m']}m)"
|
| 659 |
)
|
| 660 |
|
| 661 |
+
lines.append("\n💡 提供路線名稱可查詢即時到站時間")
|
| 662 |
|
| 663 |
return "\n".join(lines)
|
features/mcp/tools/tdx_metro.py
CHANGED
|
@@ -52,6 +52,14 @@ class TDXMetroTool(MCPTool):
|
|
| 52 |
"line": {
|
| 53 |
"type": "string",
|
| 54 |
"description": "路線名稱(如「板南線」「淡水信義線」)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
}, required=[])
|
| 57 |
|
|
@@ -76,26 +84,71 @@ class TDXMetroTool(MCPTool):
|
|
| 76 |
return schema
|
| 77 |
|
| 78 |
@classmethod
|
| 79 |
-
async def execute(cls, arguments: Dict[str, Any]
|
|
|
|
|
|
|
|
|
|
| 80 |
station_name = arguments.get("station_name", "").strip()
|
| 81 |
metro_system = arguments.get("metro_system")
|
| 82 |
line_filter = arguments.get("line")
|
| 83 |
|
| 84 |
-
# 1.
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
else:
|
| 91 |
-
ctx = env_ctx.get("context", {})
|
| 92 |
-
user_lat = ctx.get("lat")
|
| 93 |
-
user_lon = ctx.get("lon")
|
| 94 |
-
user_city = ctx.get("city", "")
|
| 95 |
|
| 96 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
if not metro_system:
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
# 3. 查詢邏輯
|
| 101 |
if station_name:
|
|
@@ -111,8 +164,9 @@ class TDXMetroTool(MCPTool):
|
|
| 111 |
async def _query_station_arrival(cls, station_name: str, metro_system: str,
|
| 112 |
line_filter: Optional[str]) -> Dict[str, Any]:
|
| 113 |
"""查詢特定車站的即時到���"""
|
| 114 |
-
# 1. 查詢車站資訊
|
| 115 |
-
|
|
|
|
| 116 |
station_params = {
|
| 117 |
"$filter": f"contains(StationName/Zh_tw, '{station_name}')",
|
| 118 |
"$format": "JSON",
|
|
@@ -138,8 +192,9 @@ class TDXMetroTool(MCPTool):
|
|
| 138 |
station_uid = target_station.get("StationUID")
|
| 139 |
full_station_name = target_station.get("StationName", {}).get("Zh_tw", station_name)
|
| 140 |
|
| 141 |
-
# 3. 查詢即時到站
|
| 142 |
-
|
|
|
|
| 143 |
arrival_params = {
|
| 144 |
"$filter": f"StationUID eq '{station_uid}'",
|
| 145 |
"$format": "JSON"
|
|
@@ -192,8 +247,9 @@ class TDXMetroTool(MCPTool):
|
|
| 192 |
@classmethod
|
| 193 |
async def _query_nearest_station(cls, lat: float, lon: float, metro_system: str) -> Dict[str, Any]:
|
| 194 |
"""查詢最近的捷運站"""
|
| 195 |
-
# 1. 取得所有車站
|
| 196 |
-
|
|
|
|
| 197 |
station_params = {
|
| 198 |
"$format": "JSON"
|
| 199 |
}
|
|
@@ -242,9 +298,50 @@ class TDXMetroTool(MCPTool):
|
|
| 242 |
data={"stations": results}
|
| 243 |
)
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
@staticmethod
|
| 246 |
def _detect_metro_system(city: str) -> str:
|
| 247 |
"""根據城市自動偵測捷運系統"""
|
|
|
|
|
|
|
|
|
|
| 248 |
for key, code in TDXMetroTool.METRO_SYSTEMS.items():
|
| 249 |
if key in city:
|
| 250 |
return code
|
|
|
|
| 52 |
"line": {
|
| 53 |
"type": "string",
|
| 54 |
"description": "路線名稱(如「板南線」「淡水信義線」)"
|
| 55 |
+
},
|
| 56 |
+
"lat": {
|
| 57 |
+
"type": "number",
|
| 58 |
+
"description": "用戶緯度(由系統自動注入)"
|
| 59 |
+
},
|
| 60 |
+
"lon": {
|
| 61 |
+
"type": "number",
|
| 62 |
+
"description": "用戶經度(由系統自動注入)"
|
| 63 |
}
|
| 64 |
}, required=[])
|
| 65 |
|
|
|
|
| 84 |
return schema
|
| 85 |
|
| 86 |
@classmethod
|
| 87 |
+
async def execute(cls, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 88 |
+
# 從 arguments 中讀取 user_id(由 coordinator 注入)
|
| 89 |
+
user_id = arguments.get("_user_id")
|
| 90 |
+
|
| 91 |
station_name = arguments.get("station_name", "").strip()
|
| 92 |
metro_system = arguments.get("metro_system")
|
| 93 |
line_filter = arguments.get("line")
|
| 94 |
|
| 95 |
+
# 1. 取得用戶位置和城市(優先從 arguments 讀取,由 coordinator 注入)
|
| 96 |
+
user_lat = arguments.get("lat")
|
| 97 |
+
user_lon = arguments.get("lon")
|
| 98 |
+
user_city = arguments.get("city", "")
|
| 99 |
+
|
| 100 |
+
logger.info(f"🚇 [Metro] 輸入參數: lat={user_lat}, lon={user_lon}, city={user_city}, station={station_name}, user_id={user_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
+
# 從資料庫補充缺失的位置資訊(僅當 coordinator 沒有注入時)
|
| 103 |
+
if user_id and (user_lat is None or user_lon is None):
|
| 104 |
+
try:
|
| 105 |
+
env_ctx = await get_user_env_current(user_id)
|
| 106 |
+
logger.info(f"📍 [Metro] 資料庫查詢結果: {env_ctx}")
|
| 107 |
+
if env_ctx and env_ctx.get("success"):
|
| 108 |
+
ctx = env_ctx.get("context", {})
|
| 109 |
+
if user_lat is None:
|
| 110 |
+
user_lat = ctx.get("lat")
|
| 111 |
+
if user_lon is None:
|
| 112 |
+
user_lon = ctx.get("lon")
|
| 113 |
+
if not user_city:
|
| 114 |
+
user_city = ctx.get("city", "")
|
| 115 |
+
logger.info(f"📍 [Metro] 補充後: lat={user_lat}, lon={user_lon}, city={user_city}")
|
| 116 |
+
else:
|
| 117 |
+
logger.warning(f"⚠️ [Metro] 資料庫查詢失敗或無資料: {env_ctx}")
|
| 118 |
+
except Exception as e:
|
| 119 |
+
logger.warning(f"⚠️ [Metro] 資料庫查詢異常: {e}")
|
| 120 |
+
|
| 121 |
+
# 檢查必要條件
|
| 122 |
+
if not station_name and (user_lat is None or user_lon is None):
|
| 123 |
+
logger.error(f"🚇 [Metro] 位置資訊缺失: lat={user_lat}, lon={user_lon}, station_name={station_name}")
|
| 124 |
+
raise ExecutionError("🚇 想幫您找附近的捷運站,但目前沒有您的位置資訊。請在 App 中開啟定位,或告訴我您想查詢哪個車站")
|
| 125 |
+
|
| 126 |
+
# 2. 自動判斷捷運系統(優先使用反向地理編碼)
|
| 127 |
if not metro_system:
|
| 128 |
+
final_city = None
|
| 129 |
+
city_source = "預設"
|
| 130 |
+
|
| 131 |
+
# 優先:即時反向地理編碼
|
| 132 |
+
if user_lat and user_lon:
|
| 133 |
+
geocoded = await cls._reverse_geocode_city(user_lat, user_lon)
|
| 134 |
+
if geocoded:
|
| 135 |
+
final_city = geocoded
|
| 136 |
+
city_source = "反向地理編碼"
|
| 137 |
+
|
| 138 |
+
# 其次:環境參數
|
| 139 |
+
if not final_city and user_city:
|
| 140 |
+
final_city = user_city
|
| 141 |
+
city_source = "環境參數"
|
| 142 |
+
|
| 143 |
+
# 最後:經緯度範圍推斷
|
| 144 |
+
if not final_city and user_lat and user_lon:
|
| 145 |
+
guessed = cls._guess_city_from_location(user_lat, user_lon)
|
| 146 |
+
if guessed:
|
| 147 |
+
final_city = guessed
|
| 148 |
+
city_source = "經緯度推斷"
|
| 149 |
+
|
| 150 |
+
metro_system = cls._detect_metro_system(final_city) if final_city else "TRTC"
|
| 151 |
+
logger.info(f"🚇 最終使用捷運系統: {metro_system} (來源={city_source})")
|
| 152 |
|
| 153 |
# 3. 查詢邏輯
|
| 154 |
if station_name:
|
|
|
|
| 164 |
async def _query_station_arrival(cls, station_name: str, metro_system: str,
|
| 165 |
line_filter: Optional[str]) -> Dict[str, Any]:
|
| 166 |
"""查詢特定車站的即時到���"""
|
| 167 |
+
# 1. 查詢車站資訊 (v2 API)
|
| 168 |
+
# GET /v2/Rail/Metro/Station/{Operator}
|
| 169 |
+
station_endpoint = f"Rail/Metro/Station/{metro_system}"
|
| 170 |
station_params = {
|
| 171 |
"$filter": f"contains(StationName/Zh_tw, '{station_name}')",
|
| 172 |
"$format": "JSON",
|
|
|
|
| 192 |
station_uid = target_station.get("StationUID")
|
| 193 |
full_station_name = target_station.get("StationName", {}).get("Zh_tw", station_name)
|
| 194 |
|
| 195 |
+
# 3. 查詢即時到站 (v2 API)
|
| 196 |
+
# GET /v2/Rail/Metro/LiveBoard/{Operator}
|
| 197 |
+
arrival_endpoint = f"Rail/Metro/LiveBoard/{metro_system}"
|
| 198 |
arrival_params = {
|
| 199 |
"$filter": f"StationUID eq '{station_uid}'",
|
| 200 |
"$format": "JSON"
|
|
|
|
| 247 |
@classmethod
|
| 248 |
async def _query_nearest_station(cls, lat: float, lon: float, metro_system: str) -> Dict[str, Any]:
|
| 249 |
"""查詢最近的捷運站"""
|
| 250 |
+
# 1. 取得所有車站 (v2 API)
|
| 251 |
+
# GET /v2/Rail/Metro/Station/{Operator}
|
| 252 |
+
station_endpoint = f"Rail/Metro/Station/{metro_system}"
|
| 253 |
station_params = {
|
| 254 |
"$format": "JSON"
|
| 255 |
}
|
|
|
|
| 298 |
data={"stations": results}
|
| 299 |
)
|
| 300 |
|
| 301 |
+
@staticmethod
|
| 302 |
+
async def _reverse_geocode_city(lat: float, lon: float) -> Optional[str]:
|
| 303 |
+
"""使用 Nominatim 反向地理編碼取得精確城市"""
|
| 304 |
+
import aiohttp
|
| 305 |
+
try:
|
| 306 |
+
async with aiohttp.ClientSession() as session:
|
| 307 |
+
async with session.get(
|
| 308 |
+
"https://nominatim.openstreetmap.org/reverse",
|
| 309 |
+
params={"format": "jsonv2", "lat": lat, "lon": lon, "zoom": 10, "addressdetails": 1},
|
| 310 |
+
headers={"User-Agent": "BloomWare/1.0"},
|
| 311 |
+
timeout=aiohttp.ClientTimeout(total=5)
|
| 312 |
+
) as resp:
|
| 313 |
+
if resp.status != 200:
|
| 314 |
+
return None
|
| 315 |
+
data = await resp.json()
|
| 316 |
+
addr = data.get("address", {}) if data else {}
|
| 317 |
+
city = addr.get("city") or addr.get("county") or addr.get("town") or ""
|
| 318 |
+
return city.replace("市", "").replace("縣", "").strip() or None
|
| 319 |
+
except Exception:
|
| 320 |
+
return None
|
| 321 |
+
|
| 322 |
+
@staticmethod
|
| 323 |
+
def _guess_city_from_location(lat: float, lon: float) -> str:
|
| 324 |
+
"""根據經緯度推斷城市(備用方案)"""
|
| 325 |
+
city_bounds = [
|
| 326 |
+
("桃園", 24.73, 25.12, 120.90, 121.40),
|
| 327 |
+
("台北", 24.95, 25.10, 121.45, 121.62),
|
| 328 |
+
("新北", 24.67, 25.30, 121.35, 122.01),
|
| 329 |
+
("台中", 24.00, 24.45, 120.45, 121.05),
|
| 330 |
+
("高雄", 22.45, 23.15, 120.15, 120.80),
|
| 331 |
+
]
|
| 332 |
+
|
| 333 |
+
for city_name, lat_min, lat_max, lon_min, lon_max in city_bounds:
|
| 334 |
+
if lat_min <= lat <= lat_max and lon_min <= lon <= lon_max:
|
| 335 |
+
return city_name
|
| 336 |
+
|
| 337 |
+
return ""
|
| 338 |
+
|
| 339 |
@staticmethod
|
| 340 |
def _detect_metro_system(city: str) -> str:
|
| 341 |
"""根據城市自動偵測捷運系統"""
|
| 342 |
+
if not city:
|
| 343 |
+
return "TRTC"
|
| 344 |
+
|
| 345 |
for key, code in TDXMetroTool.METRO_SYSTEMS.items():
|
| 346 |
if key in city:
|
| 347 |
return code
|
features/mcp/tools/tdx_parking.py
CHANGED
|
@@ -58,6 +58,14 @@ class TDXParkingTool(MCPTool):
|
|
| 58 |
"type": "integer",
|
| 59 |
"description": "返回結果數量",
|
| 60 |
"default": 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
}
|
| 62 |
}, required=[])
|
| 63 |
|
|
@@ -82,7 +90,10 @@ class TDXParkingTool(MCPTool):
|
|
| 82 |
return schema
|
| 83 |
|
| 84 |
@classmethod
|
| 85 |
-
async def execute(cls, arguments: Dict[str, Any]
|
|
|
|
|
|
|
|
|
|
| 86 |
parking_name = arguments.get("parking_name", "").strip()
|
| 87 |
city = arguments.get("city")
|
| 88 |
parking_type = arguments.get("parking_type")
|
|
@@ -90,21 +101,63 @@ class TDXParkingTool(MCPTool):
|
|
| 90 |
radius_m = min(int(arguments.get("radius_m", 1000)), 5000)
|
| 91 |
limit = min(int(arguments.get("limit", 5)), 20)
|
| 92 |
|
| 93 |
-
# 1.
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
if not city:
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
# 3. 查詢分支
|
| 110 |
if charge_station_only:
|
|
@@ -126,7 +179,8 @@ class TDXParkingTool(MCPTool):
|
|
| 126 |
@classmethod
|
| 127 |
async def _query_parking_availability(cls, parking_name: str, city: str) -> Dict[str, Any]:
|
| 128 |
"""查詢特定停車場即時資訊"""
|
| 129 |
-
# 1. 查詢停車場基本資訊
|
|
|
|
| 130 |
parking_endpoint = f"Parking/OffStreet/CarPark/City/{city}"
|
| 131 |
parking_params = {
|
| 132 |
"$filter": f"contains(CarParkName/Zh_tw, '{parking_name}')",
|
|
@@ -144,7 +198,8 @@ class TDXParkingTool(MCPTool):
|
|
| 144 |
parking_id = parking.get("CarParkID")
|
| 145 |
full_parking_name = parking.get("CarParkName", {}).get("Zh_tw", parking_name)
|
| 146 |
|
| 147 |
-
# 3. 查詢即時剩餘車位
|
|
|
|
| 148 |
avail_endpoint = f"Parking/OffStreet/ParkingAvailability/City/{city}"
|
| 149 |
avail_params = {
|
| 150 |
"$filter": f"CarParkID eq '{parking_id}'",
|
|
@@ -195,7 +250,9 @@ class TDXParkingTool(MCPTool):
|
|
| 195 |
async def _query_nearby_parkings(cls, lat: float, lon: float, city: str,
|
| 196 |
parking_type: Optional[str], radius_m: int, limit: int) -> Dict[str, Any]:
|
| 197 |
"""查詢附近停車場"""
|
| 198 |
-
# 1. 查詢附近停車場
|
|
|
|
|
|
|
| 199 |
if parking_type == "路邊":
|
| 200 |
parking_endpoint = f"Parking/OnStreet/ParkingSpace/City/{city}"
|
| 201 |
else:
|
|
@@ -228,7 +285,8 @@ class TDXParkingTool(MCPTool):
|
|
| 228 |
parkings.sort(key=lambda x: x["distance_m"])
|
| 229 |
parkings = parkings[:limit]
|
| 230 |
|
| 231 |
-
# 3. 批次查詢即時車位(僅路外停車場)
|
|
|
|
| 232 |
if parking_type != "路邊":
|
| 233 |
parking_ids = [p.get("CarParkID") for p in parkings]
|
| 234 |
|
|
@@ -280,7 +338,8 @@ class TDXParkingTool(MCPTool):
|
|
| 280 |
async def _query_charge_stations(cls, lat: float, lon: float, city: str,
|
| 281 |
radius_m: int, limit: int) -> Dict[str, Any]:
|
| 282 |
"""查詢附近充電站"""
|
| 283 |
-
# 查詢有充電站的停車場
|
|
|
|
| 284 |
parking_endpoint = f"Parking/OffStreet/CarPark/City/{city}"
|
| 285 |
parking_params = {
|
| 286 |
"$filter": "HasChargingPoint eq true",
|
|
@@ -336,9 +395,51 @@ class TDXParkingTool(MCPTool):
|
|
| 336 |
data={"charge_stations": results}
|
| 337 |
)
|
| 338 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
@staticmethod
|
| 340 |
def _map_city_name(chinese_city: str) -> str:
|
| 341 |
"""中文城市名稱轉 TDX 代碼"""
|
|
|
|
|
|
|
|
|
|
| 342 |
city_map = {
|
| 343 |
"台北": "Taipei", "臺北": "Taipei",
|
| 344 |
"新北": "NewTaipei",
|
|
|
|
| 58 |
"type": "integer",
|
| 59 |
"description": "返回結果數量",
|
| 60 |
"default": 5
|
| 61 |
+
},
|
| 62 |
+
"lat": {
|
| 63 |
+
"type": "number",
|
| 64 |
+
"description": "用戶緯度(由系統自動注入)"
|
| 65 |
+
},
|
| 66 |
+
"lon": {
|
| 67 |
+
"type": "number",
|
| 68 |
+
"description": "用戶經度(由系統自動注入)"
|
| 69 |
}
|
| 70 |
}, required=[])
|
| 71 |
|
|
|
|
| 90 |
return schema
|
| 91 |
|
| 92 |
@classmethod
|
| 93 |
+
async def execute(cls, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 94 |
+
# 從 arguments 中讀取 user_id(由 coordinator 注入)
|
| 95 |
+
user_id = arguments.get("_user_id")
|
| 96 |
+
|
| 97 |
parking_name = arguments.get("parking_name", "").strip()
|
| 98 |
city = arguments.get("city")
|
| 99 |
parking_type = arguments.get("parking_type")
|
|
|
|
| 101 |
radius_m = min(int(arguments.get("radius_m", 1000)), 5000)
|
| 102 |
limit = min(int(arguments.get("limit", 5)), 20)
|
| 103 |
|
| 104 |
+
# 1. 取得用戶位置和城市(優先從 arguments 讀取,由 coordinator 注入)
|
| 105 |
+
user_lat = arguments.get("lat")
|
| 106 |
+
user_lon = arguments.get("lon")
|
| 107 |
+
user_city = arguments.get("city", "")
|
| 108 |
+
|
| 109 |
+
logger.info(f"🅿️ [Parking] 輸入參數: lat={user_lat}, lon={user_lon}, city={user_city}, name={parking_name}, user_id={user_id}")
|
| 110 |
+
|
| 111 |
+
# 從資料庫補充缺失的位置資訊(僅當 coordinator 沒有注入時)
|
| 112 |
+
if user_id and (user_lat is None or user_lon is None):
|
| 113 |
+
try:
|
| 114 |
+
env_ctx = await get_user_env_current(user_id)
|
| 115 |
+
logger.info(f"📍 [Parking] 資料庫查詢結果: {env_ctx}")
|
| 116 |
+
if env_ctx and env_ctx.get("success"):
|
| 117 |
+
ctx = env_ctx.get("context", {})
|
| 118 |
+
if user_lat is None:
|
| 119 |
+
user_lat = ctx.get("lat")
|
| 120 |
+
if user_lon is None:
|
| 121 |
+
user_lon = ctx.get("lon")
|
| 122 |
+
if not user_city:
|
| 123 |
+
user_city = ctx.get("city", "")
|
| 124 |
+
logger.info(f"📍 [Parking] 補充後: lat={user_lat}, lon={user_lon}, city={user_city}")
|
| 125 |
+
else:
|
| 126 |
+
logger.warning(f"⚠️ [Parking] 資料庫查詢失敗或無資料: {env_ctx}")
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logger.warning(f"⚠️ [Parking] 資料庫查詢異常: {e}")
|
| 129 |
+
|
| 130 |
+
# 檢查必要條件
|
| 131 |
+
if not parking_name and (user_lat is None or user_lon is None):
|
| 132 |
+
logger.error(f"🅿️ [Parking] 位置資訊缺失: lat={user_lat}, lon={user_lon}, parking_name={parking_name}")
|
| 133 |
+
raise ExecutionError("🅿️ 想幫您找附近的停車場,但目前沒有您的位置資訊。請在 App 中開啟定位,或告訴我您想查詢哪個停車場")
|
| 134 |
+
|
| 135 |
+
# 2. 自動判斷城市(優先使用反向地理編碼)
|
| 136 |
if not city:
|
| 137 |
+
final_city = None
|
| 138 |
+
city_source = "預設"
|
| 139 |
+
|
| 140 |
+
# 優先:即時反向地理編碼
|
| 141 |
+
if user_lat and user_lon:
|
| 142 |
+
geocoded = await cls._reverse_geocode_city(user_lat, user_lon)
|
| 143 |
+
if geocoded:
|
| 144 |
+
final_city = geocoded
|
| 145 |
+
city_source = "反向地理編碼"
|
| 146 |
+
|
| 147 |
+
# 其次:環境參數
|
| 148 |
+
if not final_city and user_city:
|
| 149 |
+
final_city = user_city
|
| 150 |
+
city_source = "環境參數"
|
| 151 |
+
|
| 152 |
+
# 最後:經緯度範圍推斷
|
| 153 |
+
if not final_city and user_lat and user_lon:
|
| 154 |
+
guessed = cls._guess_city_from_location(user_lat, user_lon)
|
| 155 |
+
if guessed:
|
| 156 |
+
final_city = guessed
|
| 157 |
+
city_source = "經緯度推斷"
|
| 158 |
+
|
| 159 |
+
city = cls._map_city_name(final_city) if final_city else "Taipei"
|
| 160 |
+
logger.info(f"🏙️ 最終使用城市代碼: {city} (來源={city_source})")
|
| 161 |
|
| 162 |
# 3. 查詢分支
|
| 163 |
if charge_station_only:
|
|
|
|
| 179 |
@classmethod
|
| 180 |
async def _query_parking_availability(cls, parking_name: str, city: str) -> Dict[str, Any]:
|
| 181 |
"""查詢特定停車場即時資訊"""
|
| 182 |
+
# 1. 查詢停車場基本資訊 (v2 API)
|
| 183 |
+
# GET /v2/Parking/OffStreet/CarPark/City/{City}
|
| 184 |
parking_endpoint = f"Parking/OffStreet/CarPark/City/{city}"
|
| 185 |
parking_params = {
|
| 186 |
"$filter": f"contains(CarParkName/Zh_tw, '{parking_name}')",
|
|
|
|
| 198 |
parking_id = parking.get("CarParkID")
|
| 199 |
full_parking_name = parking.get("CarParkName", {}).get("Zh_tw", parking_name)
|
| 200 |
|
| 201 |
+
# 3. 查詢即時剩餘車位 (v2 API)
|
| 202 |
+
# GET /v2/Parking/OffStreet/ParkingAvailability/City/{City}
|
| 203 |
avail_endpoint = f"Parking/OffStreet/ParkingAvailability/City/{city}"
|
| 204 |
avail_params = {
|
| 205 |
"$filter": f"CarParkID eq '{parking_id}'",
|
|
|
|
| 250 |
async def _query_nearby_parkings(cls, lat: float, lon: float, city: str,
|
| 251 |
parking_type: Optional[str], radius_m: int, limit: int) -> Dict[str, Any]:
|
| 252 |
"""查詢附近停車場"""
|
| 253 |
+
# 1. 查詢附近停車場 (v2 API)
|
| 254 |
+
# GET /v2/Parking/OffStreet/CarPark/City/{City}
|
| 255 |
+
# GET /v2/Parking/OnStreet/ParkingSpace/City/{City}
|
| 256 |
if parking_type == "路邊":
|
| 257 |
parking_endpoint = f"Parking/OnStreet/ParkingSpace/City/{city}"
|
| 258 |
else:
|
|
|
|
| 285 |
parkings.sort(key=lambda x: x["distance_m"])
|
| 286 |
parkings = parkings[:limit]
|
| 287 |
|
| 288 |
+
# 3. 批次查詢即時車位(僅路外停車場)(v2 API)
|
| 289 |
+
# GET /v2/Parking/OffStreet/ParkingAvailability/City/{City}
|
| 290 |
if parking_type != "路邊":
|
| 291 |
parking_ids = [p.get("CarParkID") for p in parkings]
|
| 292 |
|
|
|
|
| 338 |
async def _query_charge_stations(cls, lat: float, lon: float, city: str,
|
| 339 |
radius_m: int, limit: int) -> Dict[str, Any]:
|
| 340 |
"""查詢附近充電站"""
|
| 341 |
+
# 查詢有充電站的停車場 (v2 API)
|
| 342 |
+
# GET /v2/Parking/OffStreet/CarPark/City/{City}
|
| 343 |
parking_endpoint = f"Parking/OffStreet/CarPark/City/{city}"
|
| 344 |
parking_params = {
|
| 345 |
"$filter": "HasChargingPoint eq true",
|
|
|
|
| 395 |
data={"charge_stations": results}
|
| 396 |
)
|
| 397 |
|
| 398 |
+
@staticmethod
|
| 399 |
+
async def _reverse_geocode_city(lat: float, lon: float) -> Optional[str]:
|
| 400 |
+
"""使用 Nominatim 反向地理編碼取得精確城市"""
|
| 401 |
+
import aiohttp
|
| 402 |
+
try:
|
| 403 |
+
async with aiohttp.ClientSession() as session:
|
| 404 |
+
async with session.get(
|
| 405 |
+
"https://nominatim.openstreetmap.org/reverse",
|
| 406 |
+
params={"format": "jsonv2", "lat": lat, "lon": lon, "zoom": 10, "addressdetails": 1},
|
| 407 |
+
headers={"User-Agent": "BloomWare/1.0"},
|
| 408 |
+
timeout=aiohttp.ClientTimeout(total=5)
|
| 409 |
+
) as resp:
|
| 410 |
+
if resp.status != 200:
|
| 411 |
+
return None
|
| 412 |
+
data = await resp.json()
|
| 413 |
+
addr = data.get("address", {}) if data else {}
|
| 414 |
+
city = addr.get("city") or addr.get("county") or addr.get("town") or ""
|
| 415 |
+
return city.replace("市", "").replace("縣", "").strip() or None
|
| 416 |
+
except Exception:
|
| 417 |
+
return None
|
| 418 |
+
|
| 419 |
+
@staticmethod
|
| 420 |
+
def _guess_city_from_location(lat: float, lon: float) -> str:
|
| 421 |
+
"""根據經緯度推斷城市(備用方案)"""
|
| 422 |
+
city_bounds = [
|
| 423 |
+
("桃園", 24.73, 25.12, 120.90, 121.40),
|
| 424 |
+
("台北", 24.95, 25.10, 121.45, 121.62),
|
| 425 |
+
("新北", 24.67, 25.30, 121.35, 122.01),
|
| 426 |
+
("台中", 24.00, 24.45, 120.45, 121.05),
|
| 427 |
+
("台南", 22.85, 23.40, 120.00, 120.55),
|
| 428 |
+
("高雄", 22.45, 23.15, 120.15, 120.80),
|
| 429 |
+
]
|
| 430 |
+
|
| 431 |
+
for city_name, lat_min, lat_max, lon_min, lon_max in city_bounds:
|
| 432 |
+
if lat_min <= lat <= lat_max and lon_min <= lon <= lon_max:
|
| 433 |
+
return city_name
|
| 434 |
+
|
| 435 |
+
return ""
|
| 436 |
+
|
| 437 |
@staticmethod
|
| 438 |
def _map_city_name(chinese_city: str) -> str:
|
| 439 |
"""中文城市名稱轉 TDX 代碼"""
|
| 440 |
+
if not chinese_city:
|
| 441 |
+
return "Taipei"
|
| 442 |
+
|
| 443 |
city_map = {
|
| 444 |
"台北": "Taipei", "臺北": "Taipei",
|
| 445 |
"新北": "NewTaipei",
|
features/mcp/tools/tdx_thsr.py
CHANGED
|
@@ -64,6 +64,14 @@ class TDXTHSRTool(MCPTool):
|
|
| 64 |
"type": "integer",
|
| 65 |
"description": "返回結果數量",
|
| 66 |
"default": 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
}, required=[])
|
| 69 |
|
|
@@ -89,7 +97,10 @@ class TDXTHSRTool(MCPTool):
|
|
| 89 |
return schema
|
| 90 |
|
| 91 |
@classmethod
|
| 92 |
-
async def execute(cls, arguments: Dict[str, Any]
|
|
|
|
|
|
|
|
|
|
| 93 |
origin = arguments.get("origin_station", "").strip()
|
| 94 |
destination = arguments.get("destination_station", "").strip()
|
| 95 |
train_no = arguments.get("train_no", "").strip()
|
|
@@ -97,21 +108,53 @@ class TDXTHSRTool(MCPTool):
|
|
| 97 |
departure_time = arguments.get("departure_time", "").strip()
|
| 98 |
limit = min(int(arguments.get("limit", 5)), 20)
|
| 99 |
|
| 100 |
-
# 1.
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
if env_ctx and env_ctx.get("success"):
|
| 104 |
-
ctx = env_ctx.get("context", {})
|
| 105 |
-
user_lat = ctx.get("lat")
|
| 106 |
-
user_lon = ctx.get("lon")
|
| 107 |
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
if train_no:
|
| 110 |
# 查詢特定車次
|
| 111 |
result = await cls._query_train_schedule(train_no, departure_date)
|
| 112 |
elif origin and destination:
|
| 113 |
# 查詢起迄站列車
|
| 114 |
result = await cls._query_od_trains(origin, destination, departure_date, departure_time, limit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
elif not origin and not destination:
|
| 116 |
# 查詢最近車站
|
| 117 |
if not user_lat or not user_lon:
|
|
@@ -122,6 +165,29 @@ class TDXTHSRTool(MCPTool):
|
|
| 122 |
|
| 123 |
return result
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
@classmethod
|
| 126 |
async def _query_train_schedule(cls, train_no: str, departure_date: str = "") -> Dict[str, Any]:
|
| 127 |
"""查詢特定車次時刻表"""
|
|
@@ -131,8 +197,9 @@ class TDXTHSRTool(MCPTool):
|
|
| 131 |
else:
|
| 132 |
date_str = departure_date
|
| 133 |
|
| 134 |
-
# TDX 高鐵每日時刻表
|
| 135 |
-
|
|
|
|
| 136 |
params = {
|
| 137 |
"$filter": f"DailyTrainInfo/TrainNo eq '{train_no}'",
|
| 138 |
"$format": "JSON"
|
|
@@ -200,8 +267,9 @@ class TDXTHSRTool(MCPTool):
|
|
| 200 |
else:
|
| 201 |
date_str = departure_date
|
| 202 |
|
| 203 |
-
# 1. 查詢當日所有班次
|
| 204 |
-
|
|
|
|
| 205 |
params = {
|
| 206 |
"$format": "JSON"
|
| 207 |
}
|
|
@@ -269,7 +337,8 @@ class TDXTHSRTool(MCPTool):
|
|
| 269 |
except:
|
| 270 |
pass
|
| 271 |
|
| 272 |
-
# 4. 查詢票價
|
|
|
|
| 273 |
fare_endpoint = f"Rail/THSR/ODFare/{origin_code}/to/{dest_code}"
|
| 274 |
fare_params = {
|
| 275 |
"$format": "JSON"
|
|
@@ -302,7 +371,8 @@ class TDXTHSRTool(MCPTool):
|
|
| 302 |
@classmethod
|
| 303 |
async def _query_nearest_station(cls, lat: float, lon: float) -> Dict[str, Any]:
|
| 304 |
"""查詢最近的高鐵站"""
|
| 305 |
-
# 1. 取得所有高鐵車站
|
|
|
|
| 306 |
endpoint = "Rail/THSR/Station"
|
| 307 |
params = {
|
| 308 |
"$format": "JSON"
|
|
|
|
| 64 |
"type": "integer",
|
| 65 |
"description": "返回結果數量",
|
| 66 |
"default": 5
|
| 67 |
+
},
|
| 68 |
+
"lat": {
|
| 69 |
+
"type": "number",
|
| 70 |
+
"description": "用戶緯度(由系統自動注入)"
|
| 71 |
+
},
|
| 72 |
+
"lon": {
|
| 73 |
+
"type": "number",
|
| 74 |
+
"description": "用戶經度(由系統自動注入)"
|
| 75 |
}
|
| 76 |
}, required=[])
|
| 77 |
|
|
|
|
| 97 |
return schema
|
| 98 |
|
| 99 |
@classmethod
|
| 100 |
+
async def execute(cls, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 101 |
+
# 從 arguments 中讀取 user_id(由 coordinator 注入)
|
| 102 |
+
user_id = arguments.get("_user_id")
|
| 103 |
+
|
| 104 |
origin = arguments.get("origin_station", "").strip()
|
| 105 |
destination = arguments.get("destination_station", "").strip()
|
| 106 |
train_no = arguments.get("train_no", "").strip()
|
|
|
|
| 108 |
departure_time = arguments.get("departure_time", "").strip()
|
| 109 |
limit = min(int(arguments.get("limit", 5)), 20)
|
| 110 |
|
| 111 |
+
# 1. 取得用戶位置(優先從 arguments 讀取,由 coordinator 注入)
|
| 112 |
+
user_lat = arguments.get("lat")
|
| 113 |
+
user_lon = arguments.get("lon")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
+
logger.info(f"🚄 [THSR] 輸入參數: lat={user_lat}, lon={user_lon}, origin={origin}, dest={destination}, user_id={user_id}")
|
| 116 |
+
|
| 117 |
+
# 從資料庫補充缺失的位置資訊(僅當 coordinator 沒有注入時)
|
| 118 |
+
if user_id and (user_lat is None or user_lon is None):
|
| 119 |
+
try:
|
| 120 |
+
env_ctx = await get_user_env_current(user_id)
|
| 121 |
+
logger.info(f"📍 [THSR] 資料庫查詢結果: {env_ctx}")
|
| 122 |
+
if env_ctx and env_ctx.get("success"):
|
| 123 |
+
ctx = env_ctx.get("context", {})
|
| 124 |
+
if user_lat is None:
|
| 125 |
+
user_lat = ctx.get("lat")
|
| 126 |
+
if user_lon is None:
|
| 127 |
+
user_lon = ctx.get("lon")
|
| 128 |
+
logger.info(f"📍 [THSR] 補充後: lat={user_lat}, lon={user_lon}")
|
| 129 |
+
else:
|
| 130 |
+
logger.warning(f"⚠️ [THSR] 資料庫查詢失敗或無資料: {env_ctx}")
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.warning(f"⚠️ [THSR] 資料庫查詢異常: {e}")
|
| 133 |
+
|
| 134 |
+
# 2. 驗證並清理站名(過濾無效值)
|
| 135 |
+
origin = cls._validate_station_name(origin)
|
| 136 |
+
destination = cls._validate_station_name(destination)
|
| 137 |
+
logger.info(f"🚄 [THSR] 驗證後: origin={origin}, dest={destination}")
|
| 138 |
+
|
| 139 |
+
# 3. 查詢分支
|
| 140 |
if train_no:
|
| 141 |
# 查詢特定車次
|
| 142 |
result = await cls._query_train_schedule(train_no, departure_date)
|
| 143 |
elif origin and destination:
|
| 144 |
# 查詢起迄站列車
|
| 145 |
result = await cls._query_od_trains(origin, destination, departure_date, departure_time, limit)
|
| 146 |
+
elif destination and not origin:
|
| 147 |
+
# 只有目的地,用 GPS 找最近高鐵站作為起點
|
| 148 |
+
if not user_lat or not user_lon:
|
| 149 |
+
raise ExecutionError("查詢往某站的高鐵需要定位權限,或請同時提供起站名稱")
|
| 150 |
+
nearest_result = await cls._query_nearest_station(user_lat, user_lon)
|
| 151 |
+
# create_success_response 會把 data 直接 update 到 response,所以 stations 在頂層
|
| 152 |
+
nearest_stations = nearest_result.get("stations", [])
|
| 153 |
+
if not nearest_stations:
|
| 154 |
+
raise ExecutionError("附近沒有高鐵車站")
|
| 155 |
+
origin = nearest_stations[0]["station_name"]
|
| 156 |
+
logger.info(f"🚄 [THSR] 自動設定起站: {origin}")
|
| 157 |
+
result = await cls._query_od_trains(origin, destination, departure_date, departure_time, limit)
|
| 158 |
elif not origin and not destination:
|
| 159 |
# 查詢最近車站
|
| 160 |
if not user_lat or not user_lon:
|
|
|
|
| 165 |
|
| 166 |
return result
|
| 167 |
|
| 168 |
+
@classmethod
|
| 169 |
+
def _validate_station_name(cls, station_name: str) -> str:
|
| 170 |
+
"""驗證並清理站名,過濾無效值"""
|
| 171 |
+
if not station_name:
|
| 172 |
+
return ""
|
| 173 |
+
|
| 174 |
+
# 無效的站名關鍵字
|
| 175 |
+
invalid_keywords = [
|
| 176 |
+
"台灣", "臺灣", "Taiwan", "taiwan",
|
| 177 |
+
"中華民國", "ROC", "TW",
|
| 178 |
+
"全部", "所有", "任何", "附近"
|
| 179 |
+
]
|
| 180 |
+
|
| 181 |
+
for keyword in invalid_keywords:
|
| 182 |
+
if keyword in station_name or station_name == keyword:
|
| 183 |
+
logger.warning(f"⚠️ [THSR] 過濾無效站名: {station_name}")
|
| 184 |
+
return ""
|
| 185 |
+
|
| 186 |
+
# 移除常見的後綴
|
| 187 |
+
cleaned = station_name.replace("高鐵站", "").replace("車站", "").replace("站", "").strip()
|
| 188 |
+
|
| 189 |
+
return cleaned if cleaned else station_name
|
| 190 |
+
|
| 191 |
@classmethod
|
| 192 |
async def _query_train_schedule(cls, train_no: str, departure_date: str = "") -> Dict[str, Any]:
|
| 193 |
"""查詢特定車次時刻表"""
|
|
|
|
| 197 |
else:
|
| 198 |
date_str = departure_date
|
| 199 |
|
| 200 |
+
# TDX 高鐵每日時刻表 (v2 API)
|
| 201 |
+
# GET /v2/Rail/THSR/DailyTimetable/TrainDates/{TrainDate}
|
| 202 |
+
endpoint = f"Rail/THSR/DailyTimetable/TrainDates/{date_str}"
|
| 203 |
params = {
|
| 204 |
"$filter": f"DailyTrainInfo/TrainNo eq '{train_no}'",
|
| 205 |
"$format": "JSON"
|
|
|
|
| 267 |
else:
|
| 268 |
date_str = departure_date
|
| 269 |
|
| 270 |
+
# 1. 查詢當日所有班次 (v2 API)
|
| 271 |
+
# GET /v2/Rail/THSR/DailyTimetable/TrainDates/{TrainDate}
|
| 272 |
+
endpoint = f"Rail/THSR/DailyTimetable/TrainDates/{date_str}"
|
| 273 |
params = {
|
| 274 |
"$format": "JSON"
|
| 275 |
}
|
|
|
|
| 337 |
except:
|
| 338 |
pass
|
| 339 |
|
| 340 |
+
# 4. 查詢票價 (v2 API)
|
| 341 |
+
# GET /v2/Rail/THSR/ODFare/{OriginStationID}/to/{DestinationStationID}
|
| 342 |
fare_endpoint = f"Rail/THSR/ODFare/{origin_code}/to/{dest_code}"
|
| 343 |
fare_params = {
|
| 344 |
"$format": "JSON"
|
|
|
|
| 371 |
@classmethod
|
| 372 |
async def _query_nearest_station(cls, lat: float, lon: float) -> Dict[str, Any]:
|
| 373 |
"""查詢最近的高鐵站"""
|
| 374 |
+
# 1. 取得所有高鐵車站 (v2 API)
|
| 375 |
+
# GET /v2/Rail/THSR/Station
|
| 376 |
endpoint = "Rail/THSR/Station"
|
| 377 |
params = {
|
| 378 |
"$format": "JSON"
|
features/mcp/tools/tdx_train.py
CHANGED
|
@@ -57,6 +57,14 @@ class TDXTrainTool(MCPTool):
|
|
| 57 |
"type": "integer",
|
| 58 |
"description": "返回結果數量",
|
| 59 |
"default": 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
}
|
| 61 |
}, required=[])
|
| 62 |
|
|
@@ -81,66 +89,152 @@ class TDXTrainTool(MCPTool):
|
|
| 81 |
return schema
|
| 82 |
|
| 83 |
@classmethod
|
| 84 |
-
async def execute(cls, arguments: Dict[str, Any]
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
train_type = arguments.get("train_type")
|
| 90 |
limit = min(int(arguments.get("limit", 5)), 20)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
-
|
| 93 |
-
env_ctx = await get_user_env_current(user_id) if user_id else None
|
| 94 |
-
user_lat, user_lon = None, None
|
| 95 |
-
if env_ctx and env_ctx.get("success"):
|
| 96 |
-
ctx = env_ctx.get("context", {})
|
| 97 |
-
user_lat = ctx.get("lat")
|
| 98 |
-
user_lon = ctx.get("lon")
|
| 99 |
|
| 100 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
if train_no:
|
| 102 |
# 查詢特定車次
|
| 103 |
result = await cls._query_train_schedule(train_no)
|
| 104 |
elif origin and destination:
|
| 105 |
# 查詢起迄站列車
|
| 106 |
result = await cls._query_od_trains(origin, destination, departure_time, train_type, limit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
elif not origin and not destination:
|
| 108 |
# 查詢最近車站
|
| 109 |
if not user_lat or not user_lon:
|
| 110 |
-
|
|
|
|
| 111 |
result = await cls._query_nearest_station(user_lat, user_lon)
|
| 112 |
else:
|
| 113 |
-
raise ExecutionError("
|
| 114 |
|
| 115 |
return result
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
@classmethod
|
| 118 |
async def _query_train_schedule(cls, train_no: str) -> Dict[str, Any]:
|
| 119 |
"""查詢特定車次時刻表"""
|
| 120 |
today = datetime.now().strftime("%Y-%m-%d")
|
| 121 |
|
| 122 |
-
|
|
|
|
| 123 |
params = {
|
| 124 |
-
"$filter": f"TrainNo eq '{train_no}'",
|
| 125 |
"$format": "JSON"
|
| 126 |
}
|
| 127 |
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
if not trains:
|
| 131 |
raise ExecutionError(f"找不到車次 {train_no},請確認車次號碼")
|
| 132 |
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
# 取得停靠站資訊
|
| 137 |
-
stops =
|
| 138 |
|
| 139 |
if not stops:
|
| 140 |
raise ExecutionError(f"車次 {train_no} 無停靠站資訊")
|
| 141 |
|
| 142 |
# 格式化時刻表
|
| 143 |
-
schedule_lines = [f"🚂 {train_type} {
|
| 144 |
|
| 145 |
for stop in stops:
|
| 146 |
station_name = stop.get("StationName", {}).get("Zh_tw", "未知")
|
|
@@ -160,48 +254,84 @@ class TDXTrainTool(MCPTool):
|
|
| 160 |
|
| 161 |
return cls.create_success_response(
|
| 162 |
content=content,
|
| 163 |
-
data={"train":
|
| 164 |
)
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
@classmethod
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
departure_time: Optional[str], train_type: Optional[str],
|
| 169 |
limit: int) -> Dict[str, Any]:
|
| 170 |
"""查詢起迄站列車"""
|
| 171 |
-
# 1. 先取得今日所有列車
|
| 172 |
-
|
|
|
|
| 173 |
params = {
|
| 174 |
"$format": "JSON"
|
| 175 |
}
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
| 179 |
if not all_trains:
|
| 180 |
raise ExecutionError("無法取得台鐵列車資訊")
|
| 181 |
-
|
| 182 |
# 2. 過濾符合起迄站的列車
|
| 183 |
matching_trains = []
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
origin_idx, dest_idx = -1, -1
|
|
|
|
| 190 |
for i, stop in enumerate(stops):
|
| 191 |
station = stop.get("StationName", {}).get("Zh_tw", "")
|
| 192 |
-
|
|
|
|
| 193 |
origin_idx = i
|
| 194 |
-
if destination
|
| 195 |
dest_idx = i
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
# 起站在迄站之前才符合
|
| 198 |
if origin_idx >= 0 and dest_idx > origin_idx:
|
| 199 |
origin_stop = stops[origin_idx]
|
| 200 |
dest_stop = stops[dest_idx]
|
| 201 |
|
| 202 |
train_info = {
|
| 203 |
-
"train_no":
|
| 204 |
-
"train_type":
|
| 205 |
"origin_station": origin_stop.get("StationName", {}).get("Zh_tw"),
|
| 206 |
"destination_station": dest_stop.get("StationName", {}).get("Zh_tw"),
|
| 207 |
"departure_time": origin_stop.get("DepartureTime", ""),
|
|
@@ -222,7 +352,15 @@ class TDXTrainTool(MCPTool):
|
|
| 222 |
matching_trains.append(train_info)
|
| 223 |
|
| 224 |
if not matching_trains:
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
|
| 227 |
# 3. 時間過濾
|
| 228 |
if departure_time:
|
|
@@ -254,17 +392,32 @@ class TDXTrainTool(MCPTool):
|
|
| 254 |
@classmethod
|
| 255 |
async def _query_nearest_station(cls, lat: float, lon: float) -> Dict[str, Any]:
|
| 256 |
"""查詢最近的台鐵車站"""
|
| 257 |
-
# 1. 取得所有車站
|
|
|
|
| 258 |
endpoint = "Rail/TRA/Station"
|
| 259 |
params = {
|
| 260 |
"$format": "JSON"
|
| 261 |
}
|
| 262 |
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
| 266 |
raise ExecutionError("無法取得台鐵車站資訊")
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
# 2. 計算距離
|
| 269 |
for station in stations:
|
| 270 |
pos = station.get("StationPosition", {})
|
|
@@ -309,24 +462,25 @@ class TDXTrainTool(MCPTool):
|
|
| 309 |
"""格式化起迄站查詢結果"""
|
| 310 |
if not trains:
|
| 311 |
return f"🚂 {origin} → {destination} 目前無可搭乘列車"
|
| 312 |
-
|
| 313 |
-
lines = [f"🚂 {origin} → {destination}
|
| 314 |
-
|
| 315 |
for i, train in enumerate(trains, 1):
|
| 316 |
duration_hours = train["duration_min"] // 60
|
| 317 |
duration_mins = train["duration_min"] % 60
|
| 318 |
-
|
| 319 |
if duration_hours > 0:
|
| 320 |
duration_str = f"{duration_hours}小時{duration_mins}分"
|
| 321 |
else:
|
| 322 |
duration_str = f"{duration_mins}分鐘"
|
| 323 |
-
|
|
|
|
| 324 |
lines.append(
|
| 325 |
-
f"{i}. {train['train_type']} {train['train_no']}
|
| 326 |
-
f"
|
| 327 |
-
f"
|
| 328 |
)
|
| 329 |
-
|
| 330 |
return "\n".join(lines)
|
| 331 |
|
| 332 |
@staticmethod
|
|
|
|
| 57 |
"type": "integer",
|
| 58 |
"description": "返回結果數量",
|
| 59 |
"default": 5
|
| 60 |
+
},
|
| 61 |
+
"lat": {
|
| 62 |
+
"type": "number",
|
| 63 |
+
"description": "用戶緯度(由系統自動注入)"
|
| 64 |
+
},
|
| 65 |
+
"lon": {
|
| 66 |
+
"type": "number",
|
| 67 |
+
"description": "用戶經度(由系統自動注入)"
|
| 68 |
}
|
| 69 |
}, required=[])
|
| 70 |
|
|
|
|
| 89 |
return schema
|
| 90 |
|
| 91 |
@classmethod
|
| 92 |
+
async def execute(cls, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 93 |
+
# 安全取得字串參數
|
| 94 |
+
def safe_str(val) -> str:
|
| 95 |
+
if val is None:
|
| 96 |
+
return ""
|
| 97 |
+
if isinstance(val, dict):
|
| 98 |
+
return ""
|
| 99 |
+
return str(val).strip()
|
| 100 |
+
|
| 101 |
+
# 從 arguments 中讀取 user_id(由 coordinator 注入)
|
| 102 |
+
user_id = arguments.get("_user_id")
|
| 103 |
+
|
| 104 |
+
origin = safe_str(arguments.get("origin_station"))
|
| 105 |
+
destination = safe_str(arguments.get("destination_station"))
|
| 106 |
+
train_no = safe_str(arguments.get("train_no"))
|
| 107 |
+
departure_time = safe_str(arguments.get("departure_time"))
|
| 108 |
train_type = arguments.get("train_type")
|
| 109 |
limit = min(int(arguments.get("limit", 5)), 20)
|
| 110 |
+
|
| 111 |
+
# 環境感知:如果沒有指定出發時間,自動使用當前時間(只顯示未來班次)
|
| 112 |
+
if not departure_time:
|
| 113 |
+
departure_time = datetime.now().strftime("%H:%M")
|
| 114 |
+
logger.info(f"🚂 [Train] 未指定時間,自動使用當前時間: {departure_time}")
|
| 115 |
+
|
| 116 |
+
# 1. 取得用戶位置(優先從 arguments 讀取,由 coordinator 注入)
|
| 117 |
+
user_lat = arguments.get("lat")
|
| 118 |
+
user_lon = arguments.get("lon")
|
| 119 |
|
| 120 |
+
logger.info(f"🚂 [Train] 輸入參數: lat={user_lat}, lon={user_lon}, origin={origin}, dest={destination}, user_id={user_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
+
# 從資料庫補充缺失的位置資訊(僅當 coordinator 沒有注入時)
|
| 123 |
+
if user_id and (user_lat is None or user_lon is None):
|
| 124 |
+
try:
|
| 125 |
+
env_ctx = await get_user_env_current(user_id)
|
| 126 |
+
logger.info(f"📍 [Train] 資料庫查詢結果: {env_ctx}")
|
| 127 |
+
if env_ctx and env_ctx.get("success"):
|
| 128 |
+
ctx = env_ctx.get("context", {})
|
| 129 |
+
if user_lat is None:
|
| 130 |
+
user_lat = ctx.get("lat")
|
| 131 |
+
if user_lon is None:
|
| 132 |
+
user_lon = ctx.get("lon")
|
| 133 |
+
logger.info(f"📍 [Train] 補充後: lat={user_lat}, lon={user_lon}")
|
| 134 |
+
else:
|
| 135 |
+
logger.warning(f"⚠️ [Train] 資料庫查詢失敗或無資料: {env_ctx}")
|
| 136 |
+
except Exception as e:
|
| 137 |
+
logger.warning(f"⚠️ [Train] 資料庫查詢異常: {e}")
|
| 138 |
+
|
| 139 |
+
# 2. 驗證並清理站名(過濾無效值)
|
| 140 |
+
origin = cls._validate_station_name(origin)
|
| 141 |
+
destination = cls._validate_station_name(destination)
|
| 142 |
+
logger.info(f"🚂 [Train] 驗證後: origin={origin}, dest={destination}")
|
| 143 |
+
|
| 144 |
+
# 3. 查詢分支
|
| 145 |
if train_no:
|
| 146 |
# 查詢特定車次
|
| 147 |
result = await cls._query_train_schedule(train_no)
|
| 148 |
elif origin and destination:
|
| 149 |
# 查詢起迄站列車
|
| 150 |
result = await cls._query_od_trains(origin, destination, departure_time, train_type, limit)
|
| 151 |
+
elif destination and not origin:
|
| 152 |
+
# 只有目的地,用 GPS 找最近車站作為起點
|
| 153 |
+
if not user_lat or not user_lon:
|
| 154 |
+
logger.error(f"🚂 [Train] 查詢往{destination}但位置缺失: lat={user_lat}, lon={user_lon}")
|
| 155 |
+
raise ExecutionError(f"🚂 想幫您查往{destination}的火車,但目前沒有您的位置資訊。請在 App 中開啟定位,或告訴我您從哪個車站出發(例如:從桃園到{destination})")
|
| 156 |
+
# 找最近車站作為起點
|
| 157 |
+
nearest_result = await cls._query_nearest_station(user_lat, user_lon)
|
| 158 |
+
# create_success_response 會把 data 直接 update 到 response,所以 stations 在頂層
|
| 159 |
+
nearest_stations = nearest_result.get("stations", [])
|
| 160 |
+
if not nearest_stations:
|
| 161 |
+
raise ExecutionError("🚂 附近沒有找到台鐵車站,請直接告訴我您從哪個車站出發")
|
| 162 |
+
origin = nearest_stations[0]["station_name"]
|
| 163 |
+
logger.info(f"🚂 [Train] 自動設定起站: {origin}")
|
| 164 |
+
result = await cls._query_od_trains(origin, destination, departure_time, train_type, limit)
|
| 165 |
+
elif origin and not destination:
|
| 166 |
+
# 只有起點,查詢從該站出發的列車(顯示最近車站資訊)
|
| 167 |
+
if not user_lat or not user_lon:
|
| 168 |
+
raise ExecutionError("🚂 請告訴我您要去哪裡,或開啟定位讓我幫您查詢最近的車站")
|
| 169 |
+
result = await cls._query_nearest_station(user_lat, user_lon)
|
| 170 |
elif not origin and not destination:
|
| 171 |
# 查詢最近車站
|
| 172 |
if not user_lat or not user_lon:
|
| 173 |
+
logger.error(f"🚂 [Train] 查詢最近車站但位置缺失: lat={user_lat}, lon={user_lon}")
|
| 174 |
+
raise ExecutionError("🚂 想幫您找最近的火車站,但目前沒有您的位置資訊。請在 App 中開啟定位,或直接告訴我起迄站(例如:台北到台中)")
|
| 175 |
result = await cls._query_nearest_station(user_lat, user_lon)
|
| 176 |
else:
|
| 177 |
+
raise ExecutionError("🚂 請告訴我您要查詢的車次號碼,或起迄站名稱(例如:台北到高雄的火車)")
|
| 178 |
|
| 179 |
return result
|
| 180 |
|
| 181 |
+
@classmethod
|
| 182 |
+
def _validate_station_name(cls, station_name: str) -> str:
|
| 183 |
+
"""驗證並清理站名,過濾無效值"""
|
| 184 |
+
if not station_name:
|
| 185 |
+
return ""
|
| 186 |
+
|
| 187 |
+
# 無效的站名關鍵字(國家、地區等非具體車站名稱)
|
| 188 |
+
invalid_keywords = [
|
| 189 |
+
"台灣", "臺灣", "Taiwan", "taiwan",
|
| 190 |
+
"中華民國", "ROC", "TW",
|
| 191 |
+
"全部", "所有", "任何", "附近"
|
| 192 |
+
]
|
| 193 |
+
|
| 194 |
+
for keyword in invalid_keywords:
|
| 195 |
+
if keyword in station_name or station_name == keyword:
|
| 196 |
+
logger.warning(f"⚠️ [Train] 過濾無效站名: {station_name}")
|
| 197 |
+
return ""
|
| 198 |
+
|
| 199 |
+
# 移除常見的後綴(如「站」「車站」「火車站」)以便匹配
|
| 200 |
+
cleaned = station_name.replace("火車站", "").replace("車站", "").replace("站", "").strip()
|
| 201 |
+
|
| 202 |
+
return cleaned if cleaned else station_name
|
| 203 |
+
|
| 204 |
@classmethod
|
| 205 |
async def _query_train_schedule(cls, train_no: str) -> Dict[str, Any]:
|
| 206 |
"""查詢特定車次時刻表"""
|
| 207 |
today = datetime.now().strftime("%Y-%m-%d")
|
| 208 |
|
| 209 |
+
# v3 API: GET /v3/Rail/TRA/DailyTrainTimetable/Today/TrainNo/{TrainNo}
|
| 210 |
+
endpoint = f"Rail/TRA/DailyTrainTimetable/Today/TrainNo/{train_no}"
|
| 211 |
params = {
|
|
|
|
| 212 |
"$format": "JSON"
|
| 213 |
}
|
| 214 |
|
| 215 |
+
result = await TDXBaseAPI.call_api(endpoint, params, cache_ttl=1800, api_version="v3")
|
| 216 |
+
|
| 217 |
+
# v3 回應結構: TrainTimetables 陣列
|
| 218 |
+
trains = result.get("TrainTimetables", []) if isinstance(result, dict) else result
|
| 219 |
|
| 220 |
if not trains:
|
| 221 |
raise ExecutionError(f"找不到車次 {train_no},請確認車次號碼")
|
| 222 |
|
| 223 |
+
train_data = trains[0] if isinstance(trains, list) else trains
|
| 224 |
+
|
| 225 |
+
# v3 結構: TrainInfo 包含列車資訊, StopTimes 包含停靠站
|
| 226 |
+
train_info = train_data.get("TrainInfo", train_data)
|
| 227 |
+
train_type = train_info.get("TrainTypeName", {}).get("Zh_tw", "未知")
|
| 228 |
+
actual_train_no = train_info.get("TrainNo", train_no)
|
| 229 |
|
| 230 |
# 取得停靠站資訊
|
| 231 |
+
stops = train_data.get("StopTimes", [])
|
| 232 |
|
| 233 |
if not stops:
|
| 234 |
raise ExecutionError(f"車次 {train_no} 無停靠站資訊")
|
| 235 |
|
| 236 |
# 格式化時刻表
|
| 237 |
+
schedule_lines = [f"🚂 {train_type} {actual_train_no} 次\n"]
|
| 238 |
|
| 239 |
for stop in stops:
|
| 240 |
station_name = stop.get("StationName", {}).get("Zh_tw", "未知")
|
|
|
|
| 254 |
|
| 255 |
return cls.create_success_response(
|
| 256 |
content=content,
|
| 257 |
+
data={"train": train_info, "stops": stops}
|
| 258 |
)
|
| 259 |
|
| 260 |
+
@staticmethod
|
| 261 |
+
def _normalize_station_name(name: str) -> str:
|
| 262 |
+
"""正規化站名(處理繁簡字)"""
|
| 263 |
+
# 台 → 臺 統一轉換
|
| 264 |
+
return name.replace("台", "臺")
|
| 265 |
+
|
| 266 |
@classmethod
|
| 267 |
+
def _station_match(cls, query: str, station: str) -> bool:
|
| 268 |
+
"""站名匹配(支援繁簡字)"""
|
| 269 |
+
# 正規化後比較
|
| 270 |
+
query_norm = cls._normalize_station_name(query)
|
| 271 |
+
station_norm = cls._normalize_station_name(station)
|
| 272 |
+
return query_norm in station_norm
|
| 273 |
+
|
| 274 |
+
@classmethod
|
| 275 |
+
async def _query_od_trains(cls, origin: str, destination: str,
|
| 276 |
departure_time: Optional[str], train_type: Optional[str],
|
| 277 |
limit: int) -> Dict[str, Any]:
|
| 278 |
"""查詢起迄站列車"""
|
| 279 |
+
# 1. 先取得今日所有列車 (v3 API)
|
| 280 |
+
# GET /v3/Rail/TRA/DailyTrainTimetable/Today
|
| 281 |
+
endpoint = "Rail/TRA/DailyTrainTimetable/Today"
|
| 282 |
params = {
|
| 283 |
"$format": "JSON"
|
| 284 |
}
|
| 285 |
+
|
| 286 |
+
result = await TDXBaseAPI.call_api(endpoint, params, cache_ttl=1800, api_version="v3")
|
| 287 |
+
|
| 288 |
+
# v3 回應結構: TrainTimetables 陣列
|
| 289 |
+
all_trains = result.get("TrainTimetables", []) if isinstance(result, dict) else result
|
| 290 |
+
|
| 291 |
if not all_trains:
|
| 292 |
raise ExecutionError("無法取得台鐵列車資訊")
|
| 293 |
+
|
| 294 |
# 2. 過濾符合起迄站的列車
|
| 295 |
matching_trains = []
|
| 296 |
+
|
| 297 |
+
# Debug: 收集所有包含起點的車次(用於診斷)
|
| 298 |
+
trains_with_origin = []
|
| 299 |
+
|
| 300 |
+
for train_data in all_trains:
|
| 301 |
+
# v3 結構: TrainInfo 包含列車資訊, StopTimes 包含停靠站
|
| 302 |
+
train_info_obj = train_data.get("TrainInfo", train_data)
|
| 303 |
+
stops = train_data.get("StopTimes", [])
|
| 304 |
+
|
| 305 |
+
# 找起站和迄站(使用繁簡字相容的匹配)
|
| 306 |
origin_idx, dest_idx = -1, -1
|
| 307 |
+
all_station_names = [] # Debug: 收集所有站名
|
| 308 |
for i, stop in enumerate(stops):
|
| 309 |
station = stop.get("StationName", {}).get("Zh_tw", "")
|
| 310 |
+
all_station_names.append(station)
|
| 311 |
+
if cls._station_match(origin, station):
|
| 312 |
origin_idx = i
|
| 313 |
+
if cls._station_match(destination, station):
|
| 314 |
dest_idx = i
|
| 315 |
+
|
| 316 |
+
# Debug: 記錄有經過起點的車次
|
| 317 |
+
if origin_idx >= 0:
|
| 318 |
+
trains_with_origin.append({
|
| 319 |
+
"train_no": train_info_obj.get("TrainNo"),
|
| 320 |
+
"train_type": train_info_obj.get("TrainTypeName", {}).get("Zh_tw", "未知"),
|
| 321 |
+
"has_dest": dest_idx >= 0,
|
| 322 |
+
"origin_idx": origin_idx,
|
| 323 |
+
"dest_idx": dest_idx,
|
| 324 |
+
"all_stations": all_station_names # 完整站名列表
|
| 325 |
+
})
|
| 326 |
+
|
| 327 |
# 起站在迄站之前才符合
|
| 328 |
if origin_idx >= 0 and dest_idx > origin_idx:
|
| 329 |
origin_stop = stops[origin_idx]
|
| 330 |
dest_stop = stops[dest_idx]
|
| 331 |
|
| 332 |
train_info = {
|
| 333 |
+
"train_no": train_info_obj.get("TrainNo"),
|
| 334 |
+
"train_type": train_info_obj.get("TrainTypeName", {}).get("Zh_tw", "未知"),
|
| 335 |
"origin_station": origin_stop.get("StationName", {}).get("Zh_tw"),
|
| 336 |
"destination_station": dest_stop.get("StationName", {}).get("Zh_tw"),
|
| 337 |
"departure_time": origin_stop.get("DepartureTime", ""),
|
|
|
|
| 352 |
matching_trains.append(train_info)
|
| 353 |
|
| 354 |
if not matching_trains:
|
| 355 |
+
# Debug 資訊
|
| 356 |
+
logger.error(f"🚂 [Train] 找不到 {origin} 到 {destination} 的列車")
|
| 357 |
+
logger.error(f"🚂 [Train] 經過 {origin} 的車次數: {len(trains_with_origin)}")
|
| 358 |
+
if trains_with_origin:
|
| 359 |
+
first_train = trains_with_origin[0]
|
| 360 |
+
logger.error(f"🚂 [Train] 第一班車 {first_train['train_no']} 的所有站點: {first_train['all_stations']}")
|
| 361 |
+
logger.error(f"🚂 [Train] 查詢目的地: '{destination}'")
|
| 362 |
+
logger.error(f"🚂 [Train] 前 3 個經過起點的車次: {[{'train_no': t['train_no'], 'type': t['train_type'], 'has_dest': t['has_dest']} for t in trains_with_origin[:3]]}")
|
| 363 |
+
raise ExecutionError(f"找不到 {origin} 到 {destination} 的列車(共掃描 {len(all_trains)} 班次,{len(trains_with_origin)} 班經過起點)")
|
| 364 |
|
| 365 |
# 3. 時間過濾
|
| 366 |
if departure_time:
|
|
|
|
| 392 |
@classmethod
|
| 393 |
async def _query_nearest_station(cls, lat: float, lon: float) -> Dict[str, Any]:
|
| 394 |
"""查詢最近的台鐵車站"""
|
| 395 |
+
# 1. 取得所有車站 (v3 API)
|
| 396 |
+
# GET /v3/Rail/TRA/Station
|
| 397 |
endpoint = "Rail/TRA/Station"
|
| 398 |
params = {
|
| 399 |
"$format": "JSON"
|
| 400 |
}
|
| 401 |
|
| 402 |
+
result = await TDXBaseAPI.call_api(endpoint, params, cache_ttl=86400, api_version="v3")
|
| 403 |
+
|
| 404 |
+
# v3 API 返回的是 dict,車站列表在 Stations 欄位
|
| 405 |
+
if not result:
|
| 406 |
raise ExecutionError("無法取得台鐵車站資訊")
|
| 407 |
+
|
| 408 |
+
# 提取車站列表
|
| 409 |
+
if isinstance(result, dict):
|
| 410 |
+
stations = result.get("Stations", [])
|
| 411 |
+
elif isinstance(result, list):
|
| 412 |
+
# 向後兼容:如果直接返回 list
|
| 413 |
+
stations = result
|
| 414 |
+
else:
|
| 415 |
+
logger.error(f"🚂 [Train] API 返回未知類型: {type(result).__name__}")
|
| 416 |
+
raise ExecutionError(f"台鐵車站 API 返回格式錯誤")
|
| 417 |
+
|
| 418 |
+
if not stations:
|
| 419 |
+
raise ExecutionError("無法取得台鐵車站資訊(Stations 欄位為空)")
|
| 420 |
+
|
| 421 |
# 2. 計算距離
|
| 422 |
for station in stations:
|
| 423 |
pos = station.get("StationPosition", {})
|
|
|
|
| 462 |
"""格式化起迄站查詢結果"""
|
| 463 |
if not trains:
|
| 464 |
return f"🚂 {origin} → {destination} 目前無可搭乘列車"
|
| 465 |
+
|
| 466 |
+
lines = [f"🚂 {origin} → {destination} 有以下列車:\n"]
|
| 467 |
+
|
| 468 |
for i, train in enumerate(trains, 1):
|
| 469 |
duration_hours = train["duration_min"] // 60
|
| 470 |
duration_mins = train["duration_min"] % 60
|
| 471 |
+
|
| 472 |
if duration_hours > 0:
|
| 473 |
duration_str = f"{duration_hours}小時{duration_mins}分"
|
| 474 |
else:
|
| 475 |
duration_str = f"{duration_mins}分鐘"
|
| 476 |
+
|
| 477 |
+
# 改進格式:讓車次號碼更突出
|
| 478 |
lines.append(
|
| 479 |
+
f"{i}. 【{train['train_type']} {train['train_no']}次】"
|
| 480 |
+
f" {train['departure_time'][:5]}出發 → {train['arrival_time'][:5]}抵達"
|
| 481 |
+
f" (約{duration_str})"
|
| 482 |
)
|
| 483 |
+
|
| 484 |
return "\n".join(lines)
|
| 485 |
|
| 486 |
@staticmethod
|
features/mcp/tools/tdx_youbike.py
CHANGED
|
@@ -4,7 +4,7 @@ TDX YouBike 即時查詢工具
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
import logging
|
| 7 |
-
from typing import Dict, Any, List
|
| 8 |
|
| 9 |
from .base_tool import MCPTool, StandardToolSchemas, ExecutionError
|
| 10 |
from .tdx_base import TDXBaseAPI
|
|
@@ -62,6 +62,14 @@ class TDXBikeTool(MCPTool):
|
|
| 62 |
"type": "integer",
|
| 63 |
"description": "返回結果數量",
|
| 64 |
"default": 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
}
|
| 66 |
}, required=[])
|
| 67 |
|
|
@@ -86,34 +94,88 @@ class TDXBikeTool(MCPTool):
|
|
| 86 |
return schema
|
| 87 |
|
| 88 |
@classmethod
|
| 89 |
-
async def execute(cls, arguments: Dict[str, Any]
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
city = arguments.get("city")
|
| 92 |
radius_m = min(int(arguments.get("radius_m", 500)), 2000)
|
| 93 |
limit = min(int(arguments.get("limit", 5)), 20)
|
| 94 |
|
| 95 |
-
# 1.
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
else:
|
| 102 |
-
ctx = env_ctx.get("context", {})
|
| 103 |
-
user_lat = ctx.get("lat")
|
| 104 |
-
user_lon = ctx.get("lon")
|
| 105 |
-
user_city = ctx.get("city", "")
|
| 106 |
|
| 107 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
if not city:
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
# 3. 查詢分支
|
| 112 |
if station_name:
|
| 113 |
result = await cls._query_station_availability(station_name, city)
|
| 114 |
else:
|
| 115 |
if not user_lat or not user_lon:
|
| 116 |
-
|
|
|
|
| 117 |
result = await cls._query_nearby_stations(user_lat, user_lon, city, radius_m, limit)
|
| 118 |
|
| 119 |
return result
|
|
@@ -121,7 +183,8 @@ class TDXBikeTool(MCPTool):
|
|
| 121 |
@classmethod
|
| 122 |
async def _query_station_availability(cls, station_name: str, city: str) -> Dict[str, Any]:
|
| 123 |
"""查詢特定站點即時資訊"""
|
| 124 |
-
# 1. 查詢站點基本資訊
|
|
|
|
| 125 |
station_endpoint = f"Bike/Station/City/{city}"
|
| 126 |
station_params = {
|
| 127 |
"$filter": f"contains(StationName/Zh_tw, '{station_name}')",
|
|
@@ -148,7 +211,8 @@ class TDXBikeTool(MCPTool):
|
|
| 148 |
station_uid = target_station.get("StationUID")
|
| 149 |
full_station_name = target_station.get("StationName", {}).get("Zh_tw", station_name)
|
| 150 |
|
| 151 |
-
# 3. 查詢即時可用車輛數
|
|
|
|
| 152 |
avail_endpoint = f"Bike/Availability/City/{city}"
|
| 153 |
avail_params = {
|
| 154 |
"$filter": f"StationUID eq '{station_uid}'",
|
|
@@ -171,7 +235,7 @@ class TDXBikeTool(MCPTool):
|
|
| 171 |
"available_spaces": avail.get("AvailableReturnBikes", 0),
|
| 172 |
"service_status": avail.get("ServiceStatus", 1),
|
| 173 |
"update_time": avail.get("UpdateTime", ""),
|
| 174 |
-
"bike_type":
|
| 175 |
}
|
| 176 |
|
| 177 |
# 4. 格式化結果
|
|
@@ -199,7 +263,8 @@ class TDXBikeTool(MCPTool):
|
|
| 199 |
async def _query_nearby_stations(cls, lat: float, lon: float, city: str,
|
| 200 |
radius_m: int, limit: int) -> Dict[str, Any]:
|
| 201 |
"""查詢附近站點"""
|
| 202 |
-
# 1. 查詢附近站點(使用空間過濾)
|
|
|
|
| 203 |
station_endpoint = f"Bike/Station/City/{city}"
|
| 204 |
station_params = {
|
| 205 |
"$spatialFilter": f"nearby({lat}, {lon}, {radius_m})",
|
|
@@ -228,7 +293,8 @@ class TDXBikeTool(MCPTool):
|
|
| 228 |
stations.sort(key=lambda x: x["distance_m"])
|
| 229 |
stations = stations[:limit]
|
| 230 |
|
| 231 |
-
# 3. 批次查詢即時資訊
|
|
|
|
| 232 |
station_uids = [s.get("StationUID") for s in stations]
|
| 233 |
|
| 234 |
avail_endpoint = f"Bike/Availability/City/{city}"
|
|
@@ -259,7 +325,7 @@ class TDXBikeTool(MCPTool):
|
|
| 259 |
"distance_m": int(distance),
|
| 260 |
"walking_time_min": walking_time,
|
| 261 |
"service_status": avail.get("ServiceStatus", 1),
|
| 262 |
-
"bike_type":
|
| 263 |
})
|
| 264 |
|
| 265 |
content = cls._format_nearby_result(results)
|
|
@@ -269,14 +335,74 @@ class TDXBikeTool(MCPTool):
|
|
| 269 |
data={"stations": results}
|
| 270 |
)
|
| 271 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
@staticmethod
|
| 273 |
def _map_city_name(chinese_city: str) -> str:
|
| 274 |
"""中文城市名稱轉 TDX 代碼"""
|
|
|
|
|
|
|
|
|
|
| 275 |
for key, value in TDXBikeTool.CITY_MAP.items():
|
| 276 |
if key in chinese_city:
|
| 277 |
return value
|
| 278 |
return "Taipei"
|
| 279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
@staticmethod
|
| 281 |
def _format_nearby_result(stations: List[Dict]) -> str:
|
| 282 |
"""格式化附近站點結果"""
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
import logging
|
| 7 |
+
from typing import Dict, Any, List, Optional
|
| 8 |
|
| 9 |
from .base_tool import MCPTool, StandardToolSchemas, ExecutionError
|
| 10 |
from .tdx_base import TDXBaseAPI
|
|
|
|
| 62 |
"type": "integer",
|
| 63 |
"description": "返回結果數量",
|
| 64 |
"default": 5
|
| 65 |
+
},
|
| 66 |
+
"lat": {
|
| 67 |
+
"type": "number",
|
| 68 |
+
"description": "用戶緯度(由系統自動注入)"
|
| 69 |
+
},
|
| 70 |
+
"lon": {
|
| 71 |
+
"type": "number",
|
| 72 |
+
"description": "用戶經度(由系統自動注入)"
|
| 73 |
}
|
| 74 |
}, required=[])
|
| 75 |
|
|
|
|
| 94 |
return schema
|
| 95 |
|
| 96 |
@classmethod
|
| 97 |
+
async def execute(cls, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 98 |
+
# 安全取得字串參數
|
| 99 |
+
def safe_str(val) -> str:
|
| 100 |
+
if val is None:
|
| 101 |
+
return ""
|
| 102 |
+
if isinstance(val, dict):
|
| 103 |
+
return ""
|
| 104 |
+
return str(val).strip()
|
| 105 |
+
|
| 106 |
+
# 從 arguments 中讀取 user_id(由 coordinator 注入)
|
| 107 |
+
user_id = arguments.get("_user_id")
|
| 108 |
+
|
| 109 |
+
station_name = safe_str(arguments.get("station_name"))
|
| 110 |
city = arguments.get("city")
|
| 111 |
radius_m = min(int(arguments.get("radius_m", 500)), 2000)
|
| 112 |
limit = min(int(arguments.get("limit", 5)), 20)
|
| 113 |
|
| 114 |
+
# 1. 取得用戶位置和城市(優先從 arguments 讀取,由 coordinator 注入)
|
| 115 |
+
user_lat = arguments.get("lat")
|
| 116 |
+
user_lon = arguments.get("lon")
|
| 117 |
+
user_city = safe_str(arguments.get("city"))
|
| 118 |
+
|
| 119 |
+
logger.info(f"🚲 [YouBike] 輸入參數: lat={user_lat}, lon={user_lon}, city={user_city}, station={station_name}, user_id={user_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
+
# 從資料庫補充缺失的位置資訊(僅當 coordinator 沒有注入時)
|
| 122 |
+
if user_id and (user_lat is None or user_lon is None):
|
| 123 |
+
try:
|
| 124 |
+
env_ctx = await get_user_env_current(user_id)
|
| 125 |
+
logger.info(f"📍 [YouBike] 資料庫查詢結果: {env_ctx}")
|
| 126 |
+
if env_ctx and env_ctx.get("success"):
|
| 127 |
+
ctx = env_ctx.get("context", {})
|
| 128 |
+
if user_lat is None:
|
| 129 |
+
user_lat = ctx.get("lat")
|
| 130 |
+
if user_lon is None:
|
| 131 |
+
user_lon = ctx.get("lon")
|
| 132 |
+
if not user_city:
|
| 133 |
+
user_city = safe_str(ctx.get("city"))
|
| 134 |
+
logger.info(f"📍 [YouBike] 補充後: lat={user_lat}, lon={user_lon}, city={user_city}")
|
| 135 |
+
else:
|
| 136 |
+
logger.warning(f"⚠️ [YouBike] 資料庫查詢失敗或無資料: {env_ctx}")
|
| 137 |
+
except Exception as e:
|
| 138 |
+
logger.warning(f"⚠️ [YouBike] 資料庫查詢異常: {e}")
|
| 139 |
+
|
| 140 |
+
# 檢查必要條件
|
| 141 |
+
if not station_name and (user_lat is None or user_lon is None):
|
| 142 |
+
logger.error(f"🚲 [YouBike] 位置資訊缺失: lat={user_lat}, lon={user_lon}, station_name={station_name}")
|
| 143 |
+
raise ExecutionError("🚲 想幫您找附近的 YouBike,但目前沒有您的位置資訊。請在 App 中開啟定位,或告訴我您想查詢哪個站點(例如:市政府 YouBike)")
|
| 144 |
+
|
| 145 |
+
# 2. 自動判斷城市(優先使用反向地理編碼)
|
| 146 |
if not city:
|
| 147 |
+
final_city = None
|
| 148 |
+
city_source = "預設"
|
| 149 |
+
|
| 150 |
+
# 優先:即時反向地理編碼
|
| 151 |
+
if user_lat and user_lon:
|
| 152 |
+
geocoded = await cls._reverse_geocode_city(user_lat, user_lon)
|
| 153 |
+
if geocoded:
|
| 154 |
+
final_city = geocoded
|
| 155 |
+
city_source = "反向地理編碼"
|
| 156 |
+
|
| 157 |
+
# 其次:環境參數
|
| 158 |
+
if not final_city and user_city:
|
| 159 |
+
final_city = user_city
|
| 160 |
+
city_source = "環境參數"
|
| 161 |
+
|
| 162 |
+
# 最後:經緯度範圍推斷
|
| 163 |
+
if not final_city and user_lat and user_lon:
|
| 164 |
+
guessed = cls._guess_city_from_location(user_lat, user_lon)
|
| 165 |
+
if guessed:
|
| 166 |
+
final_city = guessed
|
| 167 |
+
city_source = "經緯度推斷"
|
| 168 |
+
|
| 169 |
+
city = cls._map_city_name(final_city) if final_city else "Taipei"
|
| 170 |
+
logger.info(f"🏙️ 最終使用城市代碼: {city} (來源={city_source})")
|
| 171 |
|
| 172 |
# 3. 查詢分支
|
| 173 |
if station_name:
|
| 174 |
result = await cls._query_station_availability(station_name, city)
|
| 175 |
else:
|
| 176 |
if not user_lat or not user_lon:
|
| 177 |
+
logger.error(f"🚲 [YouBike] 查詢附近站點但位置缺失: lat={user_lat}, lon={user_lon}")
|
| 178 |
+
raise ExecutionError("🚲 想幫您找附近的 YouBike,但目前沒有您的位置資訊。請在 App 中開啟定位功能")
|
| 179 |
result = await cls._query_nearby_stations(user_lat, user_lon, city, radius_m, limit)
|
| 180 |
|
| 181 |
return result
|
|
|
|
| 183 |
@classmethod
|
| 184 |
async def _query_station_availability(cls, station_name: str, city: str) -> Dict[str, Any]:
|
| 185 |
"""查詢特定站點即時資訊"""
|
| 186 |
+
# 1. 查詢站點基本資訊 (v2 API)
|
| 187 |
+
# GET /v2/Bike/Station/City/{City}
|
| 188 |
station_endpoint = f"Bike/Station/City/{city}"
|
| 189 |
station_params = {
|
| 190 |
"$filter": f"contains(StationName/Zh_tw, '{station_name}')",
|
|
|
|
| 211 |
station_uid = target_station.get("StationUID")
|
| 212 |
full_station_name = target_station.get("StationName", {}).get("Zh_tw", station_name)
|
| 213 |
|
| 214 |
+
# 3. 查詢即時可用車輛數 (v2 API)
|
| 215 |
+
# GET /v2/Bike/Availability/City/{City}
|
| 216 |
avail_endpoint = f"Bike/Availability/City/{city}"
|
| 217 |
avail_params = {
|
| 218 |
"$filter": f"StationUID eq '{station_uid}'",
|
|
|
|
| 235 |
"available_spaces": avail.get("AvailableReturnBikes", 0),
|
| 236 |
"service_status": avail.get("ServiceStatus", 1),
|
| 237 |
"update_time": avail.get("UpdateTime", ""),
|
| 238 |
+
"bike_type": cls._detect_bike_type(target_station, full_station_name)
|
| 239 |
}
|
| 240 |
|
| 241 |
# 4. 格式化結果
|
|
|
|
| 263 |
async def _query_nearby_stations(cls, lat: float, lon: float, city: str,
|
| 264 |
radius_m: int, limit: int) -> Dict[str, Any]:
|
| 265 |
"""查詢附近站點"""
|
| 266 |
+
# 1. 查詢附近站點(使用空間過濾)(v2 API)
|
| 267 |
+
# GET /v2/Bike/Station/City/{City}
|
| 268 |
station_endpoint = f"Bike/Station/City/{city}"
|
| 269 |
station_params = {
|
| 270 |
"$spatialFilter": f"nearby({lat}, {lon}, {radius_m})",
|
|
|
|
| 293 |
stations.sort(key=lambda x: x["distance_m"])
|
| 294 |
stations = stations[:limit]
|
| 295 |
|
| 296 |
+
# 3. 批次查詢即時資訊 (v2 API)
|
| 297 |
+
# GET /v2/Bike/Availability/City/{City}
|
| 298 |
station_uids = [s.get("StationUID") for s in stations]
|
| 299 |
|
| 300 |
avail_endpoint = f"Bike/Availability/City/{city}"
|
|
|
|
| 325 |
"distance_m": int(distance),
|
| 326 |
"walking_time_min": walking_time,
|
| 327 |
"service_status": avail.get("ServiceStatus", 1),
|
| 328 |
+
"bike_type": cls._detect_bike_type(station, station_name)
|
| 329 |
})
|
| 330 |
|
| 331 |
content = cls._format_nearby_result(results)
|
|
|
|
| 335 |
data={"stations": results}
|
| 336 |
)
|
| 337 |
|
| 338 |
+
@staticmethod
|
| 339 |
+
async def _reverse_geocode_city(lat: float, lon: float) -> Optional[str]:
|
| 340 |
+
"""使用 Nominatim 反向地理編碼取得精確城市"""
|
| 341 |
+
import aiohttp
|
| 342 |
+
try:
|
| 343 |
+
async with aiohttp.ClientSession() as session:
|
| 344 |
+
async with session.get(
|
| 345 |
+
"https://nominatim.openstreetmap.org/reverse",
|
| 346 |
+
params={"format": "jsonv2", "lat": lat, "lon": lon, "zoom": 10, "addressdetails": 1},
|
| 347 |
+
headers={"User-Agent": "BloomWare/1.0"},
|
| 348 |
+
timeout=aiohttp.ClientTimeout(total=5)
|
| 349 |
+
) as resp:
|
| 350 |
+
if resp.status != 200:
|
| 351 |
+
return None
|
| 352 |
+
data = await resp.json()
|
| 353 |
+
addr = data.get("address", {}) if data else {}
|
| 354 |
+
city = addr.get("city") or addr.get("county") or addr.get("town") or ""
|
| 355 |
+
return city.replace("市", "").replace("縣", "").strip() or None
|
| 356 |
+
except Exception:
|
| 357 |
+
return None
|
| 358 |
+
|
| 359 |
+
@staticmethod
|
| 360 |
+
def _guess_city_from_location(lat: float, lon: float) -> str:
|
| 361 |
+
"""根據經緯度推斷城市(備用方案)"""
|
| 362 |
+
city_bounds = [
|
| 363 |
+
("桃園", 24.73, 25.12, 120.90, 121.40),
|
| 364 |
+
("台北", 24.95, 25.10, 121.45, 121.62),
|
| 365 |
+
("新北", 24.67, 25.30, 121.35, 122.01),
|
| 366 |
+
("新竹", 24.68, 24.90, 120.90, 121.10),
|
| 367 |
+
("台中", 24.00, 24.45, 120.45, 121.05),
|
| 368 |
+
("台南", 22.85, 23.40, 120.00, 120.55),
|
| 369 |
+
("高雄", 22.45, 23.15, 120.15, 120.80),
|
| 370 |
+
]
|
| 371 |
+
|
| 372 |
+
for city_name, lat_min, lat_max, lon_min, lon_max in city_bounds:
|
| 373 |
+
if lat_min <= lat <= lat_max and lon_min <= lon <= lon_max:
|
| 374 |
+
return city_name
|
| 375 |
+
|
| 376 |
+
return ""
|
| 377 |
+
|
| 378 |
@staticmethod
|
| 379 |
def _map_city_name(chinese_city: str) -> str:
|
| 380 |
"""中文城市名稱轉 TDX 代碼"""
|
| 381 |
+
if not chinese_city:
|
| 382 |
+
return "Taipei"
|
| 383 |
+
|
| 384 |
for key, value in TDXBikeTool.CITY_MAP.items():
|
| 385 |
if key in chinese_city:
|
| 386 |
return value
|
| 387 |
return "Taipei"
|
| 388 |
|
| 389 |
+
@staticmethod
|
| 390 |
+
def _detect_bike_type(station: Dict, station_name: str) -> str:
|
| 391 |
+
"""判斷 YouBike 類型(優先從站名判斷,其次從 BikesCapacity)"""
|
| 392 |
+
# 優先從站名判斷
|
| 393 |
+
if "2.0" in station_name or "YouBike2.0" in station_name:
|
| 394 |
+
return "YouBike 2.0"
|
| 395 |
+
if "1.0" in station_name or "YouBike1.0" in station_name:
|
| 396 |
+
return "YouBike 1.0"
|
| 397 |
+
|
| 398 |
+
# 其次從 BikesCapacity 判斷
|
| 399 |
+
capacity = str(station.get("BikesCapacity", ""))
|
| 400 |
+
if "2.0" in capacity:
|
| 401 |
+
return "YouBike 2.0"
|
| 402 |
+
|
| 403 |
+
# 預設為 2.0(新站點大多是 2.0)
|
| 404 |
+
return "YouBike 2.0"
|
| 405 |
+
|
| 406 |
@staticmethod
|
| 407 |
def _format_nearby_result(stations: List[Dict]) -> str:
|
| 408 |
"""格式化附近站點結果"""
|
features/mcp_config.json
CHANGED
|
@@ -24,36 +24,6 @@
|
|
| 24 |
"protocol_version": "2024-11-05"
|
| 25 |
},
|
| 26 |
"tools": {
|
| 27 |
-
"weather_query": {
|
| 28 |
-
"name": "weather_query",
|
| 29 |
-
"description": "查詢指定城市的天氣資訊,支援城市名稱或座標查詢",
|
| 30 |
-
"category": "daily_life",
|
| 31 |
-
"examples": ["台北天氣", "東京天氣", "25.0330,121.5654"],
|
| 32 |
-
"module": "features.mcp.tools.weather_tool",
|
| 33 |
-
"class": "WeatherTool"
|
| 34 |
-
},
|
| 35 |
-
"news_query": {
|
| 36 |
-
"name": "news_query",
|
| 37 |
-
"description": "查詢最新新聞資訊,支援關鍵詞搜尋和分類篩選,可獲取完整文章內容",
|
| 38 |
-
"category": "daily_life",
|
| 39 |
-
"examples": ["科技新聞", "今天新聞", "台灣新聞", "完整內容新聞"],
|
| 40 |
-
"module": "features.mcp.tools.news_tool",
|
| 41 |
-
"class": "NewsTool"
|
| 42 |
-
},
|
| 43 |
-
"exchange_query": {
|
| 44 |
-
"name": "exchange_query",
|
| 45 |
-
"description": "查詢匯率資訊,支援即時匯率查詢和貨幣轉換計算",
|
| 46 |
-
"category": "daily_life",
|
| 47 |
-
"examples": ["美元對台幣", "100 USD 換 TWD", "EUR USD 匯率"]
|
| 48 |
-
},
|
| 49 |
-
"healthkit_query": {
|
| 50 |
-
"name": "healthkit_query",
|
| 51 |
-
"description": "查詢 HealthKit 健康數據,包括心率、步數、血氧、呼吸頻率等",
|
| 52 |
-
"category": "health",
|
| 53 |
-
"examples": ["我的心率", "今天步數", "最近一週健康數據", "血氧濃度"],
|
| 54 |
-
"module": "features.mcp.tools.healthkit_tool",
|
| 55 |
-
"class": "HealthKitTool"
|
| 56 |
-
},
|
| 57 |
"system_list_features": {
|
| 58 |
"name": "system_list_features",
|
| 59 |
"description": "列出所有可用的 MCP 功能",
|
|
@@ -65,12 +35,63 @@
|
|
| 65 |
"description": "檢查 MCP 服務器健康狀態",
|
| 66 |
"category": "system",
|
| 67 |
"examples": ["健康檢查", "服務狀態"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
}
|
| 69 |
},
|
| 70 |
"environment": {
|
| 71 |
"required_env_vars": [
|
| 72 |
"WEATHER_API_KEY",
|
| 73 |
-
"NEWSAPI_KEY"
|
|
|
|
|
|
|
|
|
|
| 74 |
],
|
| 75 |
"optional_env_vars": [
|
| 76 |
"FIXER_API_KEY",
|
|
|
|
| 24 |
"protocol_version": "2024-11-05"
|
| 25 |
},
|
| 26 |
"tools": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
"system_list_features": {
|
| 28 |
"name": "system_list_features",
|
| 29 |
"description": "列出所有可用的 MCP 功能",
|
|
|
|
| 35 |
"description": "檢查 MCP 服務器健康狀態",
|
| 36 |
"category": "system",
|
| 37 |
"examples": ["健康檢查", "服務狀態"]
|
| 38 |
+
},
|
| 39 |
+
"tdx_bus_arrival": {
|
| 40 |
+
"name": "tdx_bus_arrival",
|
| 41 |
+
"description": "查詢公車即時到站時間(自動感知用戶位置,找最近站點)",
|
| 42 |
+
"category": "transportation",
|
| 43 |
+
"examples": ["307 公車還要多久", "附近有什麼公車"],
|
| 44 |
+
"module": "features.mcp.tools.tdx_bus_arrival",
|
| 45 |
+
"class": "TDXBusArrivalTool"
|
| 46 |
+
},
|
| 47 |
+
"tdx_metro": {
|
| 48 |
+
"name": "tdx_metro",
|
| 49 |
+
"description": "查詢捷運即時到站、最近車站(台北/高雄/桃園/台中捷運)",
|
| 50 |
+
"category": "transportation",
|
| 51 |
+
"examples": ["最近的捷運站在哪", "台北車站捷運幾分鐘到"],
|
| 52 |
+
"module": "features.mcp.tools.tdx_metro",
|
| 53 |
+
"class": "TDXMetroTool"
|
| 54 |
+
},
|
| 55 |
+
"tdx_parking": {
|
| 56 |
+
"name": "tdx_parking",
|
| 57 |
+
"description": "查詢附近停車場資訊和即時空位",
|
| 58 |
+
"category": "transportation",
|
| 59 |
+
"examples": ["附近停車場", "台北車站附近停車位"],
|
| 60 |
+
"module": "features.mcp.tools.tdx_parking",
|
| 61 |
+
"class": "TDXParkingTool"
|
| 62 |
+
},
|
| 63 |
+
"tdx_thsr": {
|
| 64 |
+
"name": "tdx_thsr",
|
| 65 |
+
"description": "查詢高鐵時刻表、票價和即時資訊",
|
| 66 |
+
"category": "transportation",
|
| 67 |
+
"examples": ["高鐵從台北到台中", "高鐵票價查詢"],
|
| 68 |
+
"module": "features.mcp.tools.tdx_thsr",
|
| 69 |
+
"class": "TDXTHSRTool"
|
| 70 |
+
},
|
| 71 |
+
"tdx_train": {
|
| 72 |
+
"name": "tdx_train",
|
| 73 |
+
"description": "查詢台鐵時刻表和即時資訊",
|
| 74 |
+
"category": "transportation",
|
| 75 |
+
"examples": ["台鐵從台北到新竹", "火車時刻表"],
|
| 76 |
+
"module": "features.mcp.tools.tdx_train",
|
| 77 |
+
"class": "TDXTrainTool"
|
| 78 |
+
},
|
| 79 |
+
"tdx_youbike": {
|
| 80 |
+
"name": "tdx_youbike",
|
| 81 |
+
"description": "查詢 YouBike 站點資訊和即時車輛數量",
|
| 82 |
+
"category": "transportation",
|
| 83 |
+
"examples": ["附近 YouBike", "捷運站 YouBike 數量"],
|
| 84 |
+
"module": "features.mcp.tools.tdx_youbike",
|
| 85 |
+
"class": "TDXBikeTool"
|
| 86 |
}
|
| 87 |
},
|
| 88 |
"environment": {
|
| 89 |
"required_env_vars": [
|
| 90 |
"WEATHER_API_KEY",
|
| 91 |
+
"NEWSAPI_KEY",
|
| 92 |
+
"TDX_CLIENT_ID",
|
| 93 |
+
"TDX_CLIENT_SECRET",
|
| 94 |
+
"OPENROUTESERVICE_API_KEY"
|
| 95 |
],
|
| 96 |
"optional_env_vars": [
|
| 97 |
"FIXER_API_KEY",
|
services/ai_service.py
CHANGED
|
@@ -149,6 +149,18 @@ def _format_history_for_prompt(history: List[Dict[str, str]]) -> str:
|
|
| 149 |
return "\n".join(lines) if lines else "(無)"
|
| 150 |
|
| 151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
def _format_env_context(ctx: Dict[str, Any]) -> str:
|
| 153 |
"""將環境資訊整理成可讀文字,確保 AI 能掌握使用者所在位置(精確到路口、門牌號)。"""
|
| 154 |
if not ctx:
|
|
@@ -157,9 +169,9 @@ def _format_env_context(ctx: Dict[str, Any]) -> str:
|
|
| 157 |
parts: List[str] = []
|
| 158 |
|
| 159 |
# 優先顯示詳細地址(最重要)
|
| 160 |
-
detailed_address = (ctx.get("detailed_address")
|
| 161 |
-
label = (ctx.get("label")
|
| 162 |
-
address_display = (ctx.get("address_display")
|
| 163 |
|
| 164 |
if detailed_address:
|
| 165 |
parts.append(f"📍 精確位置:\n{detailed_address}")
|
|
@@ -169,9 +181,9 @@ def _format_env_context(ctx: Dict[str, Any]) -> str:
|
|
| 169 |
parts.append(f"📍 當前位置: {address_display}")
|
| 170 |
|
| 171 |
# 如果有門牌資訊,額外強調
|
| 172 |
-
road = (ctx.get("road")
|
| 173 |
-
house_number = (ctx.get("house_number")
|
| 174 |
-
postcode = (ctx.get("postcode")
|
| 175 |
|
| 176 |
if road and house_number and not detailed_address:
|
| 177 |
address_line = f"{road}{house_number}號"
|
|
@@ -180,10 +192,10 @@ def _format_env_context(ctx: Dict[str, Any]) -> str:
|
|
| 180 |
parts.append(f"門牌地址: {address_line}")
|
| 181 |
|
| 182 |
# 區域資訊(如果沒有在 detailed_address 中顯示)
|
| 183 |
-
city_district = (ctx.get("city_district")
|
| 184 |
-
suburb = (ctx.get("suburb")
|
| 185 |
-
city = (ctx.get("city")
|
| 186 |
-
admin = (ctx.get("admin")
|
| 187 |
|
| 188 |
if not detailed_address:
|
| 189 |
if city_district:
|
|
@@ -206,7 +218,7 @@ def _format_env_context(ctx: Dict[str, Any]) -> str:
|
|
| 206 |
lat_f = float(lat)
|
| 207 |
lon_f = float(lon)
|
| 208 |
coord_text = f"緯度 {lat_f:.6f}, 經度 {lon_f:.6f}"
|
| 209 |
-
geohash = (ctx.get("geohash_7")
|
| 210 |
if geohash:
|
| 211 |
parts.append(f"座標: {coord_text}(Geohash {geohash})")
|
| 212 |
else:
|
|
@@ -215,9 +227,9 @@ def _format_env_context(ctx: Dict[str, Any]) -> str:
|
|
| 215 |
pass
|
| 216 |
|
| 217 |
# POI 資訊(如果是特殊地點)
|
| 218 |
-
amenity = (ctx.get("amenity")
|
| 219 |
-
shop = (ctx.get("shop")
|
| 220 |
-
building = (ctx.get("building")
|
| 221 |
|
| 222 |
poi_info = []
|
| 223 |
if amenity:
|
|
@@ -230,13 +242,13 @@ def _format_env_context(ctx: Dict[str, Any]) -> str:
|
|
| 230 |
if poi_info:
|
| 231 |
parts.append(" | ".join(poi_info))
|
| 232 |
|
| 233 |
-
tz = (ctx.get("tz")
|
| 234 |
if tz:
|
| 235 |
parts.append(f"時區: {tz}")
|
| 236 |
|
| 237 |
heading = ctx.get("heading_cardinal") or ctx.get("heading_deg")
|
| 238 |
if heading is not None:
|
| 239 |
-
parts.append(f"方位: {heading}")
|
| 240 |
|
| 241 |
acc = ctx.get("accuracy_m")
|
| 242 |
try:
|
|
@@ -245,11 +257,11 @@ def _format_env_context(ctx: Dict[str, Any]) -> str:
|
|
| 245 |
except (ValueError, TypeError):
|
| 246 |
pass
|
| 247 |
|
| 248 |
-
locale = (ctx.get("locale")
|
| 249 |
if locale:
|
| 250 |
parts.append(f"語系: {locale}")
|
| 251 |
|
| 252 |
-
device = (ctx.get("device")
|
| 253 |
if device:
|
| 254 |
parts.append(f"裝置: {device}")
|
| 255 |
|
|
|
|
| 149 |
return "\n".join(lines) if lines else "(無)"
|
| 150 |
|
| 151 |
|
| 152 |
+
def _safe_str(val: Any) -> str:
|
| 153 |
+
"""安全地將任意值轉換為字串,避免對 dict 調用 .strip() 導致錯誤"""
|
| 154 |
+
if val is None:
|
| 155 |
+
return ""
|
| 156 |
+
if isinstance(val, str):
|
| 157 |
+
return val.strip()
|
| 158 |
+
if isinstance(val, dict):
|
| 159 |
+
# dict 可能是嵌套的環境資訊,嘗試提取常見欄位
|
| 160 |
+
return str(val.get("message") or val.get("text") or val.get("value") or "").strip()
|
| 161 |
+
return str(val).strip()
|
| 162 |
+
|
| 163 |
+
|
| 164 |
def _format_env_context(ctx: Dict[str, Any]) -> str:
|
| 165 |
"""將環境資訊整理成可讀文字,確保 AI 能掌握使用者所在位置(精確到路口、門牌號)。"""
|
| 166 |
if not ctx:
|
|
|
|
| 169 |
parts: List[str] = []
|
| 170 |
|
| 171 |
# 優先顯示詳細地址(最重要)
|
| 172 |
+
detailed_address = _safe_str(ctx.get("detailed_address"))
|
| 173 |
+
label = _safe_str(ctx.get("label"))
|
| 174 |
+
address_display = _safe_str(ctx.get("address_display"))
|
| 175 |
|
| 176 |
if detailed_address:
|
| 177 |
parts.append(f"📍 精確位置:\n{detailed_address}")
|
|
|
|
| 181 |
parts.append(f"📍 當前位置: {address_display}")
|
| 182 |
|
| 183 |
# 如果有門牌資訊,額外強調
|
| 184 |
+
road = _safe_str(ctx.get("road"))
|
| 185 |
+
house_number = _safe_str(ctx.get("house_number"))
|
| 186 |
+
postcode = _safe_str(ctx.get("postcode"))
|
| 187 |
|
| 188 |
if road and house_number and not detailed_address:
|
| 189 |
address_line = f"{road}{house_number}號"
|
|
|
|
| 192 |
parts.append(f"門牌地址: {address_line}")
|
| 193 |
|
| 194 |
# 區域資訊(如果沒有在 detailed_address 中顯示)
|
| 195 |
+
city_district = _safe_str(ctx.get("city_district"))
|
| 196 |
+
suburb = _safe_str(ctx.get("suburb"))
|
| 197 |
+
city = _safe_str(ctx.get("city"))
|
| 198 |
+
admin = _safe_str(ctx.get("admin"))
|
| 199 |
|
| 200 |
if not detailed_address:
|
| 201 |
if city_district:
|
|
|
|
| 218 |
lat_f = float(lat)
|
| 219 |
lon_f = float(lon)
|
| 220 |
coord_text = f"緯度 {lat_f:.6f}, 經度 {lon_f:.6f}"
|
| 221 |
+
geohash = _safe_str(ctx.get("geohash_7"))
|
| 222 |
if geohash:
|
| 223 |
parts.append(f"座標: {coord_text}(Geohash {geohash})")
|
| 224 |
else:
|
|
|
|
| 227 |
pass
|
| 228 |
|
| 229 |
# POI 資訊(如果是特殊地點)
|
| 230 |
+
amenity = _safe_str(ctx.get("amenity"))
|
| 231 |
+
shop = _safe_str(ctx.get("shop"))
|
| 232 |
+
building = _safe_str(ctx.get("building"))
|
| 233 |
|
| 234 |
poi_info = []
|
| 235 |
if amenity:
|
|
|
|
| 242 |
if poi_info:
|
| 243 |
parts.append(" | ".join(poi_info))
|
| 244 |
|
| 245 |
+
tz = _safe_str(ctx.get("tz"))
|
| 246 |
if tz:
|
| 247 |
parts.append(f"時區: {tz}")
|
| 248 |
|
| 249 |
heading = ctx.get("heading_cardinal") or ctx.get("heading_deg")
|
| 250 |
if heading is not None:
|
| 251 |
+
parts.append(f"方位: {_safe_str(heading)}")
|
| 252 |
|
| 253 |
acc = ctx.get("accuracy_m")
|
| 254 |
try:
|
|
|
|
| 257 |
except (ValueError, TypeError):
|
| 258 |
pass
|
| 259 |
|
| 260 |
+
locale = _safe_str(ctx.get("locale"))
|
| 261 |
if locale:
|
| 262 |
parts.append(f"語系: {locale}")
|
| 263 |
|
| 264 |
+
device = _safe_str(ctx.get("device"))
|
| 265 |
if device:
|
| 266 |
parts.append(f"裝置: {device}")
|
| 267 |
|
static/frontend/index.html
CHANGED
|
@@ -5,6 +5,7 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://accounts.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; connect-src 'self' ws: wss: https://accounts.google.com; img-src 'self' data: https: blob:; media-src 'self' blob: data:; frame-src https://accounts.google.com; base-uri 'self';">
|
| 7 |
<title>Bloom Ware 語音沉浸式 - 光暈花瓣版</title>
|
|
|
|
| 8 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 9 |
<style>
|
| 10 |
* {
|
|
@@ -15,23 +16,13 @@
|
|
| 15 |
|
| 16 |
body {
|
| 17 |
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 18 |
-
background: #
|
| 19 |
color: #1A1A1A;
|
| 20 |
overflow: hidden;
|
| 21 |
-webkit-font-smoothing: antialiased;
|
| 22 |
-
/*
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
@keyframes pageEnter {
|
| 27 |
-
from {
|
| 28 |
-
opacity: 0;
|
| 29 |
-
transform: scale(1.02);
|
| 30 |
-
}
|
| 31 |
-
to {
|
| 32 |
-
opacity: 1;
|
| 33 |
-
transform: scale(1);
|
| 34 |
-
}
|
| 35 |
}
|
| 36 |
|
| 37 |
/* === 控制面板 === */
|
|
@@ -124,40 +115,48 @@
|
|
| 124 |
/* === 沉浸式覆蓋層 === */
|
| 125 |
.voice-immersive-overlay {
|
| 126 |
position: fixed;
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
z-index: 100;
|
| 129 |
display: flex;
|
| 130 |
flex-direction: column;
|
| 131 |
align-items: center;
|
| 132 |
justify-content: center;
|
| 133 |
background: #F5F1ED;
|
| 134 |
-
/* 初始載入動畫 */
|
| 135 |
-
animation: overlayEnter 0.8s cubic-bezier(0.4, 0, 0.2, 1) 0.2s backwards;
|
| 136 |
-
}
|
| 137 |
-
|
| 138 |
-
@keyframes overlayEnter {
|
| 139 |
-
from {
|
| 140 |
-
opacity: 0;
|
| 141 |
-
}
|
| 142 |
-
to {
|
| 143 |
-
opacity: 1;
|
| 144 |
-
}
|
| 145 |
}
|
| 146 |
|
| 147 |
/* === 動態漸變背景(移除光暈,改用極淺色情緒底色)=== */
|
| 148 |
.voice-immersive-background {
|
| 149 |
-
position:
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
opacity: 0;
|
| 152 |
transition: opacity 1.2s cubic-bezier(0.4, 0, 0.2, 1);
|
| 153 |
-
z-index:
|
|
|
|
| 154 |
}
|
| 155 |
|
| 156 |
.voice-immersive-background::before {
|
| 157 |
content: '';
|
| 158 |
position: absolute;
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
background: var(--emotion-bg);
|
|
|
|
| 161 |
}
|
| 162 |
|
| 163 |
.voice-immersive-background.active {
|
|
@@ -166,60 +165,35 @@
|
|
| 166 |
|
| 167 |
/* 情緒淺色底色變體 */
|
| 168 |
.emotion-neutral {
|
| 169 |
-
--emotion-bg: linear-gradient(180deg, #E0F2FE 0%, #
|
| 170 |
--petal-color: #FFFFFF;
|
| 171 |
}
|
| 172 |
|
| 173 |
.emotion-happy {
|
| 174 |
-
--emotion-bg: linear-gradient(180deg, #FEF3C7 0%, #
|
| 175 |
--petal-color: #FFFFFF;
|
| 176 |
}
|
| 177 |
|
| 178 |
.emotion-sad {
|
| 179 |
-
--emotion-bg: linear-gradient(180deg, #E0E7FF 0%, #
|
| 180 |
--petal-color: #FFFFFF;
|
| 181 |
}
|
| 182 |
|
| 183 |
.emotion-angry {
|
| 184 |
-
--emotion-bg: linear-gradient(180deg, #FEE2E2 0%, #
|
| 185 |
--petal-color: #FFFFFF;
|
| 186 |
}
|
| 187 |
|
| 188 |
.emotion-fear {
|
| 189 |
-
--emotion-bg: linear-gradient(180deg, #EDE9FE 0%, #
|
| 190 |
--petal-color: #FFFFFF;
|
| 191 |
}
|
| 192 |
|
| 193 |
.emotion-surprise {
|
| 194 |
-
--emotion-bg: linear-gradient(180deg, #FFEDD5 0%, #
|
| 195 |
--petal-color: #FFFFFF;
|
| 196 |
}
|
| 197 |
|
| 198 |
-
/* 情緒背景透明度(根據情緒強度分別調整)*/
|
| 199 |
-
.emotion-neutral .voice-immersive-background::before {
|
| 200 |
-
opacity: 0.2;
|
| 201 |
-
}
|
| 202 |
-
|
| 203 |
-
.emotion-happy .voice-immersive-background::before {
|
| 204 |
-
opacity: 0.25;
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
-
.emotion-sad .voice-immersive-background::before {
|
| 208 |
-
opacity: 0.35;
|
| 209 |
-
}
|
| 210 |
-
|
| 211 |
-
.emotion-angry .voice-immersive-background::before {
|
| 212 |
-
opacity: 0.4;
|
| 213 |
-
}
|
| 214 |
-
|
| 215 |
-
.emotion-fear .voice-immersive-background::before {
|
| 216 |
-
opacity: 0.35;
|
| 217 |
-
}
|
| 218 |
-
|
| 219 |
-
.emotion-surprise .voice-immersive-background::before {
|
| 220 |
-
opacity: 0.25;
|
| 221 |
-
}
|
| 222 |
-
|
| 223 |
/* === 中央容器 === */
|
| 224 |
.voice-center-container {
|
| 225 |
position: relative;
|
|
@@ -227,22 +201,7 @@
|
|
| 227 |
display: flex;
|
| 228 |
flex-direction: column;
|
| 229 |
align-items: center;
|
| 230 |
-
gap:
|
| 231 |
-
width: min(88vw, 640px);
|
| 232 |
-
padding: clamp(12px, 3vh, 28px) 0;
|
| 233 |
-
/* 元素依序淡入動畫 */
|
| 234 |
-
animation: containerEnter 0.8s cubic-bezier(0.4, 0, 0.2, 1) 0.4s backwards;
|
| 235 |
-
}
|
| 236 |
-
|
| 237 |
-
@keyframes containerEnter {
|
| 238 |
-
from {
|
| 239 |
-
opacity: 0;
|
| 240 |
-
transform: translateY(20px);
|
| 241 |
-
}
|
| 242 |
-
to {
|
| 243 |
-
opacity: 1;
|
| 244 |
-
transform: translateY(0);
|
| 245 |
-
}
|
| 246 |
}
|
| 247 |
|
| 248 |
/* === 波形與麥克風容器 === */
|
|
@@ -253,19 +212,6 @@
|
|
| 253 |
display: flex;
|
| 254 |
align-items: center;
|
| 255 |
justify-content: center;
|
| 256 |
-
/* 蓮花綻放入場動畫 */
|
| 257 |
-
animation: lotusBloom 1.2s cubic-bezier(0.34, 1.56, 0.64, 1) 0.6s backwards;
|
| 258 |
-
}
|
| 259 |
-
|
| 260 |
-
@keyframes lotusBloom {
|
| 261 |
-
from {
|
| 262 |
-
opacity: 0;
|
| 263 |
-
transform: scale(0.8) rotate(-10deg);
|
| 264 |
-
}
|
| 265 |
-
to {
|
| 266 |
-
opacity: 1;
|
| 267 |
-
transform: scale(1) rotate(0deg);
|
| 268 |
-
}
|
| 269 |
}
|
| 270 |
|
| 271 |
/* Canvas 波形 */
|
|
@@ -410,20 +356,20 @@
|
|
| 410 |
z-index: 10;
|
| 411 |
}
|
| 412 |
|
| 413 |
-
/*
|
| 414 |
.bloom-petal:nth-child(n+9)::before {
|
| 415 |
-
background:
|
| 416 |
-
radial-gradient(ellipse 80% 60% at 50% 30%,
|
| 417 |
-
rgba(255, 255, 255, 0.
|
| 418 |
-
rgba(
|
| 419 |
-
rgba(
|
| 420 |
-
rgba(
|
| 421 |
),
|
| 422 |
-
linear-gradient(180deg,
|
| 423 |
-
rgba(255, 255, 255, 0.
|
| 424 |
-
rgba(
|
| 425 |
-
rgba(
|
| 426 |
-
rgba(
|
| 427 |
);
|
| 428 |
}
|
| 429 |
|
|
@@ -452,13 +398,9 @@
|
|
| 452 |
inset -3px 4px 10px rgba(0, 0, 0, 0.08),
|
| 453 |
inset 3px -4px 12px rgba(255, 255, 255, 0.95),
|
| 454 |
inset 0 -2px 6px rgba(0, 0, 0, 0.04);
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
/* 極細的內描邊,讓邊界更清晰但不改變花瓣填色 */
|
| 459 |
-
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.03);
|
| 460 |
-
/* 輕微陰影,用於增加邊緣辨識度但避免過暗 */
|
| 461 |
-
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.05));
|
| 462 |
}
|
| 463 |
|
| 464 |
/* 花瓣中心脈絡(細緻葉脈,非綠莖)*/
|
|
@@ -867,43 +809,23 @@
|
|
| 867 |
animation: petalSpeakingInner 3s ease-in-out infinite 0.3s;
|
| 868 |
}
|
| 869 |
|
| 870 |
-
/* 外層花瓣動畫:完全綻放 +
|
| 871 |
@keyframes petalSpeakingOuter {
|
| 872 |
0%, 100% {
|
| 873 |
transform: translate(-50%, -50%) rotateZ(var(--speaking-angle, 0deg)) rotateX(0deg) translateY(-30px) scale(1);
|
| 874 |
-
opacity: 1;
|
| 875 |
-
}
|
| 876 |
-
25% {
|
| 877 |
-
transform: translate(-50%, -50%) rotateZ(var(--speaking-angle, 0deg)) rotateX(-3deg) translateY(-35px) scale(1.06);
|
| 878 |
-
opacity: 0.98;
|
| 879 |
}
|
| 880 |
50% {
|
| 881 |
transform: translate(-50%, -50%) rotateZ(var(--speaking-angle, 0deg)) rotateX(0deg) translateY(-33px) scale(1.03);
|
| 882 |
-
opacity: 1;
|
| 883 |
-
}
|
| 884 |
-
75% {
|
| 885 |
-
transform: translate(-50%, -50%) rotateZ(var(--speaking-angle, 0deg)) rotateX(3deg) translateY(-28px) scale(0.98);
|
| 886 |
-
opacity: 0.96;
|
| 887 |
}
|
| 888 |
}
|
| 889 |
|
| 890 |
-
/* 內層花瓣動畫:略微抬起 +
|
| 891 |
@keyframes petalSpeakingInner {
|
| 892 |
0%, 100% {
|
| 893 |
transform: translate(-50%, -50%) rotateZ(var(--speaking-angle-inner, 0deg)) rotateX(10deg) translateY(-28px) scale(1.05);
|
| 894 |
-
opacity: 1;
|
| 895 |
-
}
|
| 896 |
-
25% {
|
| 897 |
-
transform: translate(-50%, -50%) rotateZ(var(--speaking-angle-inner, 0deg)) rotateX(8deg) translateY(-25px) scale(1.02);
|
| 898 |
-
opacity: 0.97;
|
| 899 |
}
|
| 900 |
50% {
|
| 901 |
-
transform: translate(-50%, -50%) rotateZ(var(--speaking-angle-inner, 0deg)) rotateX(
|
| 902 |
-
opacity: 1;
|
| 903 |
-
}
|
| 904 |
-
75% {
|
| 905 |
-
transform: translate(-50%, -50%) rotateZ(var(--speaking-angle-inner, 0deg)) rotateX(10deg) translateY(-26px) scale(1.04);
|
| 906 |
-
opacity: 0.98;
|
| 907 |
}
|
| 908 |
}
|
| 909 |
|
|
@@ -934,50 +856,23 @@
|
|
| 934 |
inset 2px -3px 12px rgba(255, 255, 255, 0.95);
|
| 935 |
}
|
| 936 |
|
| 937 |
-
/*
|
| 938 |
.voice-mic-container.speaking .bloom-core {
|
| 939 |
animation: speakingCoreBreath 2.4s ease-in-out infinite;
|
| 940 |
-
box-shadow:
|
| 941 |
-
0 0 20px rgba(245, 158, 11, 0.4),
|
| 942 |
-
0 0 35px rgba(245, 158, 11, 0.25),
|
| 943 |
-
inset 0 2px 8px rgba(255, 255, 255, 0.5);
|
| 944 |
}
|
| 945 |
|
| 946 |
@keyframes speakingCoreBreath {
|
| 947 |
0%, 100% {
|
| 948 |
transform: translate(-50%, -50%) scale(1);
|
| 949 |
-
box-shadow:
|
| 950 |
-
0 0 20px rgba(245, 158, 11, 0.4),
|
| 951 |
-
0 0 35px rgba(245, 158, 11, 0.25),
|
| 952 |
-
inset 0 2px 8px rgba(255, 255, 255, 0.5);
|
| 953 |
-
}
|
| 954 |
-
25% {
|
| 955 |
-
transform: translate(-50%, -50%) scale(1.12);
|
| 956 |
-
box-shadow:
|
| 957 |
-
0 0 30px rgba(245, 158, 11, 0.6),
|
| 958 |
-
0 0 50px rgba(245, 158, 11, 0.4),
|
| 959 |
-
inset 0 2px 12px rgba(255, 255, 255, 0.7);
|
| 960 |
}
|
| 961 |
50% {
|
| 962 |
transform: translate(-50%, -50%) scale(1.08);
|
| 963 |
-
box-shadow:
|
| 964 |
-
0 0 25px rgba(245, 158, 11, 0.5),
|
| 965 |
-
0 0 40px rgba(245, 158, 11, 0.3),
|
| 966 |
-
inset 0 2px 10px rgba(255, 255, 255, 0.6);
|
| 967 |
-
}
|
| 968 |
-
75% {
|
| 969 |
-
transform: translate(-50%, -50%) scale(1.05);
|
| 970 |
-
box-shadow:
|
| 971 |
-
0 0 22px rgba(245, 158, 11, 0.45),
|
| 972 |
-
0 0 38px rgba(245, 158, 11, 0.28),
|
| 973 |
-
inset 0 2px 9px rgba(255, 255, 255, 0.55);
|
| 974 |
}
|
| 975 |
}
|
| 976 |
|
| 977 |
|
| 978 |
/* === Agent 文字輸出區域(打字機效果)=== */
|
| 979 |
.voice-agent-output {
|
| 980 |
-
width: 100%;
|
| 981 |
max-width: 600px;
|
| 982 |
padding: 20px 28px;
|
| 983 |
background: rgba(255, 255, 255, 0.98);
|
|
@@ -990,22 +885,18 @@
|
|
| 990 |
text-align: left;
|
| 991 |
line-height: 1.7;
|
| 992 |
min-height: 60px;
|
| 993 |
-
max-height: 180px; /* 固定最大高度約 4-5 行文字 */
|
| 994 |
-
overflow-y: auto; /* 超出時顯示滾動條 */
|
| 995 |
box-shadow: 0 6px 28px rgba(0, 0, 0, 0.1);
|
| 996 |
font-family: 'Inter', -apple-system, sans-serif;
|
|
|
|
| 997 |
opacity: 0;
|
| 998 |
-
transform: translateY(
|
| 999 |
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1000 |
-
visibility: hidden;
|
| 1001 |
-
pointer-events: none;
|
| 1002 |
}
|
| 1003 |
|
| 1004 |
.voice-agent-output.active {
|
|
|
|
| 1005 |
opacity: 1;
|
| 1006 |
transform: translateY(0);
|
| 1007 |
-
visibility: visible;
|
| 1008 |
-
pointer-events: auto;
|
| 1009 |
}
|
| 1010 |
|
| 1011 |
/* 打字游標效果 */
|
|
@@ -1026,125 +917,66 @@
|
|
| 1026 |
}
|
| 1027 |
|
| 1028 |
/* === 實時字幕 / 文字輸入框 === */
|
| 1029 |
-
.voice-transcript-wrapper {
|
| 1030 |
-
position: relative;
|
| 1031 |
-
width: 100%;
|
| 1032 |
-
max-width: 520px; /* 優化:縮小最大寬度(680px → 520px,減少約 24%)*/
|
| 1033 |
-
margin: 0 auto; /* 置中對齊 */
|
| 1034 |
-
padding: 0 16px; /* 優化:減少左右留白(20px → 16px)*/
|
| 1035 |
-
display: flex;
|
| 1036 |
-
justify-content: center;
|
| 1037 |
-
}
|
| 1038 |
-
|
| 1039 |
.voice-transcript {
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
-
border:
|
| 1045 |
-
|
| 1046 |
-
|
| 1047 |
-
font-size: 17px; /* 優化:稍微縮小字體(18px → 17px)*/
|
| 1048 |
font-weight: 400;
|
| 1049 |
color: #1A1A1A;
|
| 1050 |
text-align: center;
|
| 1051 |
-
line-height: 1.
|
| 1052 |
-
min-height:
|
| 1053 |
-
max-height: 130px; /* 優化:減少最大高度(140px → 130px)*/
|
| 1054 |
display: flex;
|
| 1055 |
align-items: center;
|
| 1056 |
justify-content: center;
|
| 1057 |
-
transition: all 0.3s
|
| 1058 |
-
box-shadow: 0
|
| 1059 |
-
outline: none;
|
| 1060 |
-
resize: none;
|
| 1061 |
-
overflow-y: auto;
|
| 1062 |
}
|
| 1063 |
|
| 1064 |
.voice-transcript.provisional {
|
| 1065 |
-
color: rgba(0, 0, 0, 0.
|
| 1066 |
font-style: italic;
|
| 1067 |
}
|
| 1068 |
|
| 1069 |
.voice-transcript.final {
|
| 1070 |
color: #1A1A1A;
|
| 1071 |
-
border-color: rgba(0, 0, 0, 0.
|
| 1072 |
-
box-shadow: 0
|
| 1073 |
}
|
| 1074 |
|
| 1075 |
/* 文字輸入模式 */
|
| 1076 |
.voice-transcript.text-input-mode {
|
| 1077 |
-
|
| 1078 |
-
|
| 1079 |
-
|
| 1080 |
-
|
| 1081 |
-
background: rgba(255, 255, 255, 0.98);
|
| 1082 |
-
white-space: pre-wrap;
|
| 1083 |
-
word-break: break-word;
|
| 1084 |
-
max-height: 130px; /* 優化:與基礎樣式保持一致 */
|
| 1085 |
}
|
| 1086 |
|
| 1087 |
-
.voice-transcript.text-input-mode
|
| 1088 |
-
|
| 1089 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1090 |
}
|
| 1091 |
|
| 1092 |
-
.voice-transcript.text-input-mode
|
| 1093 |
-
|
| 1094 |
-
color: rgba(0, 0, 0, 0.28); /* 稍微淡化(原 0.3)*/
|
| 1095 |
font-style: italic;
|
| 1096 |
}
|
| 1097 |
|
| 1098 |
-
/* 文字輸入模式下的提示文字 */
|
| 1099 |
-
.input-mode-hint {
|
| 1100 |
-
position: absolute;
|
| 1101 |
-
bottom: -36px; /* 調整位置(原 -40px)*/
|
| 1102 |
-
left: 50%;
|
| 1103 |
-
transform: translateX(-50%);
|
| 1104 |
-
font-size: 12px; /* 減小字體(原 13px)*/
|
| 1105 |
-
color: rgba(0, 0, 0, 0.38); /* 稍微提高對比(原 0.4)*/
|
| 1106 |
-
background: rgba(255, 255, 255, 0.92);
|
| 1107 |
-
padding: 6px 14px; /* 減少內距(原 8px 16px)*/
|
| 1108 |
-
border-radius: 10px; /* 減少圓角(原 12px)*/
|
| 1109 |
-
backdrop-filter: blur(10px);
|
| 1110 |
-
opacity: 0;
|
| 1111 |
-
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1112 |
-
pointer-events: none;
|
| 1113 |
-
white-space: nowrap;
|
| 1114 |
-
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); /* 添加微陰影 */
|
| 1115 |
-
}
|
| 1116 |
-
|
| 1117 |
-
.voice-transcript.text-input-mode + .input-mode-hint {
|
| 1118 |
-
opacity: 1;
|
| 1119 |
-
}
|
| 1120 |
-
|
| 1121 |
-
/* 文字輸入模式下確保波形容器保持可見 */
|
| 1122 |
-
.text-input-active .voice-waveform-container {
|
| 1123 |
-
opacity: 1 !important;
|
| 1124 |
-
}
|
| 1125 |
-
|
| 1126 |
-
/* 手機版優化 */
|
| 1127 |
-
@media (max-width: 768px) {
|
| 1128 |
-
.voice-transcript-wrapper {
|
| 1129 |
-
max-width: 100%; /* 手機版全寬 */
|
| 1130 |
-
padding: 0 14px; /* 優化:減少左右留白(16px → 14px)*/
|
| 1131 |
-
}
|
| 1132 |
-
|
| 1133 |
-
.voice-transcript {
|
| 1134 |
-
font-size: 16px; /* 手機版縮小字體 */
|
| 1135 |
-
padding: 15px 18px; /* 優化:減少內距(16px 20px → 15px 18px)*/
|
| 1136 |
-
min-height: 62px; /* 優化:減少最小高度(64px → 62px)*/
|
| 1137 |
-
max-height: 115px; /* 優化:減少最大高度(120px → 115px)*/
|
| 1138 |
-
border-radius: 11px; /* 優化:減少圓角(12px → 11px)*/
|
| 1139 |
-
}
|
| 1140 |
-
|
| 1141 |
-
.input-mode-hint {
|
| 1142 |
-
font-size: 11px;
|
| 1143 |
-
padding: 5px 12px;
|
| 1144 |
-
bottom: -32px;
|
| 1145 |
-
}
|
| 1146 |
-
}
|
| 1147 |
-
|
| 1148 |
/* === 工具卡片 === */
|
| 1149 |
.voice-tool-card {
|
| 1150 |
position: absolute;
|
|
@@ -1155,8 +987,6 @@
|
|
| 1155 |
backdrop-filter: blur(30px) saturate(180%);
|
| 1156 |
min-width: 280px;
|
| 1157 |
max-width: 360px;
|
| 1158 |
-
max-height: 400px; /* 固定最大高度,避免卡片過大 */
|
| 1159 |
-
overflow-y: auto; /* 內容超出時可滾動 */
|
| 1160 |
z-index: 40;
|
| 1161 |
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
| 1162 |
animation: cardEnter 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
|
@@ -1265,11 +1095,12 @@
|
|
| 1265 |
font-family: 'JetBrains Mono', monospace;
|
| 1266 |
}
|
| 1267 |
|
| 1268 |
-
/* ChatWindow
|
| 1269 |
.chat-icon {
|
| 1270 |
position: fixed;
|
| 1271 |
-
|
| 1272 |
-
|
|
|
|
| 1273 |
z-index: 101;
|
| 1274 |
width: 56px;
|
| 1275 |
height: 56px;
|
|
@@ -1283,8 +1114,7 @@
|
|
| 1283 |
backdrop-filter: blur(20px);
|
| 1284 |
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
| 1285 |
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1286 |
-
font-size:
|
| 1287 |
-
user-select: none;
|
| 1288 |
}
|
| 1289 |
|
| 1290 |
.chat-icon:hover {
|
|
@@ -1293,36 +1123,24 @@
|
|
| 1293 |
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
|
| 1294 |
}
|
| 1295 |
|
| 1296 |
-
/* 文字模式時的樣式 */
|
| 1297 |
-
.chat-icon.text-mode {
|
| 1298 |
-
background: rgba(59, 130, 246, 0.95);
|
| 1299 |
-
border-color: rgba(59, 130, 246, 0.3);
|
| 1300 |
-
}
|
| 1301 |
-
|
| 1302 |
-
.chat-icon.text-mode:hover {
|
| 1303 |
-
background: rgba(59, 130, 246, 1);
|
| 1304 |
-
box-shadow: 0 6px 24px rgba(59, 130, 246, 0.4);
|
| 1305 |
-
}
|
| 1306 |
-
|
| 1307 |
/* 退出按鈕 */
|
| 1308 |
.exit-button {
|
| 1309 |
position: absolute;
|
| 1310 |
-
top:
|
| 1311 |
-
right:
|
| 1312 |
z-index: 50;
|
| 1313 |
-
padding: 12px
|
| 1314 |
background: rgba(255, 255, 255, 0.95);
|
| 1315 |
border: 1px solid rgba(0, 0, 0, 0.08);
|
| 1316 |
-
border-radius:
|
| 1317 |
color: rgba(0, 0, 0, 0.6);
|
| 1318 |
font-family: 'Inter', sans-serif;
|
| 1319 |
font-size: 13px;
|
| 1320 |
font-weight: 500;
|
| 1321 |
cursor: pointer;
|
| 1322 |
transition: all 0.2s;
|
| 1323 |
-
backdrop-filter: blur(
|
| 1324 |
-
box-shadow: 0
|
| 1325 |
-
letter-spacing: 0.6px;
|
| 1326 |
}
|
| 1327 |
|
| 1328 |
.exit-button:hover {
|
|
@@ -1335,7 +1153,7 @@
|
|
| 1335 |
/* 情緒標籤 */
|
| 1336 |
.emotion-indicator {
|
| 1337 |
position: absolute;
|
| 1338 |
-
top: 40px;
|
| 1339 |
left: 50%;
|
| 1340 |
transform: translateX(-50%);
|
| 1341 |
z-index: 50;
|
|
@@ -1381,257 +1199,126 @@
|
|
| 1381 |
50% { opacity: 0.3; }
|
| 1382 |
}
|
| 1383 |
|
| 1384 |
-
/*
|
| 1385 |
-
|
| 1386 |
-
======================================== */
|
| 1387 |
-
|
| 1388 |
-
/* 工具卡片側邊欄(手機版) */
|
| 1389 |
-
.tool-sidebar {
|
| 1390 |
position: fixed;
|
| 1391 |
-
right: -100%;
|
| 1392 |
top: 0;
|
| 1393 |
-
|
| 1394 |
-
|
| 1395 |
height: 100vh;
|
| 1396 |
-
background: linear-gradient(
|
| 1397 |
-
|
| 1398 |
-
|
| 1399 |
-
border-radius: 28px 0 0 28px;
|
| 1400 |
-
box-shadow: -8px 0 32px rgba(15, 23, 42, 0.15);
|
| 1401 |
-
z-index: 300;
|
| 1402 |
-
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1403 |
-
overflow-y: auto;
|
| 1404 |
-
padding: 96px 28px 32px;
|
| 1405 |
-
display: none; /* 預設隱藏,手機版啟用 */
|
| 1406 |
-
flex-direction: column;
|
| 1407 |
-
gap: 24px;
|
| 1408 |
-
}
|
| 1409 |
-
|
| 1410 |
-
.tool-sidebar.active {
|
| 1411 |
-
right: 0;
|
| 1412 |
-
}
|
| 1413 |
-
|
| 1414 |
-
.tool-sidebar-toggle {
|
| 1415 |
-
position: fixed;
|
| 1416 |
-
top: 50%;
|
| 1417 |
-
right: 0;
|
| 1418 |
-
transform: translateY(-50%);
|
| 1419 |
-
z-index: 150;
|
| 1420 |
-
width: 24px;
|
| 1421 |
-
height: 60px;
|
| 1422 |
-
background: rgba(255, 255, 255, 0.9);
|
| 1423 |
-
border: 1px solid rgba(0, 0, 0, 0.08);
|
| 1424 |
-
border-right: none;
|
| 1425 |
-
border-radius: 12px 0 0 12px;
|
| 1426 |
-
display: none; /* 預設隱藏,手機版啟用 */
|
| 1427 |
align-items: center;
|
| 1428 |
justify-content: center;
|
| 1429 |
-
|
| 1430 |
-
box-shadow: -2px 2px 8px rgba(0, 0, 0, 0.1);
|
| 1431 |
-
font-size: 16px;
|
| 1432 |
-
font-weight: bold;
|
| 1433 |
-
color: rgba(0, 0, 0, 0.6);
|
| 1434 |
-
transition: all 0.2s;
|
| 1435 |
-
padding: 0;
|
| 1436 |
-
margin: 0;
|
| 1437 |
-
}
|
| 1438 |
-
|
| 1439 |
-
.tool-sidebar-header {
|
| 1440 |
-
display: flex;
|
| 1441 |
-
flex-direction: column;
|
| 1442 |
-
gap: 8px;
|
| 1443 |
-
padding-right: 32px;
|
| 1444 |
-
}
|
| 1445 |
-
|
| 1446 |
-
.tool-sidebar-title {
|
| 1447 |
-
font-size: 18px;
|
| 1448 |
-
font-weight: 600;
|
| 1449 |
-
color: #0f172a;
|
| 1450 |
-
letter-spacing: -0.3px;
|
| 1451 |
}
|
| 1452 |
|
| 1453 |
-
.
|
| 1454 |
-
|
| 1455 |
-
|
| 1456 |
-
|
| 1457 |
}
|
| 1458 |
|
| 1459 |
-
|
| 1460 |
-
|
| 1461 |
-
|
| 1462 |
-
|
| 1463 |
-
|
|
|
|
|
|
|
|
|
|
| 1464 |
}
|
| 1465 |
|
| 1466 |
-
.
|
|
|
|
|
|
|
|
|
|
| 1467 |
position: relative;
|
| 1468 |
-
width: 100%;
|
| 1469 |
-
min-width: unset;
|
| 1470 |
-
max-width: unset;
|
| 1471 |
-
border-radius: 18px;
|
| 1472 |
-
border: 1px solid rgba(15, 23, 42, 0.06);
|
| 1473 |
-
box-shadow: 0 12px 36px rgba(15, 23, 42, 0.12);
|
| 1474 |
-
padding: 22px 24px;
|
| 1475 |
-
}
|
| 1476 |
-
|
| 1477 |
-
.tool-sidebar .voice-tool-card .card-header {
|
| 1478 |
-
margin-bottom: 12px;
|
| 1479 |
-
padding-bottom: 12px;
|
| 1480 |
-
}
|
| 1481 |
-
|
| 1482 |
-
.tool-sidebar .voice-tool-card .card-content {
|
| 1483 |
-
font-size: 14px;
|
| 1484 |
-
}
|
| 1485 |
-
|
| 1486 |
-
.tool-sidebar-toggle:hover {
|
| 1487 |
-
background: rgba(255, 255, 255, 0.95);
|
| 1488 |
-
color: rgba(0, 0, 0, 0.8);
|
| 1489 |
-
width: 28px;
|
| 1490 |
}
|
| 1491 |
|
| 1492 |
-
.
|
| 1493 |
-
|
| 1494 |
-
|
|
|
|
|
|
|
| 1495 |
}
|
| 1496 |
|
| 1497 |
-
|
| 1498 |
-
|
|
|
|
| 1499 |
}
|
| 1500 |
|
| 1501 |
-
.
|
| 1502 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1503 |
}
|
| 1504 |
|
| 1505 |
-
|
| 1506 |
-
|
| 1507 |
-
|
|
|
|
|
|
|
| 1508 |
}
|
| 1509 |
|
| 1510 |
-
.
|
| 1511 |
-
|
| 1512 |
-
top: 20px;
|
| 1513 |
-
right: 20px;
|
| 1514 |
-
width: 40px;
|
| 1515 |
-
height: 40px;
|
| 1516 |
-
background: rgba(0, 0, 0, 0.05);
|
| 1517 |
-
border: none;
|
| 1518 |
-
border-radius: 50%;
|
| 1519 |
-
display: flex;
|
| 1520 |
align-items: center;
|
| 1521 |
justify-content: center;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1522 |
cursor: pointer;
|
| 1523 |
-
|
| 1524 |
-
|
| 1525 |
}
|
| 1526 |
|
| 1527 |
-
.
|
| 1528 |
-
background:
|
|
|
|
|
|
|
|
|
|
| 1529 |
}
|
| 1530 |
|
| 1531 |
-
|
| 1532 |
-
|
| 1533 |
-
|
| 1534 |
-
#tool-cards-container {
|
| 1535 |
-
display: none !important;
|
| 1536 |
-
}
|
| 1537 |
-
|
| 1538 |
-
.tool-sidebar {
|
| 1539 |
-
display: flex;
|
| 1540 |
-
gap: 20px;
|
| 1541 |
-
padding: 88px 24px 28px;
|
| 1542 |
-
}
|
| 1543 |
-
|
| 1544 |
-
.tool-sidebar-toggle {
|
| 1545 |
-
display: flex;
|
| 1546 |
-
}
|
| 1547 |
-
|
| 1548 |
-
/* 工具卡片在側邊欄內的樣式 */
|
| 1549 |
-
.tool-sidebar .voice-tool-card {
|
| 1550 |
-
position: static !important;
|
| 1551 |
-
width: 100% !important;
|
| 1552 |
-
transform: none !important;
|
| 1553 |
-
animation: none !important;
|
| 1554 |
-
margin-bottom: 0;
|
| 1555 |
-
}
|
| 1556 |
-
|
| 1557 |
-
/* 中央控制按鈕調整 */
|
| 1558 |
-
.voice-center-button {
|
| 1559 |
-
width: 120px !important;
|
| 1560 |
-
height: 120px !important;
|
| 1561 |
-
font-size: 48px !important;
|
| 1562 |
-
}
|
| 1563 |
-
|
| 1564 |
-
/* 歡迎文字調整 */
|
| 1565 |
-
.voice-welcome-message {
|
| 1566 |
-
font-size: 28px !important;
|
| 1567 |
-
}
|
| 1568 |
-
|
| 1569 |
-
/* 狀態文字調整 */
|
| 1570 |
-
.voice-status-text {
|
| 1571 |
-
font-size: 14px !important;
|
| 1572 |
-
}
|
| 1573 |
-
|
| 1574 |
-
/* 退出按鈕調整 */
|
| 1575 |
-
.exit-button {
|
| 1576 |
-
top: 18px;
|
| 1577 |
-
right: 24px;
|
| 1578 |
-
padding: 10px 18px;
|
| 1579 |
-
font-size: 12px;
|
| 1580 |
-
}
|
| 1581 |
-
|
| 1582 |
-
/* 聊天圖標調整 */
|
| 1583 |
-
.chat-icon {
|
| 1584 |
-
bottom: 30px;
|
| 1585 |
-
left: 30px;
|
| 1586 |
-
width: 48px;
|
| 1587 |
-
height: 48px;
|
| 1588 |
-
font-size: 20px;
|
| 1589 |
-
}
|
| 1590 |
}
|
| 1591 |
|
| 1592 |
-
|
| 1593 |
-
|
| 1594 |
-
|
| 1595 |
-
width: 100%;
|
| 1596 |
-
max-width: 100%;
|
| 1597 |
-
border-radius: 24px 24px 0 0;
|
| 1598 |
-
padding: 80px 20px 24px;
|
| 1599 |
-
}
|
| 1600 |
-
|
| 1601 |
-
.voice-center-button {
|
| 1602 |
-
width: 100px !important;
|
| 1603 |
-
height: 100px !important;
|
| 1604 |
-
font-size: 40px !important;
|
| 1605 |
-
}
|
| 1606 |
-
|
| 1607 |
-
.voice-welcome-message {
|
| 1608 |
-
font-size: 24px !important;
|
| 1609 |
-
padding: 0 20px;
|
| 1610 |
-
}
|
| 1611 |
-
|
| 1612 |
-
.voice-status-text {
|
| 1613 |
-
font-size: 13px !important;
|
| 1614 |
-
}
|
| 1615 |
-
|
| 1616 |
-
.exit-button {
|
| 1617 |
-
top: 14px;
|
| 1618 |
-
right: 20px;
|
| 1619 |
-
padding: 8px 14px;
|
| 1620 |
-
font-size: 11px;
|
| 1621 |
-
}
|
| 1622 |
-
|
| 1623 |
-
.chat-icon {
|
| 1624 |
-
bottom: 20px;
|
| 1625 |
-
left: 20px;
|
| 1626 |
-
width: 44px;
|
| 1627 |
-
height: 44px;
|
| 1628 |
-
font-size: 18px;
|
| 1629 |
-
}
|
| 1630 |
}
|
| 1631 |
-
|
| 1632 |
</style>
|
| 1633 |
</head>
|
| 1634 |
<body>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1635 |
<!-- 控制面板 -->
|
| 1636 |
<div class="control-panel">
|
| 1637 |
<h3>🎛️ 控制面板</h3>
|
|
@@ -1701,11 +1388,11 @@
|
|
| 1701 |
<!-- 情緒指示器 -->
|
| 1702 |
<div class="emotion-indicator" id="emotion-indicator">當前情緒: 😐 中性</div>
|
| 1703 |
|
| 1704 |
-
<!--
|
| 1705 |
-
<button class="exit-button"
|
| 1706 |
|
| 1707 |
-
<!-- ChatWindow
|
| 1708 |
-
<div class="chat-icon" id="
|
| 1709 |
|
| 1710 |
<!-- 中央容器 -->
|
| 1711 |
<div class="voice-center-container">
|
|
@@ -1745,38 +1432,14 @@
|
|
| 1745 |
<!-- Agent 文字輸出(打字機效果,位於花朵下方)-->
|
| 1746 |
<div class="voice-agent-output" id="agent-output"></div>
|
| 1747 |
|
| 1748 |
-
<!-- 實時字幕
|
| 1749 |
-
<div class="voice-transcript
|
| 1750 |
-
|
| 1751 |
-
class="voice-transcript text-input-mode provisional"
|
| 1752 |
-
id="transcript"
|
| 1753 |
-
contenteditable="false"
|
| 1754 |
-
data-placeholder="請說話...">
|
| 1755 |
-
請說話...
|
| 1756 |
-
</div>
|
| 1757 |
-
<div class="input-mode-hint">按 Enter 發送 | Shift+Enter 換行</div>
|
| 1758 |
</div>
|
| 1759 |
</div>
|
| 1760 |
|
| 1761 |
-
<!--
|
| 1762 |
<div id="tool-cards-container"></div>
|
| 1763 |
-
|
| 1764 |
-
<!-- 工具卡片側邊欄(手機版) -->
|
| 1765 |
-
<div class="tool-sidebar" id="tool-sidebar">
|
| 1766 |
-
<button class="tool-sidebar-close" onclick="toggleToolSidebar()">✕</button>
|
| 1767 |
-
<div class="tool-sidebar-header">
|
| 1768 |
-
<span class="tool-sidebar-title">工具總覽</span>
|
| 1769 |
-
<p class="tool-sidebar-subtitle">
|
| 1770 |
-
最近呼叫的智慧模組會集中顯示在此,方便快速重用或分享結果。
|
| 1771 |
-
</p>
|
| 1772 |
-
</div>
|
| 1773 |
-
<div id="tool-sidebar-cards"></div>
|
| 1774 |
-
</div>
|
| 1775 |
-
|
| 1776 |
-
<!-- 側邊欄切換按鈕(手機版) -->
|
| 1777 |
-
<button class="tool-sidebar-toggle" id="tool-sidebar-toggle" onclick="toggleToolSidebar()">
|
| 1778 |
-
‹
|
| 1779 |
-
</button>
|
| 1780 |
</div>
|
| 1781 |
|
| 1782 |
<!-- JavaScript 模組化引入 -->
|
|
@@ -1786,6 +1449,7 @@
|
|
| 1786 |
<script src="js/tools.js"></script>
|
| 1787 |
<script src="js/agent.js"></script>
|
| 1788 |
<script src="js/canvas.js"></script>
|
|
|
|
| 1789 |
<script src="js/websocket.js"></script>
|
| 1790 |
<script src="js/app.js"></script>
|
| 1791 |
</body>
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://accounts.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; connect-src 'self' ws: wss: https://accounts.google.com; img-src 'self' data: https: blob:; media-src 'self' blob: data:; frame-src https://accounts.google.com; base-uri 'self';">
|
| 7 |
<title>Bloom Ware 語音沉浸式 - 光暈花瓣版</title>
|
| 8 |
+
<link rel="icon" href="/login/icon.svg" type="image/svg+xml">
|
| 9 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 10 |
<style>
|
| 11 |
* {
|
|
|
|
| 16 |
|
| 17 |
body {
|
| 18 |
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 19 |
+
background: #F5F1ED;
|
| 20 |
color: #1A1A1A;
|
| 21 |
overflow: hidden;
|
| 22 |
-webkit-font-smoothing: antialiased;
|
| 23 |
+
/* 修復 2025 新版瀏覽器 viewport 高度問題 */
|
| 24 |
+
height: 100vh; /* fallback for older browsers */
|
| 25 |
+
height: 100svh; /* 使用 Small Viewport Height 確保固定高度 */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
/* === 控制面板 === */
|
|
|
|
| 115 |
/* === 沉浸式覆蓋層 === */
|
| 116 |
.voice-immersive-overlay {
|
| 117 |
position: fixed;
|
| 118 |
+
top: 0;
|
| 119 |
+
left: 0;
|
| 120 |
+
right: 0;
|
| 121 |
+
bottom: 0;
|
| 122 |
+
width: 100vw;
|
| 123 |
+
width: 100svw; /* Small Viewport Width */
|
| 124 |
+
height: 100vh; /* fallback */
|
| 125 |
+
height: 100svh; /* Small Viewport Height - 確保在行動裝置上固定可見 */
|
| 126 |
z-index: 100;
|
| 127 |
display: flex;
|
| 128 |
flex-direction: column;
|
| 129 |
align-items: center;
|
| 130 |
justify-content: center;
|
| 131 |
background: #F5F1ED;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
}
|
| 133 |
|
| 134 |
/* === 動態漸變背景(移除光暈,改用極淺色情緒底色)=== */
|
| 135 |
.voice-immersive-background {
|
| 136 |
+
position: fixed; /* 改為 fixed 確保覆蓋整個視窗 */
|
| 137 |
+
top: 0;
|
| 138 |
+
left: 0;
|
| 139 |
+
right: 0;
|
| 140 |
+
bottom: 0;
|
| 141 |
+
width: 100%;
|
| 142 |
+
height: 100%;
|
| 143 |
opacity: 0;
|
| 144 |
transition: opacity 1.2s cubic-bezier(0.4, 0, 0.2, 1);
|
| 145 |
+
z-index: -1; /* 改為 -1 確保在最底層 */
|
| 146 |
+
pointer-events: none; /* 不阻擋其他元素的點擊 */
|
| 147 |
}
|
| 148 |
|
| 149 |
.voice-immersive-background::before {
|
| 150 |
content: '';
|
| 151 |
position: absolute;
|
| 152 |
+
top: 0;
|
| 153 |
+
left: 0;
|
| 154 |
+
right: 0;
|
| 155 |
+
bottom: 0;
|
| 156 |
+
width: 100%;
|
| 157 |
+
height: 100%;
|
| 158 |
background: var(--emotion-bg);
|
| 159 |
+
opacity: 0.5; /* 提高透明度,從 0.15 增加到 0.5 */
|
| 160 |
}
|
| 161 |
|
| 162 |
.voice-immersive-background.active {
|
|
|
|
| 165 |
|
| 166 |
/* 情緒淺色底色變體 */
|
| 167 |
.emotion-neutral {
|
| 168 |
+
--emotion-bg: linear-gradient(180deg, #E0F2FE 0%, #F5F1ED 100%);
|
| 169 |
--petal-color: #FFFFFF;
|
| 170 |
}
|
| 171 |
|
| 172 |
.emotion-happy {
|
| 173 |
+
--emotion-bg: linear-gradient(180deg, #FEF3C7 0%, #F5F1ED 100%);
|
| 174 |
--petal-color: #FFFFFF;
|
| 175 |
}
|
| 176 |
|
| 177 |
.emotion-sad {
|
| 178 |
+
--emotion-bg: linear-gradient(180deg, #E0E7FF 0%, #F5F1ED 100%);
|
| 179 |
--petal-color: #FFFFFF;
|
| 180 |
}
|
| 181 |
|
| 182 |
.emotion-angry {
|
| 183 |
+
--emotion-bg: linear-gradient(180deg, #FEE2E2 0%, #F5F1ED 100%);
|
| 184 |
--petal-color: #FFFFFF;
|
| 185 |
}
|
| 186 |
|
| 187 |
.emotion-fear {
|
| 188 |
+
--emotion-bg: linear-gradient(180deg, #EDE9FE 0%, #F5F1ED 100%);
|
| 189 |
--petal-color: #FFFFFF;
|
| 190 |
}
|
| 191 |
|
| 192 |
.emotion-surprise {
|
| 193 |
+
--emotion-bg: linear-gradient(180deg, #FFEDD5 0%, #F5F1ED 100%);
|
| 194 |
--petal-color: #FFFFFF;
|
| 195 |
}
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
/* === 中央容器 === */
|
| 198 |
.voice-center-container {
|
| 199 |
position: relative;
|
|
|
|
| 201 |
display: flex;
|
| 202 |
flex-direction: column;
|
| 203 |
align-items: center;
|
| 204 |
+
gap: 60px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
}
|
| 206 |
|
| 207 |
/* === 波形與麥克風容器 === */
|
|
|
|
| 212 |
display: flex;
|
| 213 |
align-items: center;
|
| 214 |
justify-content: center;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
}
|
| 216 |
|
| 217 |
/* Canvas 波形 */
|
|
|
|
| 356 |
z-index: 10;
|
| 357 |
}
|
| 358 |
|
| 359 |
+
/* 內層花瓣顏色微調(略淡,增加層次感)*/
|
| 360 |
.bloom-petal:nth-child(n+9)::before {
|
| 361 |
+
background:
|
| 362 |
+
radial-gradient(ellipse 80% 60% at 50% 30%,
|
| 363 |
+
rgba(255, 255, 255, 0.96) 0%,
|
| 364 |
+
rgba(255, 255, 255, 0.94) 40%,
|
| 365 |
+
rgba(249, 250, 251, 0.88) 70%,
|
| 366 |
+
rgba(243, 244, 246, 0.82) 100%
|
| 367 |
),
|
| 368 |
+
linear-gradient(180deg,
|
| 369 |
+
rgba(255, 255, 255, 0.95) 0%,
|
| 370 |
+
rgba(254, 254, 254, 0.92) 30%,
|
| 371 |
+
rgba(249, 250, 251, 0.88) 70%,
|
| 372 |
+
rgba(243, 244, 246, 0.85) 100%
|
| 373 |
);
|
| 374 |
}
|
| 375 |
|
|
|
|
| 398 |
inset -3px 4px 10px rgba(0, 0, 0, 0.08),
|
| 399 |
inset 3px -4px 12px rgba(255, 255, 255, 0.95),
|
| 400 |
inset 0 -2px 6px rgba(0, 0, 0, 0.04);
|
| 401 |
+
transition: all 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
|
| 402 |
+
border: 1px solid rgba(0, 0, 0, 0.06);
|
| 403 |
+
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.08));
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
}
|
| 405 |
|
| 406 |
/* 花瓣中心脈絡(細緻葉脈,非綠莖)*/
|
|
|
|
| 809 |
animation: petalSpeakingInner 3s ease-in-out infinite 0.3s;
|
| 810 |
}
|
| 811 |
|
| 812 |
+
/* 外層花瓣動畫:完全綻放 + 輕微呼吸(內縮版本)*/
|
| 813 |
@keyframes petalSpeakingOuter {
|
| 814 |
0%, 100% {
|
| 815 |
transform: translate(-50%, -50%) rotateZ(var(--speaking-angle, 0deg)) rotateX(0deg) translateY(-30px) scale(1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 816 |
}
|
| 817 |
50% {
|
| 818 |
transform: translate(-50%, -50%) rotateZ(var(--speaking-angle, 0deg)) rotateX(0deg) translateY(-33px) scale(1.03);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 819 |
}
|
| 820 |
}
|
| 821 |
|
| 822 |
+
/* 內層花瓣動畫:略微抬起 + 反向呼吸 */
|
| 823 |
@keyframes petalSpeakingInner {
|
| 824 |
0%, 100% {
|
| 825 |
transform: translate(-50%, -50%) rotateZ(var(--speaking-angle-inner, 0deg)) rotateX(10deg) translateY(-28px) scale(1.05);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 826 |
}
|
| 827 |
50% {
|
| 828 |
+
transform: translate(-50%, -50%) rotateZ(var(--speaking-angle-inner, 0deg)) rotateX(10deg) translateY(-25px) scale(1.02);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 829 |
}
|
| 830 |
}
|
| 831 |
|
|
|
|
| 856 |
inset 2px -3px 12px rgba(255, 255, 255, 0.95);
|
| 857 |
}
|
| 858 |
|
| 859 |
+
/* 花蕊在回覆時保持金黃色,但有呼吸效果 */
|
| 860 |
.voice-mic-container.speaking .bloom-core {
|
| 861 |
animation: speakingCoreBreath 2.4s ease-in-out infinite;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 862 |
}
|
| 863 |
|
| 864 |
@keyframes speakingCoreBreath {
|
| 865 |
0%, 100% {
|
| 866 |
transform: translate(-50%, -50%) scale(1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 867 |
}
|
| 868 |
50% {
|
| 869 |
transform: translate(-50%, -50%) scale(1.08);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 870 |
}
|
| 871 |
}
|
| 872 |
|
| 873 |
|
| 874 |
/* === Agent 文字輸出區域(打字機效果)=== */
|
| 875 |
.voice-agent-output {
|
|
|
|
| 876 |
max-width: 600px;
|
| 877 |
padding: 20px 28px;
|
| 878 |
background: rgba(255, 255, 255, 0.98);
|
|
|
|
| 885 |
text-align: left;
|
| 886 |
line-height: 1.7;
|
| 887 |
min-height: 60px;
|
|
|
|
|
|
|
| 888 |
box-shadow: 0 6px 28px rgba(0, 0, 0, 0.1);
|
| 889 |
font-family: 'Inter', -apple-system, sans-serif;
|
| 890 |
+
display: none; /* 預設隱藏 */
|
| 891 |
opacity: 0;
|
| 892 |
+
transform: translateY(10px);
|
| 893 |
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
|
|
|
|
| 894 |
}
|
| 895 |
|
| 896 |
.voice-agent-output.active {
|
| 897 |
+
display: block;
|
| 898 |
opacity: 1;
|
| 899 |
transform: translateY(0);
|
|
|
|
|
|
|
| 900 |
}
|
| 901 |
|
| 902 |
/* 打字游標效果 */
|
|
|
|
| 917 |
}
|
| 918 |
|
| 919 |
/* === 實時字幕 / 文字輸入框 === */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 920 |
.voice-transcript {
|
| 921 |
+
max-width: 700px;
|
| 922 |
+
padding: 24px 32px;
|
| 923 |
+
background: rgba(255, 255, 255, 0.95);
|
| 924 |
+
border: 1px solid rgba(0, 0, 0, 0.08);
|
| 925 |
+
border-radius: 16px;
|
| 926 |
+
backdrop-filter: blur(20px) saturate(180%);
|
| 927 |
+
font-size: 20px;
|
|
|
|
| 928 |
font-weight: 400;
|
| 929 |
color: #1A1A1A;
|
| 930 |
text-align: center;
|
| 931 |
+
line-height: 1.6;
|
| 932 |
+
min-height: 80px;
|
|
|
|
| 933 |
display: flex;
|
| 934 |
align-items: center;
|
| 935 |
justify-content: center;
|
| 936 |
+
transition: all 0.3s;
|
| 937 |
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
|
|
|
|
|
|
|
|
|
| 938 |
}
|
| 939 |
|
| 940 |
.voice-transcript.provisional {
|
| 941 |
+
color: rgba(0, 0, 0, 0.4);
|
| 942 |
font-style: italic;
|
| 943 |
}
|
| 944 |
|
| 945 |
.voice-transcript.final {
|
| 946 |
color: #1A1A1A;
|
| 947 |
+
border-color: rgba(0, 0, 0, 0.12);
|
| 948 |
+
box-shadow: 0 6px 32px rgba(0, 0, 0, 0.12);
|
| 949 |
}
|
| 950 |
|
| 951 |
/* 文字輸入模式 */
|
| 952 |
.voice-transcript.text-input-mode {
|
| 953 |
+
padding: 0;
|
| 954 |
+
min-height: 100px;
|
| 955 |
+
max-height: 200px;
|
| 956 |
+
overflow: hidden;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 957 |
}
|
| 958 |
|
| 959 |
+
.voice-transcript.text-input-mode textarea {
|
| 960 |
+
width: 100%;
|
| 961 |
+
height: 100%;
|
| 962 |
+
min-height: 100px;
|
| 963 |
+
padding: 24px 32px;
|
| 964 |
+
background: transparent;
|
| 965 |
+
border: none;
|
| 966 |
+
outline: none;
|
| 967 |
+
font-size: 18px;
|
| 968 |
+
font-weight: 400;
|
| 969 |
+
color: #1A1A1A;
|
| 970 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 971 |
+
resize: vertical;
|
| 972 |
+
line-height: 1.6;
|
| 973 |
}
|
| 974 |
|
| 975 |
+
.voice-transcript.text-input-mode textarea::placeholder {
|
| 976 |
+
color: rgba(0, 0, 0, 0.4);
|
|
|
|
| 977 |
font-style: italic;
|
| 978 |
}
|
| 979 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 980 |
/* === 工具卡片 === */
|
| 981 |
.voice-tool-card {
|
| 982 |
position: absolute;
|
|
|
|
| 987 |
backdrop-filter: blur(30px) saturate(180%);
|
| 988 |
min-width: 280px;
|
| 989 |
max-width: 360px;
|
|
|
|
|
|
|
| 990 |
z-index: 40;
|
| 991 |
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
| 992 |
animation: cardEnter 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
|
|
|
| 1095 |
font-family: 'JetBrains Mono', monospace;
|
| 1096 |
}
|
| 1097 |
|
| 1098 |
+
/* ChatWindow 折疊圖標 */
|
| 1099 |
.chat-icon {
|
| 1100 |
position: fixed;
|
| 1101 |
+
/* 使用 calc 搭配 svh 確保在所有裝置上都固定在底部 */
|
| 1102 |
+
bottom: max(40px, env(safe-area-inset-bottom, 0px));
|
| 1103 |
+
left: max(40px, env(safe-area-inset-left, 0px));
|
| 1104 |
z-index: 101;
|
| 1105 |
width: 56px;
|
| 1106 |
height: 56px;
|
|
|
|
| 1114 |
backdrop-filter: blur(20px);
|
| 1115 |
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
| 1116 |
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1117 |
+
font-size: 20px;
|
|
|
|
| 1118 |
}
|
| 1119 |
|
| 1120 |
.chat-icon:hover {
|
|
|
|
| 1123 |
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
|
| 1124 |
}
|
| 1125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1126 |
/* 退出按鈕 */
|
| 1127 |
.exit-button {
|
| 1128 |
position: absolute;
|
| 1129 |
+
top: max(40px, env(safe-area-inset-top, 0px));
|
| 1130 |
+
right: max(40px, env(safe-area-inset-right, 0px));
|
| 1131 |
z-index: 50;
|
| 1132 |
+
padding: 12px 20px;
|
| 1133 |
background: rgba(255, 255, 255, 0.95);
|
| 1134 |
border: 1px solid rgba(0, 0, 0, 0.08);
|
| 1135 |
+
border-radius: 10px;
|
| 1136 |
color: rgba(0, 0, 0, 0.6);
|
| 1137 |
font-family: 'Inter', sans-serif;
|
| 1138 |
font-size: 13px;
|
| 1139 |
font-weight: 500;
|
| 1140 |
cursor: pointer;
|
| 1141 |
transition: all 0.2s;
|
| 1142 |
+
backdrop-filter: blur(10px);
|
| 1143 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
|
|
|
| 1144 |
}
|
| 1145 |
|
| 1146 |
.exit-button:hover {
|
|
|
|
| 1153 |
/* 情緒標籤 */
|
| 1154 |
.emotion-indicator {
|
| 1155 |
position: absolute;
|
| 1156 |
+
top: max(40px, env(safe-area-inset-top, 0px));
|
| 1157 |
left: 50%;
|
| 1158 |
transform: translateX(-50%);
|
| 1159 |
z-index: 50;
|
|
|
|
| 1199 |
50% { opacity: 0.3; }
|
| 1200 |
}
|
| 1201 |
|
| 1202 |
+
/* === 登入覆蓋層(蓮花風格)=== */
|
| 1203 |
+
.login-overlay {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1204 |
position: fixed;
|
|
|
|
| 1205 |
top: 0;
|
| 1206 |
+
left: 0;
|
| 1207 |
+
width: 100vw;
|
| 1208 |
height: 100vh;
|
| 1209 |
+
background: linear-gradient(135deg, #E6F7F0 0%, #F5F1ED 100%);
|
| 1210 |
+
z-index: 9999;
|
| 1211 |
+
display: flex;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1212 |
align-items: center;
|
| 1213 |
justify-content: center;
|
| 1214 |
+
transition: opacity 0.6s ease, visibility 0.6s ease;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1215 |
}
|
| 1216 |
|
| 1217 |
+
.login-overlay.hidden {
|
| 1218 |
+
opacity: 0;
|
| 1219 |
+
visibility: hidden;
|
| 1220 |
+
pointer-events: none;
|
| 1221 |
}
|
| 1222 |
|
| 1223 |
+
.login-container {
|
| 1224 |
+
text-align: center;
|
| 1225 |
+
max-width: 400px;
|
| 1226 |
+
padding: 48px;
|
| 1227 |
+
background: rgba(255, 255, 255, 0.95);
|
| 1228 |
+
border-radius: 32px;
|
| 1229 |
+
backdrop-filter: blur(20px) saturate(180%);
|
| 1230 |
+
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.12);
|
| 1231 |
}
|
| 1232 |
|
| 1233 |
+
.login-lotus {
|
| 1234 |
+
width: 120px;
|
| 1235 |
+
height: 120px;
|
| 1236 |
+
margin: 0 auto 32px;
|
| 1237 |
position: relative;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1238 |
}
|
| 1239 |
|
| 1240 |
+
.login-lotus::before {
|
| 1241 |
+
content: '🪷';
|
| 1242 |
+
font-size: 120px;
|
| 1243 |
+
line-height: 1;
|
| 1244 |
+
animation: float 3s ease-in-out infinite;
|
| 1245 |
}
|
| 1246 |
|
| 1247 |
+
@keyframes float {
|
| 1248 |
+
0%, 100% { transform: translateY(0px); }
|
| 1249 |
+
50% { transform: translateY(-10px); }
|
| 1250 |
}
|
| 1251 |
|
| 1252 |
+
.login-title {
|
| 1253 |
+
font-size: 28px;
|
| 1254 |
+
font-weight: 700;
|
| 1255 |
+
color: #1A1A1A;
|
| 1256 |
+
margin-bottom: 12px;
|
| 1257 |
+
letter-spacing: -0.5px;
|
| 1258 |
}
|
| 1259 |
|
| 1260 |
+
.login-subtitle {
|
| 1261 |
+
font-size: 15px;
|
| 1262 |
+
color: rgba(0, 0, 0, 0.5);
|
| 1263 |
+
margin-bottom: 40px;
|
| 1264 |
+
line-height: 1.6;
|
| 1265 |
}
|
| 1266 |
|
| 1267 |
+
.login-button {
|
| 1268 |
+
display: inline-flex;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1269 |
align-items: center;
|
| 1270 |
justify-content: center;
|
| 1271 |
+
gap: 12px;
|
| 1272 |
+
padding: 16px 32px;
|
| 1273 |
+
background: #FFFFFF;
|
| 1274 |
+
border: 1px solid rgba(0, 0, 0, 0.12);
|
| 1275 |
+
border-radius: 16px;
|
| 1276 |
+
color: #1A1A1A;
|
| 1277 |
+
font-family: 'Inter', sans-serif;
|
| 1278 |
+
font-size: 15px;
|
| 1279 |
+
font-weight: 600;
|
| 1280 |
cursor: pointer;
|
| 1281 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1282 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
| 1283 |
}
|
| 1284 |
|
| 1285 |
+
.login-button:hover {
|
| 1286 |
+
background: #F5F5F5;
|
| 1287 |
+
border-color: rgba(0, 0, 0, 0.18);
|
| 1288 |
+
transform: translateY(-2px);
|
| 1289 |
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
| 1290 |
}
|
| 1291 |
|
| 1292 |
+
.login-button:active {
|
| 1293 |
+
transform: translateY(0);
|
| 1294 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1295 |
}
|
| 1296 |
|
| 1297 |
+
.login-button img {
|
| 1298 |
+
width: 20px;
|
| 1299 |
+
height: 20px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1300 |
}
|
|
|
|
| 1301 |
</style>
|
| 1302 |
</head>
|
| 1303 |
<body>
|
| 1304 |
+
<!-- 登入覆蓋層 -->
|
| 1305 |
+
<div class="login-overlay" id="loginOverlay">
|
| 1306 |
+
<div class="login-container">
|
| 1307 |
+
<div class="login-lotus"></div>
|
| 1308 |
+
<h1 class="login-title">Bloom Ware</h1>
|
| 1309 |
+
<p class="login-subtitle">語音沉浸式 AI 助手<br>請登入以開始使用</p>
|
| 1310 |
+
<button class="login-button" id="googleLoginBtn">
|
| 1311 |
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
| 1312 |
+
<path d="M19.6 10.227c0-.709-.064-1.39-.182-2.045H10v3.868h5.382a4.6 4.6 0 01-1.996 3.018v2.51h3.232c1.891-1.742 2.982-4.305 2.982-7.35z" fill="#4285F4"/>
|
| 1313 |
+
<path d="M10 20c2.7 0 4.964-.895 6.618-2.423l-3.232-2.509c-.895.6-2.04.955-3.386.955-2.605 0-4.81-1.76-5.595-4.123H1.064v2.59A9.996 9.996 0 0010 20z" fill="#34A853"/>
|
| 1314 |
+
<path d="M4.405 11.9c-.2-.6-.314-1.24-.314-1.9 0-.66.114-1.3.314-1.9V5.51H1.064A9.996 9.996 0 000 10c0 1.614.386 3.14 1.064 4.49l3.34-2.59z" fill="#FBBC05"/>
|
| 1315 |
+
<path d="M10 3.977c1.468 0 2.786.505 3.823 1.496l2.868-2.868C14.959.99 12.695 0 10 0 6.09 0 2.71 2.24 1.064 5.51l3.34 2.59C5.19 5.736 7.395 3.977 10 3.977z" fill="#EA4335"/>
|
| 1316 |
+
</svg>
|
| 1317 |
+
使用 Google 登入
|
| 1318 |
+
</button>
|
| 1319 |
+
</div>
|
| 1320 |
+
</div>
|
| 1321 |
+
|
| 1322 |
<!-- 控制面板 -->
|
| 1323 |
<div class="control-panel">
|
| 1324 |
<h3>🎛️ 控制面板</h3>
|
|
|
|
| 1388 |
<!-- 情緒指示器 -->
|
| 1389 |
<div class="emotion-indicator" id="emotion-indicator">當前情緒: 😐 中性</div>
|
| 1390 |
|
| 1391 |
+
<!-- 登出按鈕 -->
|
| 1392 |
+
<button class="exit-button" id="logoutBtn">登出</button>
|
| 1393 |
|
| 1394 |
+
<!-- ChatWindow 切換圖標 -->
|
| 1395 |
+
<div class="chat-icon" id="chatIcon" title="切換對話視窗">💬</div>
|
| 1396 |
|
| 1397 |
<!-- 中央容器 -->
|
| 1398 |
<div class="voice-center-container">
|
|
|
|
| 1432 |
<!-- Agent 文字輸出(打字機效果,位於花朵下方)-->
|
| 1433 |
<div class="voice-agent-output" id="agent-output"></div>
|
| 1434 |
|
| 1435 |
+
<!-- 實時字幕 -->
|
| 1436 |
+
<div class="voice-transcript provisional" id="transcript">
|
| 1437 |
+
請說話...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1438 |
</div>
|
| 1439 |
</div>
|
| 1440 |
|
| 1441 |
+
<!-- 工具卡片容器 -->
|
| 1442 |
<div id="tool-cards-container"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1443 |
</div>
|
| 1444 |
|
| 1445 |
<!-- JavaScript 模組化引入 -->
|
|
|
|
| 1449 |
<script src="js/tools.js"></script>
|
| 1450 |
<script src="js/agent.js"></script>
|
| 1451 |
<script src="js/canvas.js"></script>
|
| 1452 |
+
<script src="js/location.js"></script>
|
| 1453 |
<script src="js/websocket.js"></script>
|
| 1454 |
<script src="js/app.js"></script>
|
| 1455 |
</body>
|
static/frontend/js/agent.js
CHANGED
|
@@ -1,14 +1,3 @@
|
|
| 1 |
-
// 全域控制:除非啟用 window.BLOOMWARE_DEBUG,否則靜音 console.log/info/debug
|
| 2 |
-
(function silenceConsoleLogs() {
|
| 3 |
-
if (typeof window !== 'undefined' && !window.BLOOMWARE_DEBUG && !console.__bloomwareSilenced) {
|
| 4 |
-
const noop = () => {};
|
| 5 |
-
console.log = noop;
|
| 6 |
-
console.info = noop;
|
| 7 |
-
console.debug = noop;
|
| 8 |
-
console.__bloomwareSilenced = true;
|
| 9 |
-
}
|
| 10 |
-
})();
|
| 11 |
-
|
| 12 |
// ========== Agent 狀態管理(單一狀態機)==========
|
| 13 |
// 狀態定義:idle | recording | thinking | speaking | disconnected
|
| 14 |
let currentState = 'idle';
|
|
@@ -43,16 +32,6 @@ function setState(newState, options = {}) {
|
|
| 43 |
if (options.clearCards !== false) {
|
| 44 |
clearAllCards();
|
| 45 |
}
|
| 46 |
-
// 恢復輸入交互能力
|
| 47 |
-
if (typeof isTextInputMode !== 'undefined' && !isTextInputMode) {
|
| 48 |
-
// 語音模式
|
| 49 |
-
transcript.style.pointerEvents = 'auto';
|
| 50 |
-
transcript.style.opacity = '1';
|
| 51 |
-
} else if (typeof isTextInputMode !== 'undefined' && isTextInputMode) {
|
| 52 |
-
// 文字輸入模式
|
| 53 |
-
transcript.contentEditable = 'true';
|
| 54 |
-
transcript.style.opacity = '1';
|
| 55 |
-
}
|
| 56 |
break;
|
| 57 |
|
| 58 |
case 'recording':
|
|
@@ -78,16 +57,6 @@ function setState(newState, options = {}) {
|
|
| 78 |
if (typeof stopSpeaking === 'function') {
|
| 79 |
stopSpeaking();
|
| 80 |
}
|
| 81 |
-
// 禁用輸入交互(語音模式)
|
| 82 |
-
if (typeof isTextInputMode !== 'undefined' && !isTextInputMode) {
|
| 83 |
-
transcript.style.pointerEvents = 'none';
|
| 84 |
-
transcript.style.opacity = '0.6';
|
| 85 |
-
}
|
| 86 |
-
// 禁用文字輸入交互
|
| 87 |
-
else if (typeof isTextInputMode !== 'undefined' && isTextInputMode) {
|
| 88 |
-
transcript.contentEditable = 'false';
|
| 89 |
-
transcript.style.opacity = '0.6';
|
| 90 |
-
}
|
| 91 |
break;
|
| 92 |
|
| 93 |
case 'speaking':
|
|
@@ -97,19 +66,6 @@ function setState(newState, options = {}) {
|
|
| 97 |
// typewriterEffect 內部會自動調用 speakText(如果 enableTTS 為 true)
|
| 98 |
typewriterEffect(options.outputText, 40, options.enableTTS);
|
| 99 |
}
|
| 100 |
-
// 恢復輸入交互能力(Agent 回應中)
|
| 101 |
-
if (typeof isTextInputMode !== 'undefined' && !isTextInputMode) {
|
| 102 |
-
// 語音模式:清空 transcript 並恢復可見性
|
| 103 |
-
transcript.textContent = '請說話...';
|
| 104 |
-
transcript.className = 'voice-transcript provisional';
|
| 105 |
-
transcript.style.pointerEvents = 'auto';
|
| 106 |
-
transcript.style.opacity = '1';
|
| 107 |
-
} else if (typeof isTextInputMode !== 'undefined' && isTextInputMode) {
|
| 108 |
-
// 文字輸入模式:清空並恢復可編輯
|
| 109 |
-
transcript.textContent = '';
|
| 110 |
-
transcript.contentEditable = 'true';
|
| 111 |
-
transcript.style.opacity = '1';
|
| 112 |
-
}
|
| 113 |
break;
|
| 114 |
|
| 115 |
case 'disconnected':
|
|
@@ -131,47 +87,13 @@ function setState(newState, options = {}) {
|
|
| 131 |
* 應用情緒主題
|
| 132 |
*/
|
| 133 |
function applyEmotion(emotion) {
|
| 134 |
-
if (!emotion || typeof emotion !== 'string') {
|
| 135 |
-
emotion = 'neutral';
|
| 136 |
-
}
|
| 137 |
-
|
| 138 |
-
const normalized = emotion.trim().toLowerCase();
|
| 139 |
-
const emotionAlias = {
|
| 140 |
-
sadness: 'sad',
|
| 141 |
-
happy: 'happy',
|
| 142 |
-
happiness: 'happy',
|
| 143 |
-
anger: 'angry',
|
| 144 |
-
angry: 'angry',
|
| 145 |
-
fear: 'fear',
|
| 146 |
-
fearful: 'fear',
|
| 147 |
-
surprise: 'surprise',
|
| 148 |
-
surprised: 'surprise',
|
| 149 |
-
neutral: 'neutral',
|
| 150 |
-
};
|
| 151 |
-
|
| 152 |
const validEmotions = ['neutral', 'happy', 'sad', 'angry', 'fear', 'surprise'];
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
background.className = `voice-immersive-background emotion-${finalEmotion} active`;
|
| 157 |
-
emotionIndicator.textContent = `當前情緒: ${emotionEmojis[finalEmotion]}`;
|
| 158 |
-
|
| 159 |
-
if (window.localStorage) {
|
| 160 |
-
try {
|
| 161 |
-
localStorage.setItem('lastEmotion', finalEmotion);
|
| 162 |
-
} catch (err) {
|
| 163 |
-
console.warn('無法儲存情緒狀態:', err);
|
| 164 |
-
}
|
| 165 |
}
|
| 166 |
-
}
|
| 167 |
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
if (storedEmotion) {
|
| 171 |
-
applyEmotion(storedEmotion);
|
| 172 |
-
}
|
| 173 |
-
} catch (err) {
|
| 174 |
-
console.warn('無法讀取情緒狀態:', err);
|
| 175 |
}
|
| 176 |
|
| 177 |
/**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
// ========== Agent 狀態管理(單一狀態機)==========
|
| 2 |
// 狀態定義:idle | recording | thinking | speaking | disconnected
|
| 3 |
let currentState = 'idle';
|
|
|
|
| 32 |
if (options.clearCards !== false) {
|
| 33 |
clearAllCards();
|
| 34 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
break;
|
| 36 |
|
| 37 |
case 'recording':
|
|
|
|
| 57 |
if (typeof stopSpeaking === 'function') {
|
| 58 |
stopSpeaking();
|
| 59 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
break;
|
| 61 |
|
| 62 |
case 'speaking':
|
|
|
|
| 66 |
// typewriterEffect 內部會自動調用 speakText(如果 enableTTS 為 true)
|
| 67 |
typewriterEffect(options.outputText, 40, options.enableTTS);
|
| 68 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
break;
|
| 70 |
|
| 71 |
case 'disconnected':
|
|
|
|
| 87 |
* 應用情緒主題
|
| 88 |
*/
|
| 89 |
function applyEmotion(emotion) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
const validEmotions = ['neutral', 'happy', 'sad', 'angry', 'fear', 'surprise'];
|
| 91 |
+
if (!validEmotions.includes(emotion)) {
|
| 92 |
+
emotion = 'neutral';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
}
|
|
|
|
| 94 |
|
| 95 |
+
background.className = `voice-immersive-background emotion-${emotion} active`;
|
| 96 |
+
emotionIndicator.textContent = `當前情緒: ${emotionEmojis[emotion]}`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
}
|
| 98 |
|
| 99 |
/**
|
static/frontend/js/app.js
CHANGED
|
@@ -1,14 +1,3 @@
|
|
| 1 |
-
// 全域控制:除非 window.BLOOMWARE_DEBUG 為 true,否則靜音非必要的 console 輸出
|
| 2 |
-
(function silenceConsoleLogs() {
|
| 3 |
-
if (typeof window !== 'undefined' && !window.BLOOMWARE_DEBUG && !console.__bloomwareSilenced) {
|
| 4 |
-
const noop = () => {};
|
| 5 |
-
console.log = noop;
|
| 6 |
-
console.info = noop;
|
| 7 |
-
console.debug = noop;
|
| 8 |
-
console.__bloomwareSilenced = true;
|
| 9 |
-
}
|
| 10 |
-
})();
|
| 11 |
-
|
| 12 |
// ========== 登入狀態檢查 ==========
|
| 13 |
|
| 14 |
/**
|
|
@@ -55,7 +44,7 @@ async function checkLoginStatus() {
|
|
| 55 |
if (!token) {
|
| 56 |
// 未登入,導向登入頁面
|
| 57 |
console.log('⚠️ 未登入,導向登入頁面...');
|
| 58 |
-
window.location.href = '/
|
| 59 |
return false;
|
| 60 |
}
|
| 61 |
|
|
@@ -67,7 +56,7 @@ async function checkLoginStatus() {
|
|
| 67 |
if (payload.exp && payload.exp < currentTime) {
|
| 68 |
console.error('❌ Token 已過期,跳轉到登入頁面');
|
| 69 |
localStorage.removeItem('jwt_token');
|
| 70 |
-
window.location.href = '/
|
| 71 |
return false;
|
| 72 |
}
|
| 73 |
|
|
@@ -75,7 +64,7 @@ async function checkLoginStatus() {
|
|
| 75 |
} catch (error) {
|
| 76 |
console.error('❌ Token 解析失敗:', error);
|
| 77 |
localStorage.removeItem('jwt_token');
|
| 78 |
-
window.location.href = '/
|
| 79 |
return false;
|
| 80 |
}
|
| 81 |
|
|
@@ -90,14 +79,21 @@ async function checkLoginStatus() {
|
|
| 90 |
function initializeApp(token) {
|
| 91 |
console.log('🚀 初始化應用...');
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
// 初始化各個模組的事件監聽器
|
| 94 |
initLoginButton();
|
| 95 |
-
|
|
|
|
| 96 |
initEmotionSelector();
|
| 97 |
initTranscriptControls();
|
| 98 |
initToolCardControls();
|
| 99 |
initAgentControls();
|
| 100 |
-
initModeToggle(); // 初始化模式切換按鈕
|
| 101 |
|
| 102 |
// 同步 MCP 工具 metadata
|
| 103 |
syncToolMetadata();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
// ========== 登入狀態檢查 ==========
|
| 2 |
|
| 3 |
/**
|
|
|
|
| 44 |
if (!token) {
|
| 45 |
// 未登入,導向登入頁面
|
| 46 |
console.log('⚠️ 未登入,導向登入頁面...');
|
| 47 |
+
window.location.href = '/login/';
|
| 48 |
return false;
|
| 49 |
}
|
| 50 |
|
|
|
|
| 56 |
if (payload.exp && payload.exp < currentTime) {
|
| 57 |
console.error('❌ Token 已過期,跳轉到登入頁面');
|
| 58 |
localStorage.removeItem('jwt_token');
|
| 59 |
+
window.location.href = '/login/';
|
| 60 |
return false;
|
| 61 |
}
|
| 62 |
|
|
|
|
| 64 |
} catch (error) {
|
| 65 |
console.error('❌ Token 解析失敗:', error);
|
| 66 |
localStorage.removeItem('jwt_token');
|
| 67 |
+
window.location.href = '/login/';
|
| 68 |
return false;
|
| 69 |
}
|
| 70 |
|
|
|
|
| 79 |
function initializeApp(token) {
|
| 80 |
console.log('🚀 初始化應用...');
|
| 81 |
|
| 82 |
+
// 隱藏登入覆蓋層
|
| 83 |
+
const loginOverlay = document.getElementById('loginOverlay');
|
| 84 |
+
if (loginOverlay) {
|
| 85 |
+
loginOverlay.classList.add('hidden');
|
| 86 |
+
console.log('✅ 登入覆蓋層已隱藏');
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
// 初始化各個模組的事件監聽器
|
| 90 |
initLoginButton();
|
| 91 |
+
initLogoutButton();
|
| 92 |
+
initChatIcon();
|
| 93 |
initEmotionSelector();
|
| 94 |
initTranscriptControls();
|
| 95 |
initToolCardControls();
|
| 96 |
initAgentControls();
|
|
|
|
| 97 |
|
| 98 |
// 同步 MCP 工具 metadata
|
| 99 |
syncToolMetadata();
|
static/frontend/js/canvas.js
CHANGED
|
@@ -1,14 +1,3 @@
|
|
| 1 |
-
// 全域控制:未開啟 window.BLOOMWARE_DEBUG 時靜音一般 console 輸出
|
| 2 |
-
(function silenceConsoleLogs() {
|
| 3 |
-
if (typeof window !== 'undefined' && !window.BLOOMWARE_DEBUG && !console.__bloomwareSilenced) {
|
| 4 |
-
const noop = () => {};
|
| 5 |
-
console.log = noop;
|
| 6 |
-
console.info = noop;
|
| 7 |
-
console.debug = noop;
|
| 8 |
-
console.__bloomwareSilenced = true;
|
| 9 |
-
}
|
| 10 |
-
})();
|
| 11 |
-
|
| 12 |
// ========== Canvas 波形渲染(效能優化版 + 真實音訊整合)==========
|
| 13 |
|
| 14 |
const canvas = document.getElementById('waveform-canvas');
|
|
@@ -31,7 +20,7 @@ for (let i = 0; i <= points; i++) {
|
|
| 31 |
sinCache[i] = Math.sin(angle);
|
| 32 |
}
|
| 33 |
|
| 34 |
-
// Web Audio API
|
| 35 |
let canvasAudioContext = null;
|
| 36 |
let analyser = null;
|
| 37 |
let dataArray = null;
|
|
@@ -39,12 +28,6 @@ let bufferLength = 0;
|
|
| 39 |
let audioStream = null;
|
| 40 |
let useRealAudio = false; // 是否使用真實音訊數據
|
| 41 |
|
| 42 |
-
// TTS 音訊視覺化(讓波形跟隨 TTS 跳動)
|
| 43 |
-
let useTTSAudio = false;
|
| 44 |
-
let ttsAnalyserRef = null;
|
| 45 |
-
let ttsDataArrayRef = null;
|
| 46 |
-
let ttsBufferLengthRef = 0;
|
| 47 |
-
|
| 48 |
/**
|
| 49 |
* 啟動真實音訊分析
|
| 50 |
*/
|
|
@@ -108,45 +91,14 @@ function stopRealAudioAnalysis() {
|
|
| 108 |
console.log('🛑 真實音訊分析已停止');
|
| 109 |
}
|
| 110 |
|
| 111 |
-
/**
|
| 112 |
-
* 啟動 TTS 音訊視覺化(從 tts.js 調用)
|
| 113 |
-
* @param {AnalyserNode} analyser - TTS 分析器節點
|
| 114 |
-
* @param {Uint8Array} dataArray - TTS 頻率數據陣列
|
| 115 |
-
* @param {number} bufferLength - 數據陣列長度
|
| 116 |
-
*/
|
| 117 |
-
function startTTSVisualization(analyser, dataArray, bufferLength) {
|
| 118 |
-
ttsAnalyserRef = analyser;
|
| 119 |
-
ttsDataArrayRef = dataArray;
|
| 120 |
-
ttsBufferLengthRef = bufferLength;
|
| 121 |
-
useTTSAudio = true;
|
| 122 |
-
|
| 123 |
-
console.log('🎵 波形開始跟隨 TTS 音訊跳動');
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
/**
|
| 127 |
-
* 停止 TTS 音訊視覺化(從 tts.js 調用)
|
| 128 |
-
*/
|
| 129 |
-
function stopTTSVisualization() {
|
| 130 |
-
useTTSAudio = false;
|
| 131 |
-
ttsAnalyserRef = null;
|
| 132 |
-
ttsDataArrayRef = null;
|
| 133 |
-
ttsBufferLengthRef = 0;
|
| 134 |
-
|
| 135 |
-
console.log('🛑 波形停止跟隨 TTS 音訊');
|
| 136 |
-
}
|
| 137 |
-
|
| 138 |
function draw360Waveform() {
|
| 139 |
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 140 |
|
| 141 |
const time = Date.now() * 0.001;
|
| 142 |
|
| 143 |
-
//
|
| 144 |
-
if (
|
| 145 |
-
|
| 146 |
-
}
|
| 147 |
-
// 否則使用麥克風錄音數據
|
| 148 |
-
else if (useRealAudio && analyser && dataArray) {
|
| 149 |
-
analyser.getByteFrequencyData(dataArray);
|
| 150 |
}
|
| 151 |
|
| 152 |
// 繪製多層波形(淺色主題)
|
|
@@ -163,21 +115,8 @@ function draw360Waveform() {
|
|
| 163 |
|
| 164 |
let amplitude;
|
| 165 |
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
// TTS 音訊模式:將 120 個波形點對應到 ttsBufferLengthRef 個頻率數據
|
| 169 |
-
const dataIndex = Math.floor((i / points) * ttsBufferLengthRef);
|
| 170 |
-
const audioValue = ttsDataArrayRef[dataIndex] / 255.0; // 標準化到 0-1
|
| 171 |
-
|
| 172 |
-
// 結合音訊數據和時間動畫(TTS 專用:更強調低頻)
|
| 173 |
-
const wave1 = audioValue * 0.7; // 主要由 TTS 音訊驅動
|
| 174 |
-
const wave2 = Math.sin(angle * 3 - time * 1.0) * 0.15; // 保留少量動畫
|
| 175 |
-
const wave3 = sinCache[i * 5 % points] * 0.05 * Math.cos(time * 1.5);
|
| 176 |
-
|
| 177 |
-
amplitude = (wave1 + wave2 + wave3) * layerMultiplier;
|
| 178 |
-
|
| 179 |
-
} else if (useRealAudio && dataArray && bufferLength > 0) {
|
| 180 |
-
// 麥克風錄音模式:將 120 個波形點對應到 bufferLength 個頻率數據
|
| 181 |
const dataIndex = Math.floor((i / points) * bufferLength);
|
| 182 |
const audioValue = dataArray[dataIndex] / 255.0; // 標準化到 0-1
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
// ========== Canvas 波形渲染(效能優化版 + 真實音訊整合)==========
|
| 2 |
|
| 3 |
const canvas = document.getElementById('waveform-canvas');
|
|
|
|
| 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 |
let audioStream = null;
|
| 29 |
let useRealAudio = false; // 是否使用真實音訊數據
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
/**
|
| 32 |
* 啟動真實音訊分析
|
| 33 |
*/
|
|
|
|
| 91 |
console.log('🛑 真實音訊分析已停止');
|
| 92 |
}
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
function draw360Waveform() {
|
| 95 |
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 96 |
|
| 97 |
const time = Date.now() * 0.001;
|
| 98 |
|
| 99 |
+
// 如果有真實音訊數據,使用它
|
| 100 |
+
if (useRealAudio && analyser && dataArray) {
|
| 101 |
+
analyser.getByteFrequencyData(dataArray); // 獲取頻率數據(0-255)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
}
|
| 103 |
|
| 104 |
// 繪製多層波形(淺色主題)
|
|
|
|
| 115 |
|
| 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 |
|
static/frontend/js/location.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 24 |
+
if (!navigator.geolocation) {
|
| 25 |
+
console.warn('⚠️ 此瀏覽器不支援定位功能');
|
| 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, // 不需要高精度(省電)
|
| 36 |
+
timeout: 10000,
|
| 37 |
+
maximumAge: 60000 // 接受 1 分鐘內的快取位置
|
| 38 |
+
});
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
console.log('✅ 位置權限已授予');
|
| 42 |
+
handlePositionUpdate(position);
|
| 43 |
+
|
| 44 |
+
// 開始持續追蹤(每 30 秒更新一次)
|
| 45 |
+
watchId = navigator.geolocation.watchPosition(
|
| 46 |
+
handlePositionUpdate,
|
| 47 |
+
handlePositionError,
|
| 48 |
+
{
|
| 49 |
+
enableHighAccuracy: false,
|
| 50 |
+
timeout: 10000,
|
| 51 |
+
maximumAge: 60000
|
| 52 |
+
}
|
| 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,
|
| 91 |
+
accuracy: accuracy,
|
| 92 |
+
heading: heading || 0,
|
| 93 |
+
speed: speed || 0,
|
| 94 |
+
timestamp: timestamp
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
// 發送環境快照到後端
|
| 98 |
+
sendEnvironmentSnapshot(lastPosition);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/**
|
| 102 |
+
* 處理定位錯誤
|
| 103 |
+
*/
|
| 104 |
+
function handlePositionError(error) {
|
| 105 |
+
let errorMessage = '';
|
| 106 |
+
|
| 107 |
+
switch (error.code) {
|
| 108 |
+
case error.PERMISSION_DENIED:
|
| 109 |
+
errorMessage = '用戶拒絕定位權限';
|
| 110 |
+
console.warn('⚠️ 位置權限被拒絕,部分功能(如查詢附近公車)將無法使用');
|
| 111 |
+
break;
|
| 112 |
+
case error.POSITION_UNAVAILABLE:
|
| 113 |
+
errorMessage = '無法取得位置資訊';
|
| 114 |
+
console.warn('⚠️ 位置資訊暫時無法取得');
|
| 115 |
+
break;
|
| 116 |
+
case error.TIMEOUT:
|
| 117 |
+
errorMessage = '定位請求逾時';
|
| 118 |
+
console.warn('⚠️ 定位請求逾時');
|
| 119 |
+
break;
|
| 120 |
+
default:
|
| 121 |
+
errorMessage = '未知錯誤';
|
| 122 |
+
console.warn('⚠️ 定位發生未知錯誤:', error);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// 即使定位失敗,也發送一個沒有位置的快照(包含時間等資訊)
|
| 126 |
+
sendEnvironmentSnapshot({
|
| 127 |
+
lat: null,
|
| 128 |
+
lon: null,
|
| 129 |
+
error: errorMessage,
|
| 130 |
+
timestamp: Date.now()
|
| 131 |
+
});
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/**
|
| 135 |
+
* 發送環境快照到後端
|
| 136 |
+
* 欄位名稱需與後端 EnvironmentContextService 期望的一致
|
| 137 |
+
*/
|
| 138 |
+
function sendEnvironmentSnapshot(positionData) {
|
| 139 |
+
if (!wsManager || !wsManager.isConnected()) {
|
| 140 |
+
console.warn('⚠️ WebSocket 未連線,跳過環境快照發送');
|
| 141 |
+
return;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
// 構建環境快照資料(欄位名稱對應後端 context_service.py)
|
| 145 |
+
const snapshot = {
|
| 146 |
+
// 位置資訊(後端期望的欄位名稱)
|
| 147 |
+
lat: positionData.lat,
|
| 148 |
+
lon: positionData.lon,
|
| 149 |
+
accuracy_m: positionData.accuracy, // 後端期望 accuracy_m
|
| 150 |
+
heading_deg: positionData.heading, // 後端期望 heading_deg
|
| 151 |
+
speed: positionData.speed,
|
| 152 |
+
timestamp: positionData.timestamp || Date.now(),
|
| 153 |
+
|
| 154 |
+
// 時區與語系(後端期望的欄位名稱)
|
| 155 |
+
tz: Intl.DateTimeFormat().resolvedOptions().timeZone, // 後端期望 tz
|
| 156 |
+
locale: navigator.language, // 後端期望 locale
|
| 157 |
+
|
| 158 |
+
// 裝置資訊(後端期望 device 物件)
|
| 159 |
+
device: {
|
| 160 |
+
user_agent: navigator.userAgent,
|
| 161 |
+
platform: navigator.platform,
|
| 162 |
+
screen_width: window.screen.width,
|
| 163 |
+
screen_height: window.screen.height,
|
| 164 |
+
viewport_width: window.innerWidth,
|
| 165 |
+
viewport_height: window.innerHeight
|
| 166 |
+
},
|
| 167 |
+
|
| 168 |
+
// 錯誤資訊(如果有)
|
| 169 |
+
error: positionData.error || null
|
| 170 |
+
};
|
| 171 |
+
|
| 172 |
+
// 發送 WebSocket 訊息
|
| 173 |
+
wsManager.send({
|
| 174 |
+
type: 'env_snapshot',
|
| 175 |
+
...snapshot
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
console.log('📤 環境快照已發送:', {
|
| 179 |
+
lat: snapshot.lat?.toFixed(6),
|
| 180 |
+
lon: snapshot.lon?.toFixed(6),
|
| 181 |
+
accuracy_m: snapshot.accuracy_m,
|
| 182 |
+
tz: snapshot.tz
|
| 183 |
+
});
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
/**
|
| 187 |
+
* 手動觸發位置更新(用於用戶主動請求)
|
| 188 |
+
*/
|
| 189 |
+
async function requestLocationUpdate() {
|
| 190 |
+
if (!navigator.geolocation) {
|
| 191 |
+
console.warn('⚠️ 此瀏覽器不支援定位功能');
|
| 192 |
+
return null;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
console.log('📍 手動請求位置更新...');
|
| 196 |
+
|
| 197 |
+
try {
|
| 198 |
+
const position = await new Promise((resolve, reject) => {
|
| 199 |
+
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
| 200 |
+
enableHighAccuracy: true, // 手動請求時使用高精度
|
| 201 |
+
timeout: 10000,
|
| 202 |
+
maximumAge: 0 // 不使用快取
|
| 203 |
+
});
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
handlePositionUpdate(position);
|
| 207 |
+
return lastPosition;
|
| 208 |
+
|
| 209 |
+
} catch (error) {
|
| 210 |
+
handlePositionError(error);
|
| 211 |
+
return null;
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
/**
|
| 216 |
+
* 取得最後已知位置
|
| 217 |
+
*/
|
| 218 |
+
function getLastKnownPosition() {
|
| 219 |
+
return lastPosition;
|
| 220 |
+
}
|
static/frontend/js/login.js
DELETED
|
@@ -1,730 +0,0 @@
|
|
| 1 |
-
// 全域控制:預設靜音 console.log/info/debug,僅保留錯誤與重要訊息
|
| 2 |
-
(function silenceConsoleLogs() {
|
| 3 |
-
if (typeof window !== 'undefined' && !window.BLOOMWARE_DEBUG && !console.__bloomwareSilenced) {
|
| 4 |
-
const noop = () => {};
|
| 5 |
-
console.log = noop;
|
| 6 |
-
console.info = noop;
|
| 7 |
-
console.debug = noop;
|
| 8 |
-
console.__bloomwareSilenced = true;
|
| 9 |
-
}
|
| 10 |
-
})();
|
| 11 |
-
|
| 12 |
-
/**
|
| 13 |
-
* 平滑跳轉到聊天室(帶過渡動畫)
|
| 14 |
-
*/
|
| 15 |
-
function smoothTransitionToChatRoom(delay = 800) {
|
| 16 |
-
console.log('🌸 開始平滑過渡到聊天室...');
|
| 17 |
-
|
| 18 |
-
// 顯示載入覆蓋層
|
| 19 |
-
const loadingOverlay = document.getElementById('loadingOverlay');
|
| 20 |
-
if (loadingOverlay) {
|
| 21 |
-
loadingOverlay.classList.add('active');
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
// 觸發頁面淡出動畫
|
| 25 |
-
document.body.classList.add('page-transitioning');
|
| 26 |
-
|
| 27 |
-
// 延遲跳轉,讓動畫完成
|
| 28 |
-
setTimeout(() => {
|
| 29 |
-
window.location.href = '/static/index.html';
|
| 30 |
-
}, delay);
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
// ========== Google OAuth PKCE 登入流程 ==========
|
| 34 |
-
|
| 35 |
-
/**
|
| 36 |
-
* 生成 PKCE code_verifier 和 code_challenge
|
| 37 |
-
*/
|
| 38 |
-
async function generatePKCE() {
|
| 39 |
-
// 生成符合 RFC 7636 規範的 code_verifier
|
| 40 |
-
// 必須是 43-128 字元,只能包含 [A-Za-z0-9-._~]
|
| 41 |
-
const array = new Uint8Array(32);
|
| 42 |
-
crypto.getRandomValues(array);
|
| 43 |
-
|
| 44 |
-
// 轉換為 base64url 格式(RFC 7636 要求)
|
| 45 |
-
const base64 = btoa(String.fromCharCode(...array));
|
| 46 |
-
const codeVerifier = base64
|
| 47 |
-
.replace(/\+/g, '-')
|
| 48 |
-
.replace(/\//g, '_')
|
| 49 |
-
.replace(/=/g, '');
|
| 50 |
-
|
| 51 |
-
// 計算 code_challenge = base64url(SHA256(code_verifier))
|
| 52 |
-
const encoder = new TextEncoder();
|
| 53 |
-
const data = encoder.encode(codeVerifier);
|
| 54 |
-
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
| 55 |
-
|
| 56 |
-
// 轉換 hash 為 base64url
|
| 57 |
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
| 58 |
-
const hashBase64 = btoa(String.fromCharCode(...hashArray));
|
| 59 |
-
const codeChallenge = hashBase64
|
| 60 |
-
.replace(/\+/g, '-')
|
| 61 |
-
.replace(/\//g, '_')
|
| 62 |
-
.replace(/=/g, '');
|
| 63 |
-
|
| 64 |
-
console.log('🔐 PKCE 生成:', {
|
| 65 |
-
verifierLength: codeVerifier.length,
|
| 66 |
-
challengeLength: codeChallenge.length
|
| 67 |
-
});
|
| 68 |
-
|
| 69 |
-
return { codeVerifier, codeChallenge };
|
| 70 |
-
}
|
| 71 |
-
|
| 72 |
-
/**
|
| 73 |
-
* Google OAuth 登入(使用後端生成 PKCE)
|
| 74 |
-
*/
|
| 75 |
-
async function handleGoogleLogin() {
|
| 76 |
-
try {
|
| 77 |
-
console.log('🚀 開始 Google OAuth 登入流程...');
|
| 78 |
-
|
| 79 |
-
// 從後端獲取授權 URL 和 PKCE 參數
|
| 80 |
-
const response = await fetch('/auth/google/url');
|
| 81 |
-
const data = await response.json();
|
| 82 |
-
|
| 83 |
-
if (!data.success) {
|
| 84 |
-
throw new Error(data.error || '獲取授權 URL 失敗');
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
console.log('✅ 獲取授權 URL 成功');
|
| 88 |
-
|
| 89 |
-
// 存儲 PKCE 參數到 sessionStorage
|
| 90 |
-
sessionStorage.setItem('oauth_state', data.state);
|
| 91 |
-
sessionStorage.setItem('oauth_code_verifier', data.code_verifier);
|
| 92 |
-
|
| 93 |
-
console.log('🔐 PKCE 參數已存儲:', {
|
| 94 |
-
state: data.state.substring(0, 8) + '...',
|
| 95 |
-
codeVerifier: data.code_verifier.substring(0, 8) + '...'
|
| 96 |
-
});
|
| 97 |
-
|
| 98 |
-
// 重定向到 Google 授權頁面
|
| 99 |
-
console.log('🌐 重定向到 Google 授權頁面...');
|
| 100 |
-
|
| 101 |
-
const inIframe = window.self !== window.top;
|
| 102 |
-
if (inIframe) {
|
| 103 |
-
// HuggingFace 主頁會以 iframe 方式載入 Space,直接跳轉會被瀏覽器阻擋
|
| 104 |
-
window.open(data.auth_url, '_blank', 'noopener,noreferrer');
|
| 105 |
-
} else {
|
| 106 |
-
window.location.href = data.auth_url;
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
} catch (error) {
|
| 110 |
-
console.error('❌ OAuth 初始化失敗:', error);
|
| 111 |
-
alert('Google 登入初始化失敗,請稍後再試');
|
| 112 |
-
}
|
| 113 |
-
}
|
| 114 |
-
|
| 115 |
-
/**
|
| 116 |
-
* 處理 OAuth Callback
|
| 117 |
-
*/
|
| 118 |
-
async function handleOAuthCallback() {
|
| 119 |
-
const urlParams = new URLSearchParams(window.location.search);
|
| 120 |
-
const code = urlParams.get('code');
|
| 121 |
-
const state = urlParams.get('state');
|
| 122 |
-
|
| 123 |
-
if (!code || !state) return;
|
| 124 |
-
|
| 125 |
-
// 嘗試讀取 state,若不存在則讓流程繼續改由後端驗證
|
| 126 |
-
let savedState = null;
|
| 127 |
-
try {
|
| 128 |
-
savedState = sessionStorage.getItem('oauth_state');
|
| 129 |
-
} catch (err) {
|
| 130 |
-
console.warn('⚠️ 無法存取 sessionStorage:', err);
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
if (savedState && state !== savedState) {
|
| 134 |
-
console.warn('⚠️ State 不匹配,可能是跨分頁或 session 過期,交給後端再次驗證');
|
| 135 |
-
}
|
| 136 |
-
|
| 137 |
-
try {
|
| 138 |
-
// 取得 code_verifier(使用後端生成的)
|
| 139 |
-
let codeVerifier = null;
|
| 140 |
-
try {
|
| 141 |
-
codeVerifier = sessionStorage.getItem('oauth_code_verifier');
|
| 142 |
-
} catch (err) {
|
| 143 |
-
console.warn('⚠️ 無法讀取 code_verifier:', err);
|
| 144 |
-
}
|
| 145 |
-
|
| 146 |
-
console.log('🔍 OAuth 回調驗證:', {
|
| 147 |
-
hasCode: !!code,
|
| 148 |
-
hasState: !!state,
|
| 149 |
-
hasCodeVerifier: !!codeVerifier,
|
| 150 |
-
stateMatch: savedState ? state === savedState : 'skip'
|
| 151 |
-
});
|
| 152 |
-
|
| 153 |
-
if (!codeVerifier) {
|
| 154 |
-
console.error('❌ 缺少 code_verifier,可能是頁面刷新或 session 過期');
|
| 155 |
-
alert('登入會話已過期,請重新登入');
|
| 156 |
-
sessionStorage.clear();
|
| 157 |
-
window.location.href = '/static/login.html';
|
| 158 |
-
return;
|
| 159 |
-
}
|
| 160 |
-
|
| 161 |
-
console.log('📤 發送授權碼到後端...');
|
| 162 |
-
|
| 163 |
-
// 調用後端交換 token
|
| 164 |
-
const response = await fetch('/auth/google/callback', {
|
| 165 |
-
method: 'POST',
|
| 166 |
-
headers: {
|
| 167 |
-
'Content-Type': 'application/json'
|
| 168 |
-
},
|
| 169 |
-
body: JSON.stringify({
|
| 170 |
-
code: code,
|
| 171 |
-
code_verifier: codeVerifier,
|
| 172 |
-
state: state
|
| 173 |
-
})
|
| 174 |
-
});
|
| 175 |
-
|
| 176 |
-
if (response.ok) {
|
| 177 |
-
const data = await response.json();
|
| 178 |
-
|
| 179 |
-
// 儲存 JWT token(後端返回 access_token)
|
| 180 |
-
if (!data.access_token) {
|
| 181 |
-
throw new Error('後端未返回 access_token');
|
| 182 |
-
}
|
| 183 |
-
localStorage.setItem('jwt_token', data.access_token);
|
| 184 |
-
|
| 185 |
-
// 清理 sessionStorage
|
| 186 |
-
sessionStorage.removeItem('oauth_code_verifier');
|
| 187 |
-
sessionStorage.removeItem('oauth_state');
|
| 188 |
-
|
| 189 |
-
console.log('✅ 登入成功!導向聊天室...');
|
| 190 |
-
console.log('🔑 JWT Token 已存儲,長度:', data.access_token.length);
|
| 191 |
-
|
| 192 |
-
// 使用平滑過渡
|
| 193 |
-
smoothTransitionToChatRoom(600);
|
| 194 |
-
} else {
|
| 195 |
-
throw new Error('Token 交換失敗');
|
| 196 |
-
}
|
| 197 |
-
|
| 198 |
-
} catch (error) {
|
| 199 |
-
console.error('❌ OAuth callback 處理失敗:', error);
|
| 200 |
-
alert('登入失敗,請重新登入');
|
| 201 |
-
window.location.href = '/static/login.html';
|
| 202 |
-
}
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
// ========== iOS 設備檢測與權限管理 ==========
|
| 206 |
-
|
| 207 |
-
/**
|
| 208 |
-
* 檢測是否為 iOS 設備
|
| 209 |
-
*/
|
| 210 |
-
function isIOSDevice() {
|
| 211 |
-
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
|
| 212 |
-
|
| 213 |
-
// 檢測 iPhone/iPad/iPod
|
| 214 |
-
const isIOS = /iPad|iPhone|iPod/.test(userAgent) && !window.MSStream;
|
| 215 |
-
|
| 216 |
-
// 檢測 iPad on iOS 13+ (在桌面模式下)
|
| 217 |
-
const isIPadOS = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
|
| 218 |
-
|
| 219 |
-
return isIOS || isIPadOS;
|
| 220 |
-
}
|
| 221 |
-
|
| 222 |
-
/**
|
| 223 |
-
* iOS 設備權限請求管理器
|
| 224 |
-
*/
|
| 225 |
-
class IOSPermissionManager {
|
| 226 |
-
constructor() {
|
| 227 |
-
this.permissionsGranted = false;
|
| 228 |
-
this.audioStream = null;
|
| 229 |
-
}
|
| 230 |
-
|
| 231 |
-
/**
|
| 232 |
-
* 請求麥克風和揚聲器權限(使用原生 Safari 彈窗)
|
| 233 |
-
*/
|
| 234 |
-
async requestPermissions() {
|
| 235 |
-
if (this.permissionsGranted) {
|
| 236 |
-
console.log('✅ iOS 權限已授予');
|
| 237 |
-
return true;
|
| 238 |
-
}
|
| 239 |
-
|
| 240 |
-
try {
|
| 241 |
-
console.log('🍎 檢測到 iOS 設備,請求原生權限...');
|
| 242 |
-
|
| 243 |
-
// 使用原生的 getUserMedia API 請求麥克風權限
|
| 244 |
-
// Safari 會自動顯示系統級別的權限彈窗
|
| 245 |
-
this.audioStream = await navigator.mediaDevices.getUserMedia({
|
| 246 |
-
audio: {
|
| 247 |
-
channelCount: 1,
|
| 248 |
-
sampleRate: 16000,
|
| 249 |
-
echoCancellation: true,
|
| 250 |
-
noiseSuppression: true,
|
| 251 |
-
autoGainControl: true
|
| 252 |
-
}
|
| 253 |
-
});
|
| 254 |
-
|
| 255 |
-
console.log('✅ iOS 麥克風權限已授予');
|
| 256 |
-
|
| 257 |
-
// 測試音頻播放權限(揚聲器)
|
| 258 |
-
// iOS 需要用戶互動才能播放音頻,但 getUserMedia 成功後通常就可以播放了
|
| 259 |
-
try {
|
| 260 |
-
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
| 261 |
-
await audioContext.resume();
|
| 262 |
-
console.log('✅ iOS 音頻播放權限已授予');
|
| 263 |
-
audioContext.close();
|
| 264 |
-
} catch (err) {
|
| 265 |
-
console.warn('⚠️ 音頻播放權限測試失敗(不影響麥克風功能):', err);
|
| 266 |
-
}
|
| 267 |
-
|
| 268 |
-
this.permissionsGranted = true;
|
| 269 |
-
|
| 270 |
-
// 顯示成功提示
|
| 271 |
-
this.showPermissionStatus('✅ 權限已授予,可以使用語音功能', 'success');
|
| 272 |
-
|
| 273 |
-
return true;
|
| 274 |
-
|
| 275 |
-
} catch (error) {
|
| 276 |
-
console.error('❌ iOS 權限請求失敗:', error);
|
| 277 |
-
|
| 278 |
-
let errorMessage = '❌ 無法獲取麥克風權限';
|
| 279 |
-
|
| 280 |
-
if (error.name === 'NotAllowedError') {
|
| 281 |
-
errorMessage = '❌ 您拒絕了麥克風權限,請在 Safari 設定中允許';
|
| 282 |
-
} else if (error.name === 'NotFoundError') {
|
| 283 |
-
errorMessage = '❌ 未找到麥克風設備';
|
| 284 |
-
} else if (error.name === 'NotReadableError') {
|
| 285 |
-
errorMessage = '❌ 麥克風正在被其他應用使用';
|
| 286 |
-
}
|
| 287 |
-
|
| 288 |
-
this.showPermissionStatus(errorMessage, 'error');
|
| 289 |
-
|
| 290 |
-
// 顯示詳細指引
|
| 291 |
-
this.showIOSPermissionGuide();
|
| 292 |
-
|
| 293 |
-
return false;
|
| 294 |
-
}
|
| 295 |
-
}
|
| 296 |
-
|
| 297 |
-
/**
|
| 298 |
-
* 顯示 iOS 權限設定指引
|
| 299 |
-
*/
|
| 300 |
-
showIOSPermissionGuide() {
|
| 301 |
-
const guide = `
|
| 302 |
-
📱 如何在 Safari 中啟用麥克風權限:
|
| 303 |
-
|
| 304 |
-
1. 開啟「設定」App
|
| 305 |
-
2. 下滑找到「Safari」
|
| 306 |
-
3. 點選「麥克風」
|
| 307 |
-
4. 選擇「詢問」或「允許」
|
| 308 |
-
5. 重新載入此頁面
|
| 309 |
-
|
| 310 |
-
或者:點擊網址列左側的「aA」圖示 → 網站設定 → 麥克風 → 允許
|
| 311 |
-
`;
|
| 312 |
-
|
| 313 |
-
// 在控制台顯示指引
|
| 314 |
-
console.log(guide);
|
| 315 |
-
|
| 316 |
-
// 可選:使用原生 alert 顯示(iOS 上更友善)
|
| 317 |
-
if (confirm('無法獲取麥克風權限。是否查看設定指引?')) {
|
| 318 |
-
alert(guide);
|
| 319 |
-
}
|
| 320 |
-
}
|
| 321 |
-
|
| 322 |
-
/**
|
| 323 |
-
* 顯示權限狀態訊息
|
| 324 |
-
*/
|
| 325 |
-
showPermissionStatus(message, type = 'info') {
|
| 326 |
-
// 尋找狀態顯示元素
|
| 327 |
-
const statusElement = document.getElementById('iosPermissionStatus') ||
|
| 328 |
-
document.getElementById('voiceLoginStatus');
|
| 329 |
-
|
| 330 |
-
if (statusElement) {
|
| 331 |
-
statusElement.textContent = message;
|
| 332 |
-
statusElement.style.display = 'block';
|
| 333 |
-
statusElement.style.color = type === 'error' ? '#f5576c' :
|
| 334 |
-
type === 'success' ? '#10b981' :
|
| 335 |
-
'rgba(0,0,0,0.6)';
|
| 336 |
-
|
| 337 |
-
// 成功訊息 3 秒後自動隱藏
|
| 338 |
-
if (type === 'success') {
|
| 339 |
-
setTimeout(() => {
|
| 340 |
-
statusElement.style.display = 'none';
|
| 341 |
-
}, 3000);
|
| 342 |
-
}
|
| 343 |
-
}
|
| 344 |
-
}
|
| 345 |
-
|
| 346 |
-
/**
|
| 347 |
-
* 清理音頻流
|
| 348 |
-
*/
|
| 349 |
-
cleanup() {
|
| 350 |
-
if (this.audioStream) {
|
| 351 |
-
this.audioStream.getTracks().forEach(track => track.stop());
|
| 352 |
-
this.audioStream = null;
|
| 353 |
-
}
|
| 354 |
-
}
|
| 355 |
-
}
|
| 356 |
-
|
| 357 |
-
// 全域 iOS 權限管理器實例
|
| 358 |
-
const iosPermissionManager = new IOSPermissionManager();
|
| 359 |
-
|
| 360 |
-
// ========== 頁面初始化 ==========
|
| 361 |
-
|
| 362 |
-
// 頁面載入時的淡入動畫
|
| 363 |
-
window.addEventListener('load', () => {
|
| 364 |
-
// 移除淡入動畫類別(避免重複觸發)
|
| 365 |
-
setTimeout(() => {
|
| 366 |
-
document.body.classList.remove('page-entering');
|
| 367 |
-
}, 500);
|
| 368 |
-
});
|
| 369 |
-
|
| 370 |
-
// 檢查是否已登入
|
| 371 |
-
const token = localStorage.getItem('jwt_token');
|
| 372 |
-
if (token && !window.location.search.includes('code=')) {
|
| 373 |
-
// 已登入,使用平滑過渡導向聊天室
|
| 374 |
-
console.log('✅ 已登入,自動跳轉到聊天室');
|
| 375 |
-
smoothTransitionToChatRoom(400);
|
| 376 |
-
}
|
| 377 |
-
|
| 378 |
-
// 檢查是否為 OAuth callback
|
| 379 |
-
if (window.location.search.includes('code=')) {
|
| 380 |
-
handleOAuthCallback();
|
| 381 |
-
}
|
| 382 |
-
|
| 383 |
-
// iOS 設備自動請求權限
|
| 384 |
-
if (isIOSDevice()) {
|
| 385 |
-
console.log('🍎 偵測到 iOS 設備');
|
| 386 |
-
|
| 387 |
-
// 等待頁面完全載入後再請求權限
|
| 388 |
-
window.addEventListener('load', async () => {
|
| 389 |
-
// 延遲 500ms,確保 UI 完全載入
|
| 390 |
-
setTimeout(async () => {
|
| 391 |
-
console.log('🍎 自動請求 iOS 權限...');
|
| 392 |
-
await iosPermissionManager.requestPermissions();
|
| 393 |
-
}, 500);
|
| 394 |
-
});
|
| 395 |
-
}
|
| 396 |
-
|
| 397 |
-
// ========== 語音登入功能 ==========
|
| 398 |
-
|
| 399 |
-
class VoiceLoginManager {
|
| 400 |
-
constructor() {
|
| 401 |
-
this.ws = null;
|
| 402 |
-
this.audioContext = null;
|
| 403 |
-
this.audioStream = null;
|
| 404 |
-
this.audioProcessor = null;
|
| 405 |
-
this.isRecording = false;
|
| 406 |
-
this.chunkCount = 0; // 添加chunk計數器
|
| 407 |
-
|
| 408 |
-
this.statusElement = document.getElementById('voiceLoginStatus');
|
| 409 |
-
this.btnElement = document.getElementById('voiceLoginBtn');
|
| 410 |
-
this.btnTextElement = document.getElementById('voiceLoginBtnText');
|
| 411 |
-
}
|
| 412 |
-
|
| 413 |
-
// 建立 WebSocket 連線(匿名,用於語音登入)
|
| 414 |
-
async connectWebSocket() {
|
| 415 |
-
return new Promise((resolve, reject) => {
|
| 416 |
-
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 417 |
-
const wsUrl = `${protocol}//${window.location.host}/ws?token=anonymous_voice_login`;
|
| 418 |
-
|
| 419 |
-
console.log('🔌 建立語音登入 WebSocket:', wsUrl);
|
| 420 |
-
|
| 421 |
-
this.ws = new WebSocket(wsUrl);
|
| 422 |
-
|
| 423 |
-
this.ws.onopen = () => {
|
| 424 |
-
console.log('✅ WebSocket 已連線');
|
| 425 |
-
resolve();
|
| 426 |
-
};
|
| 427 |
-
|
| 428 |
-
this.ws.onerror = (error) => {
|
| 429 |
-
console.error('❌ WebSocket 連線失敗:', error);
|
| 430 |
-
reject(error);
|
| 431 |
-
};
|
| 432 |
-
|
| 433 |
-
this.ws.onmessage = (event) => {
|
| 434 |
-
this.handleWebSocketMessage(JSON.parse(event.data));
|
| 435 |
-
};
|
| 436 |
-
|
| 437 |
-
// 10 秒超時
|
| 438 |
-
setTimeout(() => reject(new Error('WebSocket 連線超時')), 10000);
|
| 439 |
-
});
|
| 440 |
-
}
|
| 441 |
-
|
| 442 |
-
// 處理 WebSocket 訊息
|
| 443 |
-
handleWebSocketMessage(data) {
|
| 444 |
-
console.log('📩 收到訊息:', data.type);
|
| 445 |
-
|
| 446 |
-
switch (data.type) {
|
| 447 |
-
case 'voice_login_status':
|
| 448 |
-
if (data.message === 'recording_started') {
|
| 449 |
-
this.showStatus('🎙️ 開始錄音,請說話 5 秒...');
|
| 450 |
-
}
|
| 451 |
-
break;
|
| 452 |
-
|
| 453 |
-
case 'voice_login_result':
|
| 454 |
-
this.handleVoiceLoginResult(data);
|
| 455 |
-
break;
|
| 456 |
-
|
| 457 |
-
default:
|
| 458 |
-
console.log('📩 收到其他訊息:', data);
|
| 459 |
-
}
|
| 460 |
-
}
|
| 461 |
-
|
| 462 |
-
// 處理語音登入結果
|
| 463 |
-
async handleVoiceLoginResult(data) {
|
| 464 |
-
if (data.success) {
|
| 465 |
-
console.log('✅ 語音登入成功!');
|
| 466 |
-
console.log('👤 用戶:', data.user.name);
|
| 467 |
-
console.log('😊 情緒:', data.emotion?.label);
|
| 468 |
-
console.log('💬 歡迎詞:', data.welcome);
|
| 469 |
-
|
| 470 |
-
// 成功僅提示登入完成,不在登入頁顯示歡迎詞
|
| 471 |
-
this.showStatus('✅ 登入成功,正在跳轉…', 'success');
|
| 472 |
-
|
| 473 |
-
// 模擬生成 JWT(實際應該從後端取得)
|
| 474 |
-
// 這裡假設後端已經將 JWT 包含在 voice_login_result 中
|
| 475 |
-
if (data.token) {
|
| 476 |
-
localStorage.setItem('jwt_token', data.token);
|
| 477 |
-
} else {
|
| 478 |
-
console.warn('⚠️ 後端未返回 JWT');
|
| 479 |
-
}
|
| 480 |
-
|
| 481 |
-
// 將辨識到的情緒帶到聊天室主題(由 agent.js 啟動時套用)
|
| 482 |
-
try {
|
| 483 |
-
const emo = (data.emotion && (data.emotion.label || data.emotion)) || '';
|
| 484 |
-
if (emo) localStorage.setItem('lastEmotion', String(emo));
|
| 485 |
-
} catch (_) {}
|
| 486 |
-
|
| 487 |
-
// 關閉 WS 與音訊資源,避免殘留
|
| 488 |
-
try { this.ws && this.ws.readyState === WebSocket.OPEN && this.ws.close(1000, 'voice login done'); } catch(_) {}
|
| 489 |
-
this.cleanup();
|
| 490 |
-
|
| 491 |
-
// 使用平滑過渡到聊天室
|
| 492 |
-
smoothTransitionToChatRoom(800);
|
| 493 |
-
|
| 494 |
-
} else {
|
| 495 |
-
console.error('❌ 語音登入失敗:', data.error);
|
| 496 |
-
if (data.detail) {
|
| 497 |
-
console.error('🔍 語音登入錯誤細節:', data.detail);
|
| 498 |
-
}
|
| 499 |
-
|
| 500 |
-
let errorMsg = '語音登入失敗';
|
| 501 |
-
switch (data.error) {
|
| 502 |
-
case 'USER_NOT_BOUND':
|
| 503 |
-
errorMsg = '❌ 語音未綁定!請先點擊上方 Google 登入按鈕登入,然後在聊天室中綁定您的語音';
|
| 504 |
-
// 顯示額外的指引
|
| 505 |
-
setTimeout(() => {
|
| 506 |
-
this.showStatus('💡 步驟:1.Google登入 → 2.進入聊天室 → 3.說「綁定語音」或使用語音註冊功能', 'info');
|
| 507 |
-
}, 3000);
|
| 508 |
-
break;
|
| 509 |
-
case 'LOW_SNR':
|
| 510 |
-
errorMsg = '❌ 環境太吵或聲音太小,請重試';
|
| 511 |
-
break;
|
| 512 |
-
case 'AUDIO_TOO_SHORT':
|
| 513 |
-
errorMsg = '❌ 錄音時間不足,請說話至少 5 秒';
|
| 514 |
-
break;
|
| 515 |
-
default:
|
| 516 |
-
errorMsg = `❌ ${data.error || '未知錯誤'}`;
|
| 517 |
-
}
|
| 518 |
-
|
| 519 |
-
this.showStatus(errorMsg, 'error');
|
| 520 |
-
this.stopRecording();
|
| 521 |
-
}
|
| 522 |
-
}
|
| 523 |
-
|
| 524 |
-
// 開始錄音
|
| 525 |
-
async startRecording() {
|
| 526 |
-
try {
|
| 527 |
-
this.showStatus('🔌 正在連線...');
|
| 528 |
-
|
| 529 |
-
// 建立 WebSocket 連線
|
| 530 |
-
await this.connectWebSocket();
|
| 531 |
-
|
| 532 |
-
// iOS 設備檢查權限
|
| 533 |
-
if (isIOSDevice()) {
|
| 534 |
-
if (!iosPermissionManager.permissionsGranted) {
|
| 535 |
-
console.log('🍎 iOS 設備需要先授權權限');
|
| 536 |
-
this.showStatus('🍎 正在請求麥克風權限...', 'info');
|
| 537 |
-
|
| 538 |
-
const granted = await iosPermissionManager.requestPermissions();
|
| 539 |
-
if (!granted) {
|
| 540 |
-
this.showStatus('❌ 權限未授予,無法使用語音登入', 'error');
|
| 541 |
-
this.cleanup();
|
| 542 |
-
return;
|
| 543 |
-
}
|
| 544 |
-
}
|
| 545 |
-
}
|
| 546 |
-
|
| 547 |
-
// 請求麥克風權限(iOS 已經在上面授權過了)
|
| 548 |
-
this.audioStream = await navigator.mediaDevices.getUserMedia({
|
| 549 |
-
audio: {
|
| 550 |
-
channelCount: 1,
|
| 551 |
-
sampleRate: 16000,
|
| 552 |
-
echoCancellation: true,
|
| 553 |
-
noiseSuppression: true,
|
| 554 |
-
autoGainControl: true // iOS 優化
|
| 555 |
-
}
|
| 556 |
-
});
|
| 557 |
-
|
| 558 |
-
// 建立 AudioContext
|
| 559 |
-
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
| 560 |
-
sampleRate: 16000
|
| 561 |
-
});
|
| 562 |
-
|
| 563 |
-
const source = this.audioContext.createMediaStreamSource(this.audioStream);
|
| 564 |
-
|
| 565 |
-
// 使用 ScriptProcessor 或 AudioWorklet
|
| 566 |
-
if (this.audioContext.audioWorklet) {
|
| 567 |
-
// 使用更現代的 AudioWorklet(暫時先用 ScriptProcessor)
|
| 568 |
-
this.audioProcessor = this.audioContext.createScriptProcessor(4096, 1, 1);
|
| 569 |
-
} else {
|
| 570 |
-
this.audioProcessor = this.audioContext.createScriptProcessor(4096, 1, 1);
|
| 571 |
-
}
|
| 572 |
-
|
| 573 |
-
this.audioProcessor.onaudioprocess = (e) => {
|
| 574 |
-
if (!this.isRecording) return;
|
| 575 |
-
|
| 576 |
-
const inputData = e.inputBuffer.getChannelData(0);
|
| 577 |
-
const pcm16 = this.float32ToPCM16(inputData);
|
| 578 |
-
const base64 = this.arrayBufferToBase64(pcm16);
|
| 579 |
-
|
| 580 |
-
// 發送音頻塊
|
| 581 |
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
| 582 |
-
this.ws.send(JSON.stringify({
|
| 583 |
-
type: 'audio_chunk',
|
| 584 |
-
pcm16_base64: base64
|
| 585 |
-
}));
|
| 586 |
-
this.chunkCount++;
|
| 587 |
-
console.log(`🎤 發送音頻chunk #${this.chunkCount},大小: ${pcm16.byteLength} bytes`);
|
| 588 |
-
} else {
|
| 589 |
-
console.warn('⚠️ WebSocket未連接,無法發送音頻chunk');
|
| 590 |
-
}
|
| 591 |
-
};
|
| 592 |
-
|
| 593 |
-
source.connect(this.audioProcessor);
|
| 594 |
-
this.audioProcessor.connect(this.audioContext.destination);
|
| 595 |
-
|
| 596 |
-
// 發送開始錄音訊號
|
| 597 |
-
this.ws.send(JSON.stringify({
|
| 598 |
-
type: 'audio_start',
|
| 599 |
-
sample_rate: 16000
|
| 600 |
-
}));
|
| 601 |
-
|
| 602 |
-
this.isRecording = true;
|
| 603 |
-
this.chunkCount = 0; // 重置chunk計數器
|
| 604 |
-
this.btnElement.classList.add('recording');
|
| 605 |
-
this.btnTextElement.textContent = '錄音中...(5 秒)';
|
| 606 |
-
|
| 607 |
-
// 5 秒後自動停止(增加時間確保數據完整)
|
| 608 |
-
setTimeout(() => {
|
| 609 |
-
if (this.isRecording) {
|
| 610 |
-
this.stopRecording();
|
| 611 |
-
}
|
| 612 |
-
}, 5000);
|
| 613 |
-
|
| 614 |
-
} catch (error) {
|
| 615 |
-
console.error('❌ 啟動錄音失敗:', error);
|
| 616 |
-
|
| 617 |
-
// iOS 特定錯誤處理
|
| 618 |
-
if (isIOSDevice()) {
|
| 619 |
-
if (error.name === 'NotAllowedError') {
|
| 620 |
-
this.showStatus('❌ 麥克風權限被拒絕,請檢查 Safari 設定', 'error');
|
| 621 |
-
iosPermissionManager.showIOSPermissionGuide();
|
| 622 |
-
} else {
|
| 623 |
-
this.showStatus('❌ 無法啟動麥克風: ' + error.message, 'error');
|
| 624 |
-
}
|
| 625 |
-
} else {
|
| 626 |
-
this.showStatus('❌ 無法啟動麥克風,請檢查權限', 'error');
|
| 627 |
-
}
|
| 628 |
-
|
| 629 |
-
this.cleanup();
|
| 630 |
-
}
|
| 631 |
-
}
|
| 632 |
-
|
| 633 |
-
// 停止錄音
|
| 634 |
-
stopRecording() {
|
| 635 |
-
if (!this.isRecording) return;
|
| 636 |
-
|
| 637 |
-
console.log(`🎤 停止錄音請求,共發送 ${this.chunkCount} 個音頻chunk`);
|
| 638 |
-
|
| 639 |
-
// 先標記為停止錄音,但不立即清理資源
|
| 640 |
-
this.isRecording = false;
|
| 641 |
-
|
| 642 |
-
// 等待一小段時間,讓最後的音頻數據被處理完
|
| 643 |
-
setTimeout(() => {
|
| 644 |
-
console.log(`🎤 錄音完全結束,準備發送停止訊號`);
|
| 645 |
-
|
| 646 |
-
this.btnElement.classList.remove('recording');
|
| 647 |
-
this.btnTextElement.textContent = '���用語音登入';
|
| 648 |
-
|
| 649 |
-
// 發送停止訊號
|
| 650 |
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
| 651 |
-
this.ws.send(JSON.stringify({
|
| 652 |
-
type: 'audio_stop',
|
| 653 |
-
mode: 'voice_login'
|
| 654 |
-
}));
|
| 655 |
-
}
|
| 656 |
-
|
| 657 |
-
this.showStatus('🔄 正在辨識身份與情緒...');
|
| 658 |
-
|
| 659 |
-
// 清理音頻資源(延遲 1 秒,讓後端處理完)
|
| 660 |
-
setTimeout(() => this.cleanup(), 1000);
|
| 661 |
-
}, 200); // 等待200ms讓最後的chunk被處理
|
| 662 |
-
}
|
| 663 |
-
|
| 664 |
-
// 清理資源
|
| 665 |
-
cleanup() {
|
| 666 |
-
if (this.audioProcessor) {
|
| 667 |
-
this.audioProcessor.disconnect();
|
| 668 |
-
this.audioProcessor = null;
|
| 669 |
-
}
|
| 670 |
-
|
| 671 |
-
if (this.audioStream) {
|
| 672 |
-
this.audioStream.getTracks().forEach(track => track.stop());
|
| 673 |
-
this.audioStream = null;
|
| 674 |
-
}
|
| 675 |
-
|
| 676 |
-
if (this.audioContext && this.audioContext.state !== 'closed') {
|
| 677 |
-
this.audioContext.close();
|
| 678 |
-
this.audioContext = null;
|
| 679 |
-
}
|
| 680 |
-
}
|
| 681 |
-
|
| 682 |
-
// Float32 轉 PCM16
|
| 683 |
-
float32ToPCM16(float32Array) {
|
| 684 |
-
const pcm16 = new Int16Array(float32Array.length);
|
| 685 |
-
for (let i = 0; i < float32Array.length; i++) {
|
| 686 |
-
const s = Math.max(-1, Math.min(1, float32Array[i]));
|
| 687 |
-
pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
| 688 |
-
}
|
| 689 |
-
return pcm16.buffer;
|
| 690 |
-
}
|
| 691 |
-
|
| 692 |
-
// ArrayBuffer 轉 Base64
|
| 693 |
-
arrayBufferToBase64(buffer) {
|
| 694 |
-
let binary = '';
|
| 695 |
-
const bytes = new Uint8Array(buffer);
|
| 696 |
-
for (let i = 0; i < bytes.byteLength; i++) {
|
| 697 |
-
binary += String.fromCharCode(bytes[i]);
|
| 698 |
-
}
|
| 699 |
-
return btoa(binary);
|
| 700 |
-
}
|
| 701 |
-
|
| 702 |
-
// 顯示狀態訊息
|
| 703 |
-
showStatus(message, type = 'info') {
|
| 704 |
-
this.statusElement.textContent = message;
|
| 705 |
-
this.statusElement.style.display = 'block';
|
| 706 |
-
this.statusElement.style.color = type === 'error' ? '#f5576c' :
|
| 707 |
-
type === 'success' ? '#10b981' :
|
| 708 |
-
'rgba(0,0,0,0.6)';
|
| 709 |
-
}
|
| 710 |
-
|
| 711 |
-
// 切換錄音狀態
|
| 712 |
-
toggle() {
|
| 713 |
-
if (this.isRecording) {
|
| 714 |
-
this.stopRecording();
|
| 715 |
-
} else {
|
| 716 |
-
this.startRecording();
|
| 717 |
-
}
|
| 718 |
-
}
|
| 719 |
-
}
|
| 720 |
-
|
| 721 |
-
// 初始化語音登入管理器
|
| 722 |
-
const voiceLoginManager = new VoiceLoginManager();
|
| 723 |
-
|
| 724 |
-
// 註冊登入按鈕事件
|
| 725 |
-
document.getElementById('googleLoginBtn').addEventListener('click', handleGoogleLogin);
|
| 726 |
-
document.getElementById('voiceLoginBtn').addEventListener('click', () => {
|
| 727 |
-
voiceLoginManager.toggle();
|
| 728 |
-
});
|
| 729 |
-
|
| 730 |
-
console.log('🪷 Bloom Ware 登入頁面已載入(含語音登入)');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/frontend/js/tools.js
CHANGED
|
@@ -96,25 +96,12 @@ function addToolCard(type) {
|
|
| 96 |
}
|
| 97 |
|
| 98 |
function clearAllCards() {
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
desktopCards.forEach(card => {
|
| 102 |
card.classList.add('exiting');
|
| 103 |
setTimeout(() => card.remove(), 300);
|
| 104 |
});
|
| 105 |
-
|
| 106 |
-
// 清除手機版側邊欄卡片
|
| 107 |
-
const sidebarCards = document.getElementById('tool-sidebar-cards');
|
| 108 |
-
if (sidebarCards) {
|
| 109 |
-
const mobileCards = sidebarCards.querySelectorAll('.voice-tool-card');
|
| 110 |
-
mobileCards.forEach(card => {
|
| 111 |
-
card.classList.add('exiting');
|
| 112 |
-
setTimeout(() => card.remove(), 300);
|
| 113 |
-
});
|
| 114 |
-
}
|
| 115 |
-
|
| 116 |
usedPositions = [];
|
| 117 |
-
updateSidebarToggle();
|
| 118 |
}
|
| 119 |
|
| 120 |
// 模擬工具調用事件監聽(延遲初始化)
|
|
@@ -177,16 +164,9 @@ function getIconForTool(toolName, category) {
|
|
| 177 |
const iconMap = {
|
| 178 |
// 分類映射
|
| 179 |
'健康': '❤️',
|
| 180 |
-
'健康數據': '❤️',
|
| 181 |
'天氣': '🌤️',
|
| 182 |
'新聞': '📰',
|
| 183 |
'匯率': '💱',
|
| 184 |
-
'生活資訊': '💬',
|
| 185 |
-
'地理定位': '📍',
|
| 186 |
-
'軌道運輸': '🚇',
|
| 187 |
-
'道路運輸': '🚌',
|
| 188 |
-
'微型運具': '🚲',
|
| 189 |
-
'停車與充電': '🅿️',
|
| 190 |
'時間': '⏰',
|
| 191 |
'提醒': '⏰',
|
| 192 |
'日曆': '📅',
|
|
@@ -194,24 +174,23 @@ function getIconForTool(toolName, category) {
|
|
| 194 |
'地圖': '🗺️',
|
| 195 |
'翻譯': '🌐',
|
| 196 |
'計算': '🔢',
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
// 工具名稱映射
|
| 199 |
'healthkit_query': '❤️',
|
| 200 |
'weather_query': '🌤️',
|
| 201 |
'news_query': '📰',
|
| 202 |
-
'
|
| 203 |
-
'forward_geocode': '📍',
|
| 204 |
-
'reverse_geocode': '📍',
|
| 205 |
-
'directions': '🗺️',
|
| 206 |
-
'tdx_bus_arrival': '🚌',
|
| 207 |
-
'tdx_metro': '�',
|
| 208 |
-
'tdx_train': '🚆',
|
| 209 |
-
'tdx_thsr': '🚄',
|
| 210 |
-
'tdx_youbike': '🚲',
|
| 211 |
-
'tdx_parking': '🅿️',
|
| 212 |
'time_query': '⏰',
|
| 213 |
'reminder': '⏰',
|
| 214 |
-
'calendar': '📅'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
};
|
| 216 |
|
| 217 |
// 優先使用工具名稱匹配
|
|
@@ -240,8 +219,12 @@ function displayToolCard(toolName, toolData) {
|
|
| 240 |
const category = toolMeta.category || '未知';
|
| 241 |
const icon = getIconForTool(toolName, category);
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
const card = document.createElement('div');
|
| 244 |
-
card.className =
|
| 245 |
card.dataset.type = toolName;
|
| 246 |
|
| 247 |
// 渲染卡片內容
|
|
@@ -252,23 +235,10 @@ function displayToolCard(toolName, toolData) {
|
|
| 252 |
<div class="card-icon">${icon}</div>
|
| 253 |
<h3>${category}</h3>
|
| 254 |
</div>
|
| 255 |
-
<div class="card-content">${contentHTML}</div>
|
| 256 |
`;
|
| 257 |
|
| 258 |
-
|
| 259 |
-
// 手機版:添加到側邊欄
|
| 260 |
-
const sidebarCards = document.getElementById('tool-sidebar-cards');
|
| 261 |
-
sidebarCards.appendChild(card);
|
| 262 |
-
updateSidebarToggle();
|
| 263 |
-
} else {
|
| 264 |
-
// 桌面版:使用原有邏輯
|
| 265 |
-
const position = getNextPosition();
|
| 266 |
-
if (!position) return;
|
| 267 |
-
|
| 268 |
-
card.classList.add(position);
|
| 269 |
-
cardsContainer.appendChild(card);
|
| 270 |
-
}
|
| 271 |
-
|
| 272 |
console.log(`🃏 顯示工具卡片: ${toolName} (${category})`);
|
| 273 |
}
|
| 274 |
|
|
@@ -302,24 +272,39 @@ function renderCardContent(toolName, toolData) {
|
|
| 302 |
return renderWeatherData(weatherData);
|
| 303 |
}
|
| 304 |
|
| 305 |
-
// 模式 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
if (toolData.rate !== undefined && toolData.from_currency !== undefined) {
|
| 307 |
-
console.log('✅ 匹配到模式
|
| 308 |
return renderExchangeRate(toolData);
|
| 309 |
}
|
| 310 |
|
| 311 |
-
// 模式
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
| 318 |
}
|
| 319 |
|
| 320 |
-
// 模式
|
| 321 |
if (toolData.raw_data && typeof toolData.raw_data === 'object') {
|
| 322 |
-
console.log('✅ 匹配到模式
|
| 323 |
return renderKeyValuePairs(toolData.raw_data);
|
| 324 |
}
|
| 325 |
|
|
@@ -408,26 +393,20 @@ function renderHealthMetrics(healthData) {
|
|
| 408 |
}
|
| 409 |
|
| 410 |
/**
|
| 411 |
-
*
|
| 412 |
-
* 顯示全部新聞,但保持 3 條的高度可滾動
|
| 413 |
*/
|
| 414 |
function renderNewsList(articles) {
|
| 415 |
let html = '';
|
| 416 |
-
articles.forEach(article => {
|
| 417 |
-
// 優先使用 AI 生成的簡短摘要,否則 fallback 到標題
|
| 418 |
-
const displayText = article.summary || article.title || '無摘要';
|
| 419 |
-
|
| 420 |
html += `
|
| 421 |
-
<div class="data-row" style="margin-bottom:
|
| 422 |
-
<span style="font-
|
|
|
|
| 423 |
</div>
|
| 424 |
`;
|
| 425 |
});
|
| 426 |
|
| 427 |
-
|
| 428 |
-
return html
|
| 429 |
-
? `<div style="max-height: 90px; overflow-y: auto; padding-right: 4px;">${html}</div>`
|
| 430 |
-
: '<p>無新聞</p>';
|
| 431 |
}
|
| 432 |
|
| 433 |
/**
|
|
@@ -522,101 +501,187 @@ function renderExchangeRate(data) {
|
|
| 522 |
}
|
| 523 |
|
| 524 |
/**
|
| 525 |
-
*
|
| 526 |
*/
|
| 527 |
-
function
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
const bestMatch = data.best_match || data; // ← 兼容兩種結構
|
| 531 |
-
const results = data.results || [];
|
| 532 |
-
const query = data.query || '';
|
| 533 |
-
|
| 534 |
-
let html = '';
|
| 535 |
-
|
| 536 |
-
// 顯示查詢字串(如果有)
|
| 537 |
-
if (query) {
|
| 538 |
-
html += `
|
| 539 |
-
<div class="data-row">
|
| 540 |
-
<span class="data-label">🔍 查詢</span>
|
| 541 |
-
<span class="data-value">${query}</span>
|
| 542 |
-
</div>
|
| 543 |
-
`;
|
| 544 |
}
|
| 545 |
|
| 546 |
-
|
| 547 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
html += `
|
| 549 |
-
<div class="
|
| 550 |
-
<
|
| 551 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
</div>
|
| 553 |
`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 554 |
}
|
| 555 |
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
html += `
|
| 562 |
-
<div class="
|
| 563 |
-
<
|
| 564 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
</div>
|
| 566 |
`;
|
| 567 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
|
|
|
| 574 |
}
|
| 575 |
-
|
|
|
|
| 576 |
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
|
|
|
| 585 |
|
| 586 |
-
//
|
| 587 |
-
|
|
|
|
|
|
|
|
|
|
| 588 |
html += `
|
| 589 |
-
<div class="data-row">
|
| 590 |
-
<
|
| 591 |
-
|
| 592 |
-
|
|
|
|
| 593 |
`;
|
| 594 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
|
|
|
| 604 |
}
|
| 605 |
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
const
|
|
|
|
|
|
|
|
|
|
| 609 |
html += `
|
| 610 |
-
<div class="data-row" style="margin-
|
| 611 |
-
<
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
</
|
| 615 |
</div>
|
| 616 |
`;
|
| 617 |
-
}
|
| 618 |
|
| 619 |
-
return html
|
| 620 |
}
|
| 621 |
|
| 622 |
/**
|
|
@@ -625,118 +690,3 @@ function renderLocationData(data) {
|
|
| 625 |
function renderJSONFallback(data) {
|
| 626 |
return `<pre style="font-size: 0.85em; white-space: pre-wrap;">${JSON.stringify(data, null, 2)}</pre>`;
|
| 627 |
}
|
| 628 |
-
|
| 629 |
-
// ========== RWD 響應式側邊欄控制 ==========
|
| 630 |
-
|
| 631 |
-
/**
|
| 632 |
-
* 切換工具卡片側邊欄(手機版)
|
| 633 |
-
*/
|
| 634 |
-
function toggleToolSidebar() {
|
| 635 |
-
const sidebar = document.getElementById('tool-sidebar');
|
| 636 |
-
const toggle = document.getElementById('tool-sidebar-toggle');
|
| 637 |
-
|
| 638 |
-
if (sidebar.classList.contains('active')) {
|
| 639 |
-
sidebar.classList.remove('active');
|
| 640 |
-
toggle.classList.remove('active');
|
| 641 |
-
} else {
|
| 642 |
-
sidebar.classList.add('active');
|
| 643 |
-
toggle.classList.add('active');
|
| 644 |
-
// 檢查側邊欄內是否有卡片,動態更新切換按鈕
|
| 645 |
-
updateSidebarToggle();
|
| 646 |
-
}
|
| 647 |
-
}
|
| 648 |
-
|
| 649 |
-
/**
|
| 650 |
-
* 更新側邊欄切換按鈕狀態
|
| 651 |
-
*/
|
| 652 |
-
function updateSidebarToggle() {
|
| 653 |
-
// 新的設計中按鈕始終可見,不需要特殊狀態
|
| 654 |
-
return;
|
| 655 |
-
}
|
| 656 |
-
|
| 657 |
-
/**
|
| 658 |
-
* 檢測是否為手機/平板模式
|
| 659 |
-
*/
|
| 660 |
-
function isMobileMode() {
|
| 661 |
-
return window.innerWidth <= 1024;
|
| 662 |
-
}
|
| 663 |
-
|
| 664 |
-
// 重寫 addToolCard 函數,支援雙容器(桌面 vs 手機)
|
| 665 |
-
const originalAddToolCard = addToolCard;
|
| 666 |
-
function addToolCard(type) {
|
| 667 |
-
if (isMobileMode()) {
|
| 668 |
-
// 手機版:卡片加到側邊欄
|
| 669 |
-
const sidebarCards = document.getElementById('tool-sidebar-cards');
|
| 670 |
-
|
| 671 |
-
const card = document.createElement('div');
|
| 672 |
-
card.className = 'voice-tool-card';
|
| 673 |
-
card.dataset.type = type;
|
| 674 |
-
|
| 675 |
-
// 複製原有的卡片內容生成邏輯
|
| 676 |
-
if (type === 'weather') {
|
| 677 |
-
card.innerHTML = `
|
| 678 |
-
<div class="card-header">
|
| 679 |
-
<div class="card-icon">🌤️</div>
|
| 680 |
-
<h3>台北天氣</h3>
|
| 681 |
-
</div>
|
| 682 |
-
<div class="card-content">
|
| 683 |
-
<div class="data-row">
|
| 684 |
-
<span class="data-label">溫度</span>
|
| 685 |
-
<span class="data-value">23°C</span>
|
| 686 |
-
</div>
|
| 687 |
-
<div class="data-row">
|
| 688 |
-
<span class="data-label">狀況</span>
|
| 689 |
-
<span class="data-value">晴朗</span>
|
| 690 |
-
</div>
|
| 691 |
-
<div class="data-row">
|
| 692 |
-
<span class="data-label">濕度</span>
|
| 693 |
-
<span class="data-value">65%</span>
|
| 694 |
-
</div>
|
| 695 |
-
</div>
|
| 696 |
-
`;
|
| 697 |
-
} else if (type === 'news') {
|
| 698 |
-
card.innerHTML = `
|
| 699 |
-
<div class="card-header">
|
| 700 |
-
<div class="card-icon">📰</div>
|
| 701 |
-
<h3>今日科技新聞</h3>
|
| 702 |
-
</div>
|
| 703 |
-
<div class="card-content">
|
| 704 |
-
<div class="data-row">
|
| 705 |
-
<span style="font-size: 13px; line-height: 1.6;">
|
| 706 |
-
• OpenAI 發布新模型<br>
|
| 707 |
-
• 蘋果推出 Vision Pro 2<br>
|
| 708 |
-
• 台積電宣布 2nm 製程
|
| 709 |
-
</span>
|
| 710 |
-
</div>
|
| 711 |
-
</div>
|
| 712 |
-
`;
|
| 713 |
-
} else if (type === 'health') {
|
| 714 |
-
card.innerHTML = `
|
| 715 |
-
<div class="card-header">
|
| 716 |
-
<div class="card-icon">❤️</div>
|
| 717 |
-
<h3>健康數據</h3>
|
| 718 |
-
</div>
|
| 719 |
-
<div class="card-content">
|
| 720 |
-
<div class="data-row">
|
| 721 |
-
<span class="data-label">心率</span>
|
| 722 |
-
<span class="data-value">72 bpm</span>
|
| 723 |
-
</div>
|
| 724 |
-
<div class="data-row">
|
| 725 |
-
<span class="data-label">步數</span>
|
| 726 |
-
<span class="data-value">8,542</span>
|
| 727 |
-
</div>
|
| 728 |
-
<div class="data-row">
|
| 729 |
-
<span class="data-label">血氧</span>
|
| 730 |
-
<span class="data-value">98%</span>
|
| 731 |
-
</div>
|
| 732 |
-
</div>
|
| 733 |
-
`;
|
| 734 |
-
}
|
| 735 |
-
|
| 736 |
-
sidebarCards.appendChild(card);
|
| 737 |
-
updateSidebarToggle();
|
| 738 |
-
} else {
|
| 739 |
-
// 桌面版:使用原有邏輯
|
| 740 |
-
originalAddToolCard(type);
|
| 741 |
-
}
|
| 742 |
-
}
|
|
|
|
| 96 |
}
|
| 97 |
|
| 98 |
function clearAllCards() {
|
| 99 |
+
const cards = cardsContainer.querySelectorAll('.voice-tool-card');
|
| 100 |
+
cards.forEach(card => {
|
|
|
|
| 101 |
card.classList.add('exiting');
|
| 102 |
setTimeout(() => card.remove(), 300);
|
| 103 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
usedPositions = [];
|
|
|
|
| 105 |
}
|
| 106 |
|
| 107 |
// 模擬工具調用事件監聽(延遲初始化)
|
|
|
|
| 164 |
const iconMap = {
|
| 165 |
// 分類映射
|
| 166 |
'健康': '❤️',
|
|
|
|
| 167 |
'天氣': '🌤️',
|
| 168 |
'新聞': '📰',
|
| 169 |
'匯率': '💱',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
'時間': '⏰',
|
| 171 |
'提醒': '⏰',
|
| 172 |
'日曆': '📅',
|
|
|
|
| 174 |
'地圖': '🗺️',
|
| 175 |
'翻譯': '🌐',
|
| 176 |
'計算': '🔢',
|
| 177 |
+
'道路運輸': '🚌',
|
| 178 |
+
'軌道運輸': '🚇',
|
| 179 |
+
'地理定位': '📍',
|
| 180 |
|
| 181 |
// 工具名稱映射
|
| 182 |
'healthkit_query': '❤️',
|
| 183 |
'weather_query': '🌤️',
|
| 184 |
'news_query': '📰',
|
| 185 |
+
'exchange_rate': '💱',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
'time_query': '⏰',
|
| 187 |
'reminder': '⏰',
|
| 188 |
+
'calendar': '📅',
|
| 189 |
+
'tdx_bus_arrival': '🚌',
|
| 190 |
+
'tdx_metro': '🚇',
|
| 191 |
+
'reverse_geocode': '📍',
|
| 192 |
+
'forward_geocode': '📍',
|
| 193 |
+
'directions': '🗺️'
|
| 194 |
};
|
| 195 |
|
| 196 |
// 優先使用工具名稱匹配
|
|
|
|
| 219 |
const category = toolMeta.category || '未知';
|
| 220 |
const icon = getIconForTool(toolName, category);
|
| 221 |
|
| 222 |
+
// 根據 toolData 結構自動渲染
|
| 223 |
+
const position = getNextPosition();
|
| 224 |
+
if (!position) return;
|
| 225 |
+
|
| 226 |
const card = document.createElement('div');
|
| 227 |
+
card.className = `voice-tool-card ${position}`;
|
| 228 |
card.dataset.type = toolName;
|
| 229 |
|
| 230 |
// 渲染卡片內容
|
|
|
|
| 235 |
<div class="card-icon">${icon}</div>
|
| 236 |
<h3>${category}</h3>
|
| 237 |
</div>
|
| 238 |
+
<div class="card-content" style="max-height: 400px; overflow-y: auto; overflow-x: hidden; padding-right: 8px;">${contentHTML}</div>
|
| 239 |
`;
|
| 240 |
|
| 241 |
+
cardsContainer.appendChild(card);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
console.log(`🃏 顯示工具卡片: ${toolName} (${category})`);
|
| 243 |
}
|
| 244 |
|
|
|
|
| 272 |
return renderWeatherData(weatherData);
|
| 273 |
}
|
| 274 |
|
| 275 |
+
// 模式 4:公車到站資訊
|
| 276 |
+
if (toolData.arrivals && Array.isArray(toolData.arrivals)) {
|
| 277 |
+
console.log('✅ 匹配到模式 4: 公車到站資訊');
|
| 278 |
+
return renderBusArrivals(toolData.arrivals, toolData.route_name);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
// 模式 5:附近公車站點
|
| 282 |
+
if (toolData.stops && Array.isArray(toolData.stops)) {
|
| 283 |
+
console.log('✅ 匹配到模式 5: 附近公車站點');
|
| 284 |
+
return renderNearbyStops(toolData.stops);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
// 模式 6:匯率數據
|
| 288 |
if (toolData.rate !== undefined && toolData.from_currency !== undefined) {
|
| 289 |
+
console.log('✅ 匹配到模式 6: 匯率數據');
|
| 290 |
return renderExchangeRate(toolData);
|
| 291 |
}
|
| 292 |
|
| 293 |
+
// 模式 7:火車列車資訊
|
| 294 |
+
if (toolData.trains && Array.isArray(toolData.trains)) {
|
| 295 |
+
console.log('✅ 匹配到模式 7: 火車列車資訊');
|
| 296 |
+
return renderTrainList(toolData.trains);
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
// 模式 8:YouBike 站點資訊
|
| 300 |
+
if (toolData.stations && Array.isArray(toolData.stations)) {
|
| 301 |
+
console.log('✅ 匹配到模式 8: YouBike 站點資訊');
|
| 302 |
+
return renderYouBikeStations(toolData.stations);
|
| 303 |
}
|
| 304 |
|
| 305 |
+
// 模式 9:通用 raw_data 物件
|
| 306 |
if (toolData.raw_data && typeof toolData.raw_data === 'object') {
|
| 307 |
+
console.log('✅ 匹配到模式 9: 通用 raw_data');
|
| 308 |
return renderKeyValuePairs(toolData.raw_data);
|
| 309 |
}
|
| 310 |
|
|
|
|
| 393 |
}
|
| 394 |
|
| 395 |
/**
|
| 396 |
+
* 渲染新聞列表
|
|
|
|
| 397 |
*/
|
| 398 |
function renderNewsList(articles) {
|
| 399 |
let html = '';
|
| 400 |
+
articles.slice(0, 3).forEach(article => {
|
|
|
|
|
|
|
|
|
|
| 401 |
html += `
|
| 402 |
+
<div class="data-row" style="flex-direction: column; align-items: flex-start; margin-bottom: 10px;">
|
| 403 |
+
<span class="data-label" style="font-weight: bold;">${article.title || '無標題'}</span>
|
| 404 |
+
<span class="data-value" style="font-size: 0.85em; opacity: 0.8;">${article.source?.name || article.source || ''}</span>
|
| 405 |
</div>
|
| 406 |
`;
|
| 407 |
});
|
| 408 |
|
| 409 |
+
return html || '<p>無新聞</p>';
|
|
|
|
|
|
|
|
|
|
| 410 |
}
|
| 411 |
|
| 412 |
/**
|
|
|
|
| 501 |
}
|
| 502 |
|
| 503 |
/**
|
| 504 |
+
* 渲染火車列車資訊
|
| 505 |
*/
|
| 506 |
+
function renderTrainList(trains) {
|
| 507 |
+
if (!trains || trains.length === 0) {
|
| 508 |
+
return '<p class="data-row">查無列車資訊</p>';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
}
|
| 510 |
|
| 511 |
+
let html = '<div class="train-list">';
|
| 512 |
+
|
| 513 |
+
trains.forEach((train, index) => {
|
| 514 |
+
const trainType = train.train_type || '未知';
|
| 515 |
+
const trainNo = train.train_no || '---';
|
| 516 |
+
const departTime = train.departure_time ? train.departure_time.substring(0, 5) : '--:--';
|
| 517 |
+
const arriveTime = train.arrival_time ? train.arrival_time.substring(0, 5) : '--:--';
|
| 518 |
+
const duration = train.duration_min ? `${train.duration_min}分鐘` : '未知';
|
| 519 |
+
const originStation = train.origin_station || '未知';
|
| 520 |
+
const destStation = train.destination_station || '未知';
|
| 521 |
+
|
| 522 |
html += `
|
| 523 |
+
<div class="train-item" style="border-bottom: 1px solid #eee; padding: 12px 0; ${index === trains.length - 1 ? 'border-bottom: none;' : ''}">
|
| 524 |
+
<div class="data-row" style="margin-bottom: 8px;">
|
| 525 |
+
<span class="data-label" style="font-weight: bold; color: #0066cc;">🚂 ${trainType} ${trainNo}次</span>
|
| 526 |
+
</div>
|
| 527 |
+
<div class="data-row">
|
| 528 |
+
<span class="data-label">📍 起訖站</span>
|
| 529 |
+
<span class="data-value">${originStation} → ${destStation}</span>
|
| 530 |
+
</div>
|
| 531 |
+
<div class="data-row">
|
| 532 |
+
<span class="data-label">⏰ 出發</span>
|
| 533 |
+
<span class="data-value">${departTime}</span>
|
| 534 |
+
</div>
|
| 535 |
+
<div class="data-row">
|
| 536 |
+
<span class="data-label">⏱️ 抵達</span>
|
| 537 |
+
<span class="data-value">${arriveTime}</span>
|
| 538 |
+
</div>
|
| 539 |
+
<div class="data-row">
|
| 540 |
+
<span class="data-label">🕐 行駛時間</span>
|
| 541 |
+
<span class="data-value">${duration}</span>
|
| 542 |
+
</div>
|
| 543 |
</div>
|
| 544 |
`;
|
| 545 |
+
});
|
| 546 |
+
|
| 547 |
+
html += '</div>';
|
| 548 |
+
return html;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
/**
|
| 552 |
+
* 渲染 YouBike 站點資訊
|
| 553 |
+
*/
|
| 554 |
+
function renderYouBikeStations(stations) {
|
| 555 |
+
if (!stations || stations.length === 0) {
|
| 556 |
+
return '<p class="data-row">附近無 YouBike 站點</p>';
|
| 557 |
}
|
| 558 |
|
| 559 |
+
let html = '<div class="youbike-list">';
|
| 560 |
+
|
| 561 |
+
stations.forEach((station, index) => {
|
| 562 |
+
const stationName = station.station_name || '未知站點';
|
| 563 |
+
const availableBikes = station.available_bikes ?? 0;
|
| 564 |
+
const availableSpaces = station.available_spaces ?? 0;
|
| 565 |
+
const distance = station.distance_m || 0;
|
| 566 |
+
const walkingTime = station.walking_time_min || 0;
|
| 567 |
+
const bikeType = station.bike_type || 'YouBike';
|
| 568 |
+
const serviceStatus = station.service_status === 1 ? '營運中' : '暫停服務';
|
| 569 |
+
|
| 570 |
+
// 可借車輛狀態:0 = 紅色,1-3 = 橘色,>3 = 綠色
|
| 571 |
+
let bikeStatusColor = '#e74c3c'; // 紅色
|
| 572 |
+
let bikeStatusIcon = '🚫';
|
| 573 |
+
if (availableBikes > 3) {
|
| 574 |
+
bikeStatusColor = '#27ae60'; // 綠色
|
| 575 |
+
bikeStatusIcon = '✅';
|
| 576 |
+
} else if (availableBikes > 0) {
|
| 577 |
+
bikeStatusColor = '#f39c12'; // 橘色
|
| 578 |
+
bikeStatusIcon = '⚠️';
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
html += `
|
| 582 |
+
<div class="youbike-item" style="border-bottom: 1px solid #eee; padding: 12px 0; ${index === stations.length - 1 ? 'border-bottom: none;' : ''}">
|
| 583 |
+
<div class="data-row" style="margin-bottom: 8px;">
|
| 584 |
+
<span class="data-label" style="font-weight: bold; color: #e67e22;">🚲 ${stationName}</span>
|
| 585 |
+
</div>
|
| 586 |
+
<div class="data-row">
|
| 587 |
+
<span class="data-label">📍 距離</span>
|
| 588 |
+
<span class="data-value">${distance}m (步行約 ${walkingTime} 分鐘)</span>
|
| 589 |
+
</div>
|
| 590 |
+
<div class="data-row">
|
| 591 |
+
<span class="data-label">🚴 可借車輛</span>
|
| 592 |
+
<span class="data-value" style="color: ${bikeStatusColor}; font-weight: bold;">${bikeStatusIcon} ${availableBikes} 輛</span>
|
| 593 |
+
</div>
|
| 594 |
+
<div class="data-row">
|
| 595 |
+
<span class="data-label">🅿️ 可還空位</span>
|
| 596 |
+
<span class="data-value">${availableSpaces} 個</span>
|
| 597 |
+
</div>
|
| 598 |
+
<div class="data-row">
|
| 599 |
+
<span class="data-label">ℹ️ 類型</span>
|
| 600 |
+
<span class="data-value">${bikeType} (${serviceStatus})</span>
|
| 601 |
+
</div>
|
| 602 |
</div>
|
| 603 |
`;
|
| 604 |
+
});
|
| 605 |
+
|
| 606 |
+
html += '</div>';
|
| 607 |
+
return html;
|
| 608 |
+
}
|
| 609 |
|
| 610 |
+
/**
|
| 611 |
+
* 渲染公車到站資訊
|
| 612 |
+
*/
|
| 613 |
+
function renderBusArrivals(arrivals, routeName) {
|
| 614 |
+
if (!arrivals || arrivals.length === 0) {
|
| 615 |
+
return '<p>目前無到站資訊</p>';
|
| 616 |
}
|
| 617 |
+
|
| 618 |
+
let html = '';
|
| 619 |
|
| 620 |
+
// 按站點分組
|
| 621 |
+
const stopGroups = {};
|
| 622 |
+
arrivals.forEach(arr => {
|
| 623 |
+
const stopName = arr.stop_name || '未知站點';
|
| 624 |
+
if (!stopGroups[stopName]) {
|
| 625 |
+
stopGroups[stopName] = [];
|
| 626 |
+
}
|
| 627 |
+
stopGroups[stopName].push(arr);
|
| 628 |
+
});
|
| 629 |
|
| 630 |
+
// 渲染每個站點
|
| 631 |
+
Object.entries(stopGroups).slice(0, 3).forEach(([stopName, stopArrivals], index) => {
|
| 632 |
+
const firstArr = stopArrivals[0];
|
| 633 |
+
const distance = firstArr.distance_m ? `${Math.round(firstArr.distance_m)}m` : '';
|
| 634 |
+
|
| 635 |
html += `
|
| 636 |
+
<div class="data-row" style="flex-direction: column; align-items: flex-start; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid rgba(0,0,0,0.05);">
|
| 637 |
+
<div style="display: flex; justify-content: space-between; width: 100%; margin-bottom: 4px;">
|
| 638 |
+
<span class="data-label" style="font-weight: 600;">🚏 ${stopName}</span>
|
| 639 |
+
${distance ? `<span class="data-value" style="font-size: 0.85em; opacity: 0.7;">${distance}</span>` : ''}
|
| 640 |
+
</div>
|
| 641 |
`;
|
| 642 |
+
|
| 643 |
+
stopArrivals.forEach(arr => {
|
| 644 |
+
const direction = arr.direction === 0 ? '往 ↑' : '返 ↓';
|
| 645 |
+
const status = arr.status || '未知';
|
| 646 |
+
html += `
|
| 647 |
+
<div style="display: flex; justify-content: space-between; width: 100%; padding: 2px 0;">
|
| 648 |
+
<span style="font-size: 0.9em; opacity: 0.8;">${direction}</span>
|
| 649 |
+
<span class="data-value" style="font-size: 0.9em;">${status}</span>
|
| 650 |
+
</div>
|
| 651 |
+
`;
|
| 652 |
+
});
|
| 653 |
+
|
| 654 |
+
html += `</div>`;
|
| 655 |
+
});
|
| 656 |
|
| 657 |
+
return html;
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
/**
|
| 661 |
+
* 渲染附近公車站點
|
| 662 |
+
*/
|
| 663 |
+
function renderNearbyStops(stops) {
|
| 664 |
+
if (!stops || stops.length === 0) {
|
| 665 |
+
return '<p>附近沒有公車站</p>';
|
| 666 |
}
|
| 667 |
|
| 668 |
+
let html = '';
|
| 669 |
+
stops.slice(0, 5).forEach((stop, index) => {
|
| 670 |
+
const stopName = stop.stop_name || '未知站點';
|
| 671 |
+
const distance = stop.distance_m ? `${Math.round(stop.distance_m)}m` : '';
|
| 672 |
+
const walkTime = stop.walking_time_min ? `步行 ${stop.walking_time_min} 分` : '';
|
| 673 |
+
|
| 674 |
html += `
|
| 675 |
+
<div class="data-row" style="margin-bottom: 8px;">
|
| 676 |
+
<div style="flex: 1;">
|
| 677 |
+
<div style="font-weight: 600; margin-bottom: 2px;">🚏 ${stopName}</div>
|
| 678 |
+
<div style="font-size: 0.85em; opacity: 0.7;">${walkTime} ${distance ? `(${distance})` : ''}</div>
|
| 679 |
+
</div>
|
| 680 |
</div>
|
| 681 |
`;
|
| 682 |
+
});
|
| 683 |
|
| 684 |
+
return html;
|
| 685 |
}
|
| 686 |
|
| 687 |
/**
|
|
|
|
| 690 |
function renderJSONFallback(data) {
|
| 691 |
return `<pre style="font-size: 0.85em; white-space: pre-wrap;">${JSON.stringify(data, null, 2)}</pre>`;
|
| 692 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/frontend/js/tts.js
CHANGED
|
@@ -4,12 +4,6 @@ let isPlaying = false; // 是否正在播放
|
|
| 4 |
let audioContext = null; // 預先建立的 AudioContext(繞過自動播放限制)
|
| 5 |
let userGestureReceived = false; // 是否已收到用戶手勢
|
| 6 |
|
| 7 |
-
// TTS 音訊分析相關
|
| 8 |
-
let ttsAnalyser = null; // TTS 音訊分析器
|
| 9 |
-
let ttsSource = null; // TTS 音訊源節點
|
| 10 |
-
let ttsDataArray = null; // TTS 頻率數據陣列
|
| 11 |
-
let ttsBufferLength = 0; // TTS 數據陣列長度
|
| 12 |
-
|
| 13 |
console.log('✅ TTS 模組已載入');
|
| 14 |
|
| 15 |
// ========== 用戶手勢處理(解鎖自動播放)==========
|
|
@@ -86,20 +80,15 @@ async function speakText(text) {
|
|
| 86 |
currentAudio = new Audio(audioUrl);
|
| 87 |
isPlaying = true;
|
| 88 |
|
| 89 |
-
// 設置 TTS 音訊分析(讓波形跟隨 TTS 音訊跳動)
|
| 90 |
-
setupTTSAudioAnalysis(currentAudio);
|
| 91 |
-
|
| 92 |
currentAudio.onended = () => {
|
| 93 |
console.log('✅ 語音播放完成');
|
| 94 |
isPlaying = false;
|
| 95 |
-
stopTTSAudioAnalysis();
|
| 96 |
URL.revokeObjectURL(audioUrl);
|
| 97 |
};
|
| 98 |
|
| 99 |
currentAudio.onerror = (e) => {
|
| 100 |
console.error('❌ 音頻播放錯誤:', e);
|
| 101 |
isPlaying = false;
|
| 102 |
-
stopTTSAudioAnalysis();
|
| 103 |
URL.revokeObjectURL(audioUrl);
|
| 104 |
};
|
| 105 |
|
|
@@ -109,7 +98,7 @@ async function speakText(text) {
|
|
| 109 |
|
| 110 |
if (playPromise !== undefined) {
|
| 111 |
await playPromise;
|
| 112 |
-
console.log('▶️
|
| 113 |
}
|
| 114 |
} catch (playError) {
|
| 115 |
// 處理瀏覽器自動播放策略限制
|
|
@@ -171,82 +160,6 @@ function stopSpeaking() {
|
|
| 171 |
currentAudio.pause();
|
| 172 |
currentAudio.currentTime = 0;
|
| 173 |
isPlaying = false;
|
| 174 |
-
stopTTSAudioAnalysis();
|
| 175 |
console.log('⏹️ 停止語音播放');
|
| 176 |
}
|
| 177 |
}
|
| 178 |
-
|
| 179 |
-
// ========== TTS 音訊分析(讓波形跟隨 TTS 跳動)==========
|
| 180 |
-
|
| 181 |
-
/**
|
| 182 |
-
* 設置 TTS 音訊分析
|
| 183 |
-
* @param {HTMLAudioElement} audioElement - Audio 元素
|
| 184 |
-
*/
|
| 185 |
-
function setupTTSAudioAnalysis(audioElement) {
|
| 186 |
-
try {
|
| 187 |
-
// 確保 AudioContext 已初始化
|
| 188 |
-
if (!audioContext) {
|
| 189 |
-
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
| 190 |
-
userGestureReceived = true;
|
| 191 |
-
}
|
| 192 |
-
|
| 193 |
-
// 創建分析器節點
|
| 194 |
-
ttsAnalyser = audioContext.createAnalyser();
|
| 195 |
-
ttsAnalyser.fftSize = 256; // 與 canvas.js 保持一致
|
| 196 |
-
ttsAnalyser.smoothingTimeConstant = 0.8;
|
| 197 |
-
|
| 198 |
-
// 創建音訊源(從 Audio 元素)
|
| 199 |
-
ttsSource = audioContext.createMediaElementSource(audioElement);
|
| 200 |
-
|
| 201 |
-
// 連接:音訊源 → 分析器 → 輸出(揚聲器)
|
| 202 |
-
ttsSource.connect(ttsAnalyser);
|
| 203 |
-
ttsAnalyser.connect(audioContext.destination);
|
| 204 |
-
|
| 205 |
-
// 準備數據陣列
|
| 206 |
-
ttsBufferLength = ttsAnalyser.frequencyBinCount;
|
| 207 |
-
ttsDataArray = new Uint8Array(ttsBufferLength);
|
| 208 |
-
|
| 209 |
-
console.log('🎵 TTS 音訊分析已啟動');
|
| 210 |
-
|
| 211 |
-
// 通知 canvas.js 使用 TTS 音訊數據
|
| 212 |
-
if (typeof startTTSVisualization === 'function') {
|
| 213 |
-
startTTSVisualization(ttsAnalyser, ttsDataArray, ttsBufferLength);
|
| 214 |
-
}
|
| 215 |
-
|
| 216 |
-
} catch (error) {
|
| 217 |
-
console.error('❌ TTS 音訊分析設置失敗:', error);
|
| 218 |
-
}
|
| 219 |
-
}
|
| 220 |
-
|
| 221 |
-
/**
|
| 222 |
-
* 停止 TTS 音訊分析
|
| 223 |
-
*/
|
| 224 |
-
function stopTTSAudioAnalysis() {
|
| 225 |
-
if (ttsSource) {
|
| 226 |
-
try {
|
| 227 |
-
ttsSource.disconnect();
|
| 228 |
-
} catch (e) {
|
| 229 |
-
// 忽略斷開連接錯誤
|
| 230 |
-
}
|
| 231 |
-
ttsSource = null;
|
| 232 |
-
}
|
| 233 |
-
|
| 234 |
-
if (ttsAnalyser) {
|
| 235 |
-
try {
|
| 236 |
-
ttsAnalyser.disconnect();
|
| 237 |
-
} catch (e) {
|
| 238 |
-
// 忽略斷開連接錯誤
|
| 239 |
-
}
|
| 240 |
-
ttsAnalyser = null;
|
| 241 |
-
}
|
| 242 |
-
|
| 243 |
-
ttsDataArray = null;
|
| 244 |
-
ttsBufferLength = 0;
|
| 245 |
-
|
| 246 |
-
// 通知 canvas.js 停止使用 TTS 音訊數據
|
| 247 |
-
if (typeof stopTTSVisualization === 'function') {
|
| 248 |
-
stopTTSVisualization();
|
| 249 |
-
}
|
| 250 |
-
|
| 251 |
-
console.log('🛑 TTS 音訊分析已停止');
|
| 252 |
-
}
|
|
|
|
| 4 |
let audioContext = null; // 預先建立的 AudioContext(繞過自動播放限制)
|
| 5 |
let userGestureReceived = false; // 是否已收到用戶手勢
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
console.log('✅ TTS 模組已載入');
|
| 8 |
|
| 9 |
// ========== 用戶手勢處理(解鎖自動播放)==========
|
|
|
|
| 80 |
currentAudio = new Audio(audioUrl);
|
| 81 |
isPlaying = true;
|
| 82 |
|
|
|
|
|
|
|
|
|
|
| 83 |
currentAudio.onended = () => {
|
| 84 |
console.log('✅ 語音播放完成');
|
| 85 |
isPlaying = false;
|
|
|
|
| 86 |
URL.revokeObjectURL(audioUrl);
|
| 87 |
};
|
| 88 |
|
| 89 |
currentAudio.onerror = (e) => {
|
| 90 |
console.error('❌ 音頻播放錯誤:', e);
|
| 91 |
isPlaying = false;
|
|
|
|
| 92 |
URL.revokeObjectURL(audioUrl);
|
| 93 |
};
|
| 94 |
|
|
|
|
| 98 |
|
| 99 |
if (playPromise !== undefined) {
|
| 100 |
await playPromise;
|
| 101 |
+
console.log('▶️ 開始播放語音');
|
| 102 |
}
|
| 103 |
} catch (playError) {
|
| 104 |
// 處理瀏覽器自動播放策略限制
|
|
|
|
| 160 |
currentAudio.pause();
|
| 161 |
currentAudio.currentTime = 0;
|
| 162 |
isPlaying = false;
|
|
|
|
| 163 |
console.log('⏹️ 停止語音播放');
|
| 164 |
}
|
| 165 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/frontend/js/ui.js
CHANGED
|
@@ -81,178 +81,129 @@ function initLoginButton() {
|
|
| 81 |
|
| 82 |
// ========== 登出按鈕 ==========
|
| 83 |
|
| 84 |
-
function
|
| 85 |
-
const
|
| 86 |
-
if (
|
| 87 |
-
|
|
|
|
| 88 |
}
|
| 89 |
}
|
| 90 |
|
| 91 |
-
|
| 92 |
-
console.log('🚪
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
}
|
| 101 |
-
});
|
| 102 |
-
} catch (error) {
|
| 103 |
-
console.warn('⚠️ 後端登出失敗(忽略):', error);
|
| 104 |
}
|
| 105 |
|
| 106 |
-
//
|
| 107 |
-
|
| 108 |
-
|
|
|
|
| 109 |
|
| 110 |
-
console.log('✅
|
| 111 |
|
| 112 |
-
//
|
| 113 |
-
window.location.href = '/
|
| 114 |
}
|
| 115 |
|
| 116 |
-
// ==========
|
| 117 |
|
| 118 |
-
let isTextInputMode = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
-
/**
|
| 121 |
-
* 切換語音模式與文字輸入模式
|
| 122 |
-
*/
|
| 123 |
function toggleInputMode() {
|
| 124 |
isTextInputMode = !isTextInputMode;
|
| 125 |
-
|
| 126 |
-
const modeToggleBtn = document.getElementById('modeToggleBtn');
|
| 127 |
const transcript = document.getElementById('transcript');
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
if (isTextInputMode) {
|
| 131 |
-
//
|
| 132 |
-
console.log('
|
| 133 |
-
|
| 134 |
-
//
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
//
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
//
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
// 啟用文字輸入
|
| 155 |
-
transcript.contentEditable = 'true';
|
| 156 |
-
transcript.classList.add('text-input-mode');
|
| 157 |
-
transcript.classList.remove('provisional', 'final');
|
| 158 |
-
transcript.textContent = '';
|
| 159 |
-
transcript.setAttribute('data-placeholder', '請輸入文字...');
|
| 160 |
-
transcript.focus();
|
| 161 |
-
|
| 162 |
-
// 更新按鈕樣式
|
| 163 |
-
modeToggleBtn.classList.add('text-mode');
|
| 164 |
-
modeToggleBtn.textContent = '🎤';
|
| 165 |
-
modeToggleBtn.title = '切換為語音模式';
|
| 166 |
-
|
| 167 |
-
// 監聽 Enter 鍵發送訊息
|
| 168 |
-
transcript.addEventListener('keydown', handleTextInput);
|
| 169 |
-
|
| 170 |
} else {
|
| 171 |
-
//
|
| 172 |
-
console.log('
|
| 173 |
-
|
| 174 |
-
//
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
| 181 |
transcript.textContent = '請說話...';
|
| 182 |
-
transcript.removeAttribute('data-placeholder');
|
| 183 |
-
|
| 184 |
-
// 啟用麥克風
|
| 185 |
-
micContainer.style.pointerEvents = 'auto';
|
| 186 |
-
micContainer.style.opacity = '1';
|
| 187 |
-
|
| 188 |
-
// 更新按鈕樣式
|
| 189 |
-
modeToggleBtn.classList.remove('text-mode');
|
| 190 |
-
modeToggleBtn.textContent = '💬';
|
| 191 |
-
modeToggleBtn.title = '切換為文字輸入模式';
|
| 192 |
-
|
| 193 |
-
// 移除文字輸入監聽
|
| 194 |
-
transcript.removeEventListener('keydown', handleTextInput);
|
| 195 |
}
|
| 196 |
}
|
| 197 |
|
| 198 |
-
/**
|
| 199 |
-
* 處理文字輸入(Enter 發送訊息)
|
| 200 |
-
*/
|
| 201 |
function handleTextInput(event) {
|
|
|
|
| 202 |
if (event.key === 'Enter' && !event.shiftKey) {
|
| 203 |
event.preventDefault();
|
| 204 |
|
| 205 |
-
const
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
if (!message) {
|
| 209 |
-
console.warn('⚠️ 訊息內容為空');
|
| 210 |
return;
|
| 211 |
}
|
| 212 |
|
| 213 |
-
console.log('📤
|
| 214 |
|
| 215 |
-
//
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
| 218 |
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
// 獲取當前 chat_id(從全域變數)
|
| 222 |
-
const chatId = window.currentChatId;
|
| 223 |
|
| 224 |
-
|
| 225 |
-
|
|
|
|
| 226 |
}
|
| 227 |
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
// 文字輸入模式下也顯示思考狀態(花瓣綻放)
|
| 231 |
-
setState('thinking');
|
| 232 |
-
|
| 233 |
-
// 清空輸入框(延遲一下讓用戶看到發送的訊息)
|
| 234 |
-
setTimeout(() => {
|
| 235 |
-
transcript.textContent = '思考中...';
|
| 236 |
-
transcript.classList.remove('final');
|
| 237 |
-
transcript.classList.add('provisional');
|
| 238 |
-
}, 300);
|
| 239 |
} else {
|
| 240 |
-
console.error('❌ WebSocket
|
| 241 |
-
alert('連線已斷開,請重新整理頁面');
|
| 242 |
}
|
| 243 |
-
} else if (event.key === 'Enter' && event.shiftKey) {
|
| 244 |
-
// Shift+Enter 換行(contenteditable 預設行為)
|
| 245 |
-
// 不需要特別處理
|
| 246 |
-
}
|
| 247 |
-
}
|
| 248 |
-
|
| 249 |
-
/**
|
| 250 |
-
* 初始化模式切換按鈕
|
| 251 |
-
*/
|
| 252 |
-
function initModeToggle() {
|
| 253 |
-
const modeToggleBtn = document.getElementById('modeToggleBtn');
|
| 254 |
-
if (modeToggleBtn) {
|
| 255 |
-
modeToggleBtn.addEventListener('click', toggleInputMode);
|
| 256 |
-
console.log('✅ 模式切換按鈕已初始化');
|
| 257 |
}
|
| 258 |
}
|
|
|
|
| 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; // 文字輸入框元素
|
| 118 |
+
|
| 119 |
+
function initChatIcon() {
|
| 120 |
+
const chatIcon = document.getElementById('chatIcon');
|
| 121 |
+
if (chatIcon) {
|
| 122 |
+
chatIcon.addEventListener('click', toggleInputMode);
|
| 123 |
+
console.log('✅ 輸入模式切換按鈕已初始化');
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
|
|
|
|
|
|
|
|
|
|
| 127 |
function toggleInputMode() {
|
| 128 |
isTextInputMode = !isTextInputMode;
|
|
|
|
|
|
|
| 129 |
const transcript = document.getElementById('transcript');
|
| 130 |
+
|
| 131 |
+
if (!transcript) {
|
| 132 |
+
console.error('❌ 找不到 transcript 元素');
|
| 133 |
+
return;
|
| 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 |
|
| 181 |
+
const text = textInputElement.value.trim();
|
| 182 |
+
if (!text) {
|
| 183 |
+
console.warn('⚠️ 訊息為空,不送出');
|
|
|
|
|
|
|
| 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 未初始化');
|
|
|
|
| 207 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
}
|
| 209 |
}
|
static/frontend/js/websocket.js
CHANGED
|
@@ -1,14 +1,3 @@
|
|
| 1 |
-
// 全域控制:僅保留錯誤/重要訊息的 console 輸出
|
| 2 |
-
(function silenceConsoleLogs() {
|
| 3 |
-
if (typeof window !== 'undefined' && !window.BLOOMWARE_DEBUG && !console.__bloomwareSilenced) {
|
| 4 |
-
const noop = () => {};
|
| 5 |
-
console.log = noop;
|
| 6 |
-
console.info = noop;
|
| 7 |
-
console.debug = noop;
|
| 8 |
-
console.__bloomwareSilenced = true;
|
| 9 |
-
}
|
| 10 |
-
})();
|
| 11 |
-
|
| 12 |
/**
|
| 13 |
* Bloom Ware WebSocket 通訊管理模組(完整版)
|
| 14 |
* 處理 WebSocket 連接、訊息收發、重連機制
|
|
@@ -85,50 +74,12 @@ class WebSocketManager {
|
|
| 85 |
this.send({ type: 'chat_focus', chat_id: cid });
|
| 86 |
}
|
| 87 |
|
| 88 |
-
//
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
const sendSnapshot = (lat, lon, acc, heading) => {
|
| 95 |
-
this.send({
|
| 96 |
-
type: 'env_snapshot',
|
| 97 |
-
lat, lon,
|
| 98 |
-
accuracy_m: acc,
|
| 99 |
-
heading_deg: heading,
|
| 100 |
-
tz, locale, device
|
| 101 |
-
});
|
| 102 |
-
};
|
| 103 |
-
|
| 104 |
-
// 裝置方向(可能受限權限)
|
| 105 |
-
let heading = undefined;
|
| 106 |
-
try {
|
| 107 |
-
if (window.screen && window.screen.orientation && window.screen.orientation.angle !== undefined) {
|
| 108 |
-
heading = window.screen.orientation.angle; // 粗略
|
| 109 |
-
}
|
| 110 |
-
} catch (_) {}
|
| 111 |
-
|
| 112 |
-
if (navigator.geolocation) {
|
| 113 |
-
navigator.geolocation.getCurrentPosition(
|
| 114 |
-
(pos) => {
|
| 115 |
-
const c = pos.coords || {};
|
| 116 |
-
sendSnapshot(c.latitude, c.longitude, c.accuracy, heading);
|
| 117 |
-
},
|
| 118 |
-
(_err) => {
|
| 119 |
-
sendSnapshot(undefined, undefined, undefined, heading);
|
| 120 |
-
},
|
| 121 |
-
{
|
| 122 |
-
enableHighAccuracy: true, // 啟用高精度模式(GPS 優先於 WiFi/Cell)
|
| 123 |
-
maximumAge: 0, // 不使用快取,強制取得最新位置
|
| 124 |
-
timeout: 15000 // 延長超時到 15 秒(GPS 冷啟動需要時間)
|
| 125 |
-
}
|
| 126 |
-
);
|
| 127 |
-
} else {
|
| 128 |
-
sendSnapshot(undefined, undefined, undefined, heading);
|
| 129 |
-
}
|
| 130 |
-
} catch (e) {
|
| 131 |
-
console.warn('環境快照上報失敗', e);
|
| 132 |
}
|
| 133 |
}
|
| 134 |
|
|
@@ -173,7 +124,7 @@ class WebSocketManager {
|
|
| 173 |
localStorage.removeItem('jwt_token');
|
| 174 |
// 跳轉到登入頁
|
| 175 |
setTimeout(() => {
|
| 176 |
-
window.location.href = '/
|
| 177 |
}, 500);
|
| 178 |
return;
|
| 179 |
}
|
|
@@ -202,12 +153,12 @@ class WebSocketManager {
|
|
| 202 |
if (payload.exp && payload.exp < currentTime) {
|
| 203 |
console.error('❌ Token 已過期,跳轉到登入頁面');
|
| 204 |
localStorage.removeItem('jwt_token');
|
| 205 |
-
window.location.href = '/
|
| 206 |
}
|
| 207 |
} catch (e) {
|
| 208 |
console.error('❌ Token 解析失敗,跳轉到登入頁面');
|
| 209 |
localStorage.removeItem('jwt_token');
|
| 210 |
-
window.location.href = '/
|
| 211 |
}
|
| 212 |
}
|
| 213 |
}
|
|
@@ -229,7 +180,7 @@ class WebSocketManager {
|
|
| 229 |
if (payload.exp && payload.exp < currentTime) {
|
| 230 |
console.error('❌ Token 已過期,跳轉到登入頁面');
|
| 231 |
localStorage.removeItem('jwt_token');
|
| 232 |
-
window.location.href = '/
|
| 233 |
return;
|
| 234 |
}
|
| 235 |
} catch (error) {
|
|
@@ -241,7 +192,7 @@ class WebSocketManager {
|
|
| 241 |
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
| 242 |
console.error('❌ WebSocket 重連次數已達上限,可能是認證問題,清除 token 並跳轉登入頁');
|
| 243 |
localStorage.removeItem('jwt_token');
|
| 244 |
-
window.location.href = '/
|
| 245 |
return;
|
| 246 |
}
|
| 247 |
}
|
|
@@ -270,20 +221,21 @@ class WebSocketManager {
|
|
| 270 |
|
| 271 |
// 發送用戶輸入
|
| 272 |
sendUserMessage(text, chatId) {
|
| 273 |
-
if (!text || !
|
| 274 |
-
console.warn('⚠️
|
| 275 |
return false;
|
| 276 |
}
|
| 277 |
|
| 278 |
-
//
|
| 279 |
if (!chatId) {
|
| 280 |
-
console.
|
|
|
|
| 281 |
}
|
| 282 |
|
| 283 |
const payload = {
|
| 284 |
type: 'user_message',
|
| 285 |
message: text,
|
| 286 |
-
chat_id: chatId
|
| 287 |
};
|
| 288 |
|
| 289 |
return this.send(payload);
|
|
@@ -475,7 +427,7 @@ class WebSocketManager {
|
|
| 475 |
}
|
| 476 |
|
| 477 |
// 發送停止錄音信號
|
| 478 |
-
this.send({
|
| 479 |
type: 'audio_stop',
|
| 480 |
mode: 'chat' // 對話模式
|
| 481 |
});
|
|
@@ -483,159 +435,6 @@ class WebSocketManager {
|
|
| 483 |
this.isRecording = false;
|
| 484 |
console.log('✅ 錄音已停止');
|
| 485 |
}
|
| 486 |
-
|
| 487 |
-
// ========== 語音綁定專用錄音功能 ==========
|
| 488 |
-
|
| 489 |
-
/**
|
| 490 |
-
* 開始語音綁定錄音(專用於綁定流程)
|
| 491 |
-
*/
|
| 492 |
-
async startVoiceBindingRecording() {
|
| 493 |
-
if (this.isRecording) {
|
| 494 |
-
console.warn('⚠️ 已經在錄音中');
|
| 495 |
-
return false;
|
| 496 |
-
}
|
| 497 |
-
|
| 498 |
-
if (!this.isConnected()) {
|
| 499 |
-
console.error('❌ WebSocket 未連接,無法開始錄音');
|
| 500 |
-
return false;
|
| 501 |
-
}
|
| 502 |
-
|
| 503 |
-
try {
|
| 504 |
-
console.log('🎙️ 開始語音綁定錄音...');
|
| 505 |
-
|
| 506 |
-
// 🔓 解鎖音頻播放
|
| 507 |
-
if (typeof unlockAudioPlayback === 'function') {
|
| 508 |
-
unlockAudioPlayback();
|
| 509 |
-
}
|
| 510 |
-
|
| 511 |
-
// 請求麥克風權限
|
| 512 |
-
this.audioStream = await navigator.mediaDevices.getUserMedia({
|
| 513 |
-
audio: {
|
| 514 |
-
channelCount: 1,
|
| 515 |
-
sampleRate: 16000,
|
| 516 |
-
echoCancellation: true,
|
| 517 |
-
noiseSuppression: true,
|
| 518 |
-
autoGainControl: true
|
| 519 |
-
}
|
| 520 |
-
});
|
| 521 |
-
|
| 522 |
-
// 創建音訊上下文
|
| 523 |
-
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
| 524 |
-
sampleRate: 16000
|
| 525 |
-
});
|
| 526 |
-
|
| 527 |
-
// 創建音訊處理節點
|
| 528 |
-
this.audioSource = this.audioContext.createMediaStreamSource(this.audioStream);
|
| 529 |
-
this.audioProcessor = this.audioContext.createScriptProcessor(4096, 1, 1);
|
| 530 |
-
|
| 531 |
-
// 連接音訊節點
|
| 532 |
-
this.audioSource.connect(this.audioProcessor);
|
| 533 |
-
this.audioProcessor.connect(this.audioContext.destination);
|
| 534 |
-
|
| 535 |
-
// 發送開始錄音信號(語音綁定模式)
|
| 536 |
-
this.send({
|
| 537 |
-
type: 'audio_start',
|
| 538 |
-
sample_rate: 16000,
|
| 539 |
-
mode: 'binding' // 語音綁定模式
|
| 540 |
-
});
|
| 541 |
-
|
| 542 |
-
this.isRecording = true;
|
| 543 |
-
|
| 544 |
-
// 處理音訊數據
|
| 545 |
-
this.audioProcessor.onaudioprocess = (e) => {
|
| 546 |
-
if (!this.isRecording) return;
|
| 547 |
-
|
| 548 |
-
try {
|
| 549 |
-
const inputData = e.inputBuffer.getChannelData(0);
|
| 550 |
-
|
| 551 |
-
// Float32 轉 Int16 PCM
|
| 552 |
-
const pcm16 = new Int16Array(inputData.length);
|
| 553 |
-
for (let i = 0; i < inputData.length; i++) {
|
| 554 |
-
let sample = Math.max(-1, Math.min(1, inputData[i]));
|
| 555 |
-
pcm16[i] = sample < 0 ? sample * 0x8000 : sample * 0x7FFF;
|
| 556 |
-
}
|
| 557 |
-
|
| 558 |
-
// 轉為 Uint8Array 並 Base64 編碼
|
| 559 |
-
const bytes = new Uint8Array(pcm16.buffer);
|
| 560 |
-
const b64 = btoa(String.fromCharCode(...bytes));
|
| 561 |
-
|
| 562 |
-
// 發送音訊塊
|
| 563 |
-
this.send({
|
| 564 |
-
type: 'audio_chunk',
|
| 565 |
-
pcm16_base64: b64
|
| 566 |
-
});
|
| 567 |
-
|
| 568 |
-
} catch (error) {
|
| 569 |
-
console.error('❌ 音訊處理錯誤:', error);
|
| 570 |
-
}
|
| 571 |
-
};
|
| 572 |
-
|
| 573 |
-
console.log('✅ 語音綁定錄音已開始');
|
| 574 |
-
return true;
|
| 575 |
-
|
| 576 |
-
} catch (error) {
|
| 577 |
-
console.error('❌ 開始語音綁定錄音失敗:', error);
|
| 578 |
-
|
| 579 |
-
// 顯示錯誤提示
|
| 580 |
-
if (error.name === 'NotAllowedError') {
|
| 581 |
-
if (typeof showErrorNotification === 'function') {
|
| 582 |
-
showErrorNotification('需要麥克風權限才能使用語音綁定功能');
|
| 583 |
-
}
|
| 584 |
-
}
|
| 585 |
-
|
| 586 |
-
this.isRecording = false;
|
| 587 |
-
return false;
|
| 588 |
-
}
|
| 589 |
-
}
|
| 590 |
-
|
| 591 |
-
/**
|
| 592 |
-
* 停止語音綁定錄音
|
| 593 |
-
*/
|
| 594 |
-
stopVoiceBindingRecording() {
|
| 595 |
-
if (!this.isRecording) {
|
| 596 |
-
console.warn('⚠️ 目前沒有在錄音');
|
| 597 |
-
return;
|
| 598 |
-
}
|
| 599 |
-
|
| 600 |
-
console.log('🛑 停止語音綁定錄音...');
|
| 601 |
-
|
| 602 |
-
// 停止音訊處理
|
| 603 |
-
if (this.audioProcessor) {
|
| 604 |
-
this.audioProcessor.disconnect();
|
| 605 |
-
this.audioProcessor = null;
|
| 606 |
-
}
|
| 607 |
-
|
| 608 |
-
// 斷開音訊源
|
| 609 |
-
if (this.audioSource) {
|
| 610 |
-
try {
|
| 611 |
-
this.audioSource.disconnect();
|
| 612 |
-
} catch (e) {
|
| 613 |
-
console.warn('⚠️ 斷開音訊源失敗:', e);
|
| 614 |
-
}
|
| 615 |
-
this.audioSource = null;
|
| 616 |
-
}
|
| 617 |
-
|
| 618 |
-
// 停止麥克風軌道
|
| 619 |
-
if (this.audioStream) {
|
| 620 |
-
this.audioStream.getTracks().forEach(track => track.stop());
|
| 621 |
-
this.audioStream = null;
|
| 622 |
-
}
|
| 623 |
-
|
| 624 |
-
// 關閉音訊上下文
|
| 625 |
-
if (this.audioContext) {
|
| 626 |
-
this.audioContext.close();
|
| 627 |
-
this.audioContext = null;
|
| 628 |
-
}
|
| 629 |
-
|
| 630 |
-
// 發送停止錄音信號(語音綁定模式)
|
| 631 |
-
this.send({
|
| 632 |
-
type: 'audio_stop',
|
| 633 |
-
mode: 'binding' // 語音綁定模式
|
| 634 |
-
});
|
| 635 |
-
|
| 636 |
-
this.isRecording = false;
|
| 637 |
-
console.log('✅ 語音綁定錄音已停止');
|
| 638 |
-
}
|
| 639 |
}
|
| 640 |
|
| 641 |
/**
|
|
@@ -663,14 +462,13 @@ function initializeWebSocket(token) {
|
|
| 663 |
case 'system':
|
| 664 |
// 系統訊息(歡迎詞、連線成功)
|
| 665 |
console.log('🔔 系統訊息:', data.message);
|
| 666 |
-
|
| 667 |
-
//
|
| 668 |
if (data.chat_id) {
|
| 669 |
-
currentChatId = data.chat_id;
|
| 670 |
-
|
| 671 |
-
console.log('✅ 已設置 chat_id:', currentChatId);
|
| 672 |
}
|
| 673 |
-
|
| 674 |
if (data.message) {
|
| 675 |
setState('speaking', {
|
| 676 |
outputText: data.message,
|
|
@@ -698,10 +496,6 @@ function initializeWebSocket(token) {
|
|
| 698 |
has_tool_data: !!data.tool_data,
|
| 699 |
tool_data_keys: data.tool_data ? Object.keys(data.tool_data) : null
|
| 700 |
});
|
| 701 |
-
const inCareMode = Boolean(data.care_mode);
|
| 702 |
-
if (inCareMode) {
|
| 703 |
-
console.log('💙 關懷模式啟動:隱藏工具卡片');
|
| 704 |
-
}
|
| 705 |
|
| 706 |
// 同時啟動:文字打字效果 + 語音播放
|
| 707 |
setState('speaking', {
|
|
@@ -709,15 +503,12 @@ function initializeWebSocket(token) {
|
|
| 709 |
enableTTS: true // 啟用語音(異步並行)
|
| 710 |
});
|
| 711 |
|
| 712 |
-
|
| 713 |
-
if (
|
| 714 |
console.log('📊 準備顯示工具卡片:', data.tool_name);
|
| 715 |
displayToolCard(data.tool_name, data.tool_data);
|
| 716 |
} else {
|
| 717 |
-
console.log('⚠️
|
| 718 |
-
if (typeof clearAllCards === 'function') {
|
| 719 |
-
clearAllCards();
|
| 720 |
-
}
|
| 721 |
}
|
| 722 |
|
| 723 |
// 不自動返回 idle,保持回應顯示
|
|
@@ -740,45 +531,22 @@ function initializeWebSocket(token) {
|
|
| 740 |
|
| 741 |
// 應用情緒主題(如果有的話)
|
| 742 |
if (data.emotion && typeof applyEmotion === 'function') {
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
applyEmotion(emotionValue);
|
| 746 |
-
try { if (emotionValue) localStorage.setItem('lastEmotion', String(emotionValue)); } catch(_) {}
|
| 747 |
-
}
|
| 748 |
-
break;
|
| 749 |
-
|
| 750 |
-
case 'emotion_detected':
|
| 751 |
-
// 文字情緒偵測結果(新增)
|
| 752 |
-
console.log('😊 偵測到情緒:', data.emotion, 'care_mode:', data.care_mode);
|
| 753 |
-
if (data.emotion && typeof applyEmotion === 'function') {
|
| 754 |
-
const emotionValue = typeof data.emotion === 'string' ? data.emotion : data.emotion.label;
|
| 755 |
-
applyEmotion(emotionValue);
|
| 756 |
-
}
|
| 757 |
-
if (data.care_mode) {
|
| 758 |
-
console.log('💙 進入關懷模式');
|
| 759 |
}
|
| 760 |
break;
|
| 761 |
|
| 762 |
-
case '
|
| 763 |
-
//
|
| 764 |
-
currentChatId = data.chat_id;
|
| 765 |
-
|
| 766 |
-
console.log('✅ 新對話建立:', currentChatId, '標題:', data.title);
|
| 767 |
break;
|
| 768 |
|
| 769 |
case 'error':
|
| 770 |
// 錯誤訊息
|
| 771 |
console.error('❌ 後端錯誤:', data.message);
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
const isEnvSnapshotWarning = messageText.includes('env_snapshot');
|
| 775 |
-
|
| 776 |
-
if (!isEnvSnapshotWarning) {
|
| 777 |
-
setState('idle');
|
| 778 |
-
showErrorNotification(messageText || '系統發生未知錯誤');
|
| 779 |
-
} else {
|
| 780 |
-
console.warn('⚠️ 後端尚未支援 env_snapshot,忽略此警告');
|
| 781 |
-
}
|
| 782 |
break;
|
| 783 |
|
| 784 |
case 'voice_login_result':
|
|
@@ -791,27 +559,21 @@ function initializeWebSocket(token) {
|
|
| 791 |
console.log('🎙️ 語音登入狀態:', data.message);
|
| 792 |
break;
|
| 793 |
|
| 794 |
-
case '
|
| 795 |
-
//
|
| 796 |
-
console.log('
|
| 797 |
-
handleVoiceBindingReady();
|
| 798 |
-
break;
|
| 799 |
-
|
| 800 |
-
case 'voice_binding_success':
|
| 801 |
-
// 綁定成功:不重覆顯示訊息,只更新本地狀態(供後續使用)
|
| 802 |
-
try {
|
| 803 |
-
if (data.speaker_label) {
|
| 804 |
-
localStorage.setItem('speaker_label', data.speaker_label);
|
| 805 |
-
}
|
| 806 |
-
} catch (_) {}
|
| 807 |
-
console.log('✅ 語音綁定完成(已更新本地狀態)');
|
| 808 |
-
break;
|
| 809 |
|
| 810 |
-
|
| 811 |
-
if (data.
|
| 812 |
-
|
|
|
|
| 813 |
} else {
|
| 814 |
-
console.warn('⚠️
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 815 |
}
|
| 816 |
break;
|
| 817 |
|
|
@@ -844,11 +606,8 @@ function handleVoiceLoginResult(data) {
|
|
| 844 |
currentUserId = data.user.id;
|
| 845 |
|
| 846 |
// 套用情緒主題
|
| 847 |
-
if (data.emotion) {
|
| 848 |
-
|
| 849 |
-
applyEmotion(emotionValue);
|
| 850 |
-
// 持久化情緒以便重新整理或跳頁仍保持主題
|
| 851 |
-
try { if (emotionValue) localStorage.setItem('lastEmotion', String(emotionValue)); } catch(_) {}
|
| 852 |
}
|
| 853 |
|
| 854 |
// 顯示歡迎詞
|
|
@@ -867,89 +626,4 @@ function handleVoiceLoginResult(data) {
|
|
| 867 |
}
|
| 868 |
}
|
| 869 |
|
| 870 |
-
/**
|
| 871 |
-
* 處理語音綁定準備就緒訊息
|
| 872 |
-
* 自動切換到花蕊錄音狀態,錄製 5 秒語音
|
| 873 |
-
*/
|
| 874 |
-
async function handleVoiceBindingReady() {
|
| 875 |
-
console.log('🌸 開始語音綁定流程:自動錄音 5 秒');
|
| 876 |
-
|
| 877 |
-
// 更新提示文字
|
| 878 |
-
if (typeof transcript !== 'undefined') {
|
| 879 |
-
transcript.textContent = '請開始說話(錄音 3 秒)...';
|
| 880 |
-
transcript.className = 'voice-transcript provisional';
|
| 881 |
-
}
|
| 882 |
-
|
| 883 |
-
// 切換到錄音狀態(花蕊綻放)
|
| 884 |
-
if (typeof setState === 'function') {
|
| 885 |
-
setState('recording', {
|
| 886 |
-
keepOutput: false,
|
| 887 |
-
keepCards: false
|
| 888 |
-
});
|
| 889 |
-
}
|
| 890 |
-
|
| 891 |
-
// 啟動音訊視覺化
|
| 892 |
-
if (typeof startRealAudioAnalysis === 'function') {
|
| 893 |
-
await startRealAudioAnalysis();
|
| 894 |
-
}
|
| 895 |
-
|
| 896 |
-
// 啟動語音綁定專用錄音
|
| 897 |
-
if (wsManager && typeof wsManager.startVoiceBindingRecording === 'function') {
|
| 898 |
-
const success = await wsManager.startVoiceBindingRecording();
|
| 899 |
-
|
| 900 |
-
if (!success) {
|
| 901 |
-
console.error('❌ 語音綁定錄音啟動失敗');
|
| 902 |
-
setState('idle');
|
| 903 |
-
if (typeof stopRealAudioAnalysis === 'function') {
|
| 904 |
-
stopRealAudioAnalysis();
|
| 905 |
-
}
|
| 906 |
-
showErrorNotification('麥克風啟動失敗,請檢查權限設定');
|
| 907 |
-
return;
|
| 908 |
-
}
|
| 909 |
-
|
| 910 |
-
console.log('⏱️ 開始倒數 3 秒錄音...');
|
| 911 |
-
|
| 912 |
-
// 倒數計時提示
|
| 913 |
-
let countdown = 3;
|
| 914 |
-
const countdownInterval = setInterval(() => {
|
| 915 |
-
countdown--;
|
| 916 |
-
if (countdown > 0 && typeof transcript !== 'undefined') {
|
| 917 |
-
transcript.textContent = `請繼續說話(剩餘 ${countdown} 秒)...`;
|
| 918 |
-
transcript.className = 'voice-transcript provisional';
|
| 919 |
-
}
|
| 920 |
-
}, 1000);
|
| 921 |
-
|
| 922 |
-
// 3 秒後自動停止錄音
|
| 923 |
-
setTimeout(() => {
|
| 924 |
-
clearInterval(countdownInterval);
|
| 925 |
-
console.log('⏹️ 3 秒錄音完成,自動停止');
|
| 926 |
-
|
| 927 |
-
// 停止音訊視覺化
|
| 928 |
-
if (typeof stopRealAudioAnalysis === 'function') {
|
| 929 |
-
stopRealAudioAnalysis();
|
| 930 |
-
}
|
| 931 |
-
|
| 932 |
-
// 停止語音綁定錄音
|
| 933 |
-
if (wsManager && typeof wsManager.stopVoiceBindingRecording === 'function') {
|
| 934 |
-
wsManager.stopVoiceBindingRecording();
|
| 935 |
-
}
|
| 936 |
-
|
| 937 |
-
// 切換到思考狀態
|
| 938 |
-
if (typeof setState === 'function') {
|
| 939 |
-
setState('thinking');
|
| 940 |
-
}
|
| 941 |
-
|
| 942 |
-
// 更新提示
|
| 943 |
-
if (typeof transcript !== 'undefined') {
|
| 944 |
-
transcript.textContent = '正在處理語音綁定...';
|
| 945 |
-
transcript.className = 'voice-transcript provisional';
|
| 946 |
-
}
|
| 947 |
-
|
| 948 |
-
}, 3000); // 3 秒錄音時長
|
| 949 |
-
} else {
|
| 950 |
-
console.error('❌ WebSocket 管理器未初始化');
|
| 951 |
-
showErrorNotification('系統錯誤:WebSocket 未連接');
|
| 952 |
-
}
|
| 953 |
-
}
|
| 954 |
-
|
| 955 |
console.log('✅ WebSocket 模組已載入(完整版)');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
/**
|
| 2 |
* Bloom Ware WebSocket 通訊管理模組(完整版)
|
| 3 |
* 處理 WebSocket 連接、訊息收發、重連機制
|
|
|
|
| 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 |
|
|
|
|
| 124 |
localStorage.removeItem('jwt_token');
|
| 125 |
// 跳轉到登入頁
|
| 126 |
setTimeout(() => {
|
| 127 |
+
window.location.href = '/login/';
|
| 128 |
}, 500);
|
| 129 |
return;
|
| 130 |
}
|
|
|
|
| 153 |
if (payload.exp && payload.exp < currentTime) {
|
| 154 |
console.error('❌ Token 已過期,跳轉到登入頁面');
|
| 155 |
localStorage.removeItem('jwt_token');
|
| 156 |
+
window.location.href = '/login/';
|
| 157 |
}
|
| 158 |
} catch (e) {
|
| 159 |
console.error('❌ Token 解析失敗,跳轉到登入頁面');
|
| 160 |
localStorage.removeItem('jwt_token');
|
| 161 |
+
window.location.href = '/login/';
|
| 162 |
}
|
| 163 |
}
|
| 164 |
}
|
|
|
|
| 180 |
if (payload.exp && payload.exp < currentTime) {
|
| 181 |
console.error('❌ Token 已過期,跳轉到登入頁面');
|
| 182 |
localStorage.removeItem('jwt_token');
|
| 183 |
+
window.location.href = '/login/';
|
| 184 |
return;
|
| 185 |
}
|
| 186 |
} catch (error) {
|
|
|
|
| 192 |
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
| 193 |
console.error('❌ WebSocket 重連次數已達上限,可能是認證問題,清除 token 並跳轉登入頁');
|
| 194 |
localStorage.removeItem('jwt_token');
|
| 195 |
+
window.location.href = '/login/';
|
| 196 |
return;
|
| 197 |
}
|
| 198 |
}
|
|
|
|
| 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;
|
| 233 |
}
|
| 234 |
|
| 235 |
const payload = {
|
| 236 |
type: 'user_message',
|
| 237 |
message: text,
|
| 238 |
+
chat_id: chatId
|
| 239 |
};
|
| 240 |
|
| 241 |
return this.send(payload);
|
|
|
|
| 427 |
}
|
| 428 |
|
| 429 |
// 發送停止錄音信號
|
| 430 |
+
this.send({
|
| 431 |
type: 'audio_stop',
|
| 432 |
mode: 'chat' // 對話模式
|
| 433 |
});
|
|
|
|
| 435 |
this.isRecording = false;
|
| 436 |
console.log('✅ 錄音已停止');
|
| 437 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
}
|
| 439 |
|
| 440 |
/**
|
|
|
|
| 462 |
case 'system':
|
| 463 |
// 系統訊息(歡迎詞、連線成功)
|
| 464 |
console.log('🔔 系統訊息:', data.message);
|
| 465 |
+
|
| 466 |
+
// 提取並保存 chat_id(後端在歡迎訊息中會發送)
|
| 467 |
if (data.chat_id) {
|
| 468 |
+
window.currentChatId = data.chat_id;
|
| 469 |
+
console.log('✅ Chat ID 已設定(來自 system 訊息):', window.currentChatId);
|
|
|
|
| 470 |
}
|
| 471 |
+
|
| 472 |
if (data.message) {
|
| 473 |
setState('speaking', {
|
| 474 |
outputText: data.message,
|
|
|
|
| 496 |
has_tool_data: !!data.tool_data,
|
| 497 |
tool_data_keys: data.tool_data ? Object.keys(data.tool_data) : null
|
| 498 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
|
| 500 |
// 同時啟動:文字打字效果 + 語音播放
|
| 501 |
setState('speaking', {
|
|
|
|
| 503 |
enableTTS: true // 啟用語音(異步並行)
|
| 504 |
});
|
| 505 |
|
| 506 |
+
// 如果有工具資料,顯示對應卡片
|
| 507 |
+
if (data.tool_name && data.tool_data) {
|
| 508 |
console.log('📊 準備顯示工具卡片:', data.tool_name);
|
| 509 |
displayToolCard(data.tool_name, data.tool_data);
|
| 510 |
} else {
|
| 511 |
+
console.log('⚠️ 無工具資料,不顯示卡片');
|
|
|
|
|
|
|
|
|
|
| 512 |
}
|
| 513 |
|
| 514 |
// 不自動返回 idle,保持回應顯示
|
|
|
|
| 531 |
|
| 532 |
// 應用情緒主題(如果有的話)
|
| 533 |
if (data.emotion && typeof applyEmotion === 'function') {
|
| 534 |
+
console.log('😊 應用情緒主題:', data.emotion);
|
| 535 |
+
applyEmotion(data.emotion);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
}
|
| 537 |
break;
|
| 538 |
|
| 539 |
+
case 'chat_ready':
|
| 540 |
+
// 後端發送當前 chat_id
|
| 541 |
+
window.currentChatId = data.chat_id;
|
| 542 |
+
console.log('✅ Chat ID 已設定:', window.currentChatId);
|
|
|
|
| 543 |
break;
|
| 544 |
|
| 545 |
case 'error':
|
| 546 |
// 錯誤訊息
|
| 547 |
console.error('❌ 後端錯誤:', data.message);
|
| 548 |
+
setState('idle');
|
| 549 |
+
showErrorNotification(data.message);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 550 |
break;
|
| 551 |
|
| 552 |
case 'voice_login_result':
|
|
|
|
| 559 |
console.log('🎙️ 語音登入狀態:', data.message);
|
| 560 |
break;
|
| 561 |
|
| 562 |
+
case 'emotion_detected':
|
| 563 |
+
// 情緒檢測結果
|
| 564 |
+
console.log('😊 檢測到情緒:', data.emotion, '關懷模式:', data.care_mode);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
|
| 566 |
+
// 應用情緒主題(使用 agent.js 的 applyEmotion 函數)
|
| 567 |
+
if (data.emotion && typeof applyEmotion === 'function') {
|
| 568 |
+
applyEmotion(data.emotion);
|
| 569 |
+
console.log('✅ 情緒主題已套用:', data.emotion);
|
| 570 |
} else {
|
| 571 |
+
console.warn('⚠️ applyEmotion 函數未定義或情緒值無效');
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
// 如果啟用關懷模式,可以在這裡添加額外的 UI 提示
|
| 575 |
+
if (data.care_mode) {
|
| 576 |
+
console.log('💙 關懷模式已啟動');
|
| 577 |
}
|
| 578 |
break;
|
| 579 |
|
|
|
|
| 606 |
currentUserId = data.user.id;
|
| 607 |
|
| 608 |
// 套用情緒主題
|
| 609 |
+
if (data.emotion && data.emotion.label) {
|
| 610 |
+
applyEmotion(data.emotion.label);
|
|
|
|
|
|
|
|
|
|
| 611 |
}
|
| 612 |
|
| 613 |
// 顯示歡迎詞
|
|
|
|
| 626 |
}
|
| 627 |
}
|
| 628 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 629 |
console.log('✅ WebSocket 模組已載入(完整版)');
|
static/frontend/login.html
DELETED
|
@@ -1,547 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="zh-TW">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
-
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://accounts.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; connect-src 'self' ws: wss: https://accounts.google.com; img-src 'self' data: https: blob:; media-src 'self' blob: data:; frame-src https://accounts.google.com; base-uri 'self';">
|
| 7 |
-
<title>登入 - Bloom Ware</title>
|
| 8 |
-
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
|
| 9 |
-
<style>
|
| 10 |
-
* {
|
| 11 |
-
box-sizing: border-box;
|
| 12 |
-
margin: 0;
|
| 13 |
-
padding: 0;
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
body {
|
| 17 |
-
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 18 |
-
background: #FFF8E7;
|
| 19 |
-
min-height: 100vh;
|
| 20 |
-
display: flex;
|
| 21 |
-
align-items: center;
|
| 22 |
-
justify-content: center;
|
| 23 |
-
-webkit-font-smoothing: antialiased;
|
| 24 |
-
position: relative;
|
| 25 |
-
overflow: hidden;
|
| 26 |
-
padding: 1rem;
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
/* 背景裝飾鬱金香 */
|
| 30 |
-
.tulip-decoration {
|
| 31 |
-
position: absolute;
|
| 32 |
-
opacity: 0.15;
|
| 33 |
-
pointer-events: none;
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
.tulip-decoration.top-left {
|
| 37 |
-
top: 2rem;
|
| 38 |
-
left: 2rem;
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
.tulip-decoration.bottom-right {
|
| 42 |
-
bottom: 2rem;
|
| 43 |
-
right: 2rem;
|
| 44 |
-
transform: rotate(180deg);
|
| 45 |
-
}
|
| 46 |
-
|
| 47 |
-
.tulip-decoration.top-right {
|
| 48 |
-
top: 25%;
|
| 49 |
-
right: 3rem;
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
.tulip-decoration.bottom-left {
|
| 53 |
-
bottom: 25%;
|
| 54 |
-
left: 3rem;
|
| 55 |
-
transform: rotate(12deg);
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
@media (max-width: 768px) {
|
| 59 |
-
.tulip-decoration {
|
| 60 |
-
display: none;
|
| 61 |
-
}
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
.login-container {
|
| 65 |
-
width: 100%;
|
| 66 |
-
max-width: 28rem;
|
| 67 |
-
position: relative;
|
| 68 |
-
z-index: 10;
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
.login-content {
|
| 72 |
-
display: flex;
|
| 73 |
-
flex-direction: column;
|
| 74 |
-
align-items: center;
|
| 75 |
-
gap: 1.5rem;
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
.brand-section {
|
| 79 |
-
text-align: center;
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
.brand-title {
|
| 83 |
-
font-family: 'Playfair Display', Georgia, serif;
|
| 84 |
-
font-size: 3rem;
|
| 85 |
-
color: #2C2C2C;
|
| 86 |
-
letter-spacing: 0.05em;
|
| 87 |
-
margin-bottom: 0.25rem;
|
| 88 |
-
font-weight: 500;
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
@media (min-width: 640px) {
|
| 92 |
-
.brand-title {
|
| 93 |
-
font-size: 3.5rem;
|
| 94 |
-
}
|
| 95 |
-
}
|
| 96 |
-
|
| 97 |
-
@media (min-width: 768px) {
|
| 98 |
-
.brand-title {
|
| 99 |
-
font-size: 4rem;
|
| 100 |
-
}
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
.brand-subtitle {
|
| 104 |
-
color: #4A4A4A;
|
| 105 |
-
font-size: 0.75rem;
|
| 106 |
-
letter-spacing: 0.2em;
|
| 107 |
-
text-transform: uppercase;
|
| 108 |
-
font-weight: 400;
|
| 109 |
-
}
|
| 110 |
-
|
| 111 |
-
@media (min-width: 640px) {
|
| 112 |
-
.brand-subtitle {
|
| 113 |
-
font-size: 0.875rem;
|
| 114 |
-
}
|
| 115 |
-
}
|
| 116 |
-
|
| 117 |
-
.tulip-illustration {
|
| 118 |
-
margin: 2rem 0;
|
| 119 |
-
}
|
| 120 |
-
|
| 121 |
-
@media (min-width: 640px) {
|
| 122 |
-
.tulip-illustration {
|
| 123 |
-
margin: 2.5rem 0;
|
| 124 |
-
}
|
| 125 |
-
}
|
| 126 |
-
|
| 127 |
-
@media (min-width: 768px) {
|
| 128 |
-
.tulip-illustration {
|
| 129 |
-
margin: 3rem 0;
|
| 130 |
-
}
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
.login-buttons {
|
| 134 |
-
width: 100%;
|
| 135 |
-
display: flex;
|
| 136 |
-
flex-direction: column;
|
| 137 |
-
gap: 0.75rem;
|
| 138 |
-
}
|
| 139 |
-
|
| 140 |
-
@media (min-width: 640px) {
|
| 141 |
-
.login-buttons {
|
| 142 |
-
gap: 1rem;
|
| 143 |
-
}
|
| 144 |
-
}
|
| 145 |
-
|
| 146 |
-
.login-btn {
|
| 147 |
-
width: 100%;
|
| 148 |
-
height: 2.75rem;
|
| 149 |
-
display: flex;
|
| 150 |
-
align-items: center;
|
| 151 |
-
justify-content: center;
|
| 152 |
-
gap: 0.5rem;
|
| 153 |
-
background: white;
|
| 154 |
-
border: 1px solid #E5E5E5;
|
| 155 |
-
border-radius: 0.5rem;
|
| 156 |
-
color: #2C2C2C;
|
| 157 |
-
font-family: 'Inter', sans-serif;
|
| 158 |
-
font-size: 0.875rem;
|
| 159 |
-
font-weight: 500;
|
| 160 |
-
cursor: pointer;
|
| 161 |
-
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
| 162 |
-
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 163 |
-
}
|
| 164 |
-
|
| 165 |
-
@media (min-width: 640px) {
|
| 166 |
-
.login-btn {
|
| 167 |
-
height: 3rem;
|
| 168 |
-
gap: 0.75rem;
|
| 169 |
-
font-size: 1rem;
|
| 170 |
-
}
|
| 171 |
-
}
|
| 172 |
-
|
| 173 |
-
.login-btn:hover {
|
| 174 |
-
background: #F9F9F9;
|
| 175 |
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
| 176 |
-
transform: translateY(-1px);
|
| 177 |
-
}
|
| 178 |
-
|
| 179 |
-
.login-btn:active {
|
| 180 |
-
transform: translateY(0);
|
| 181 |
-
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 182 |
-
}
|
| 183 |
-
|
| 184 |
-
.btn-icon {
|
| 185 |
-
width: 1rem;
|
| 186 |
-
height: 1rem;
|
| 187 |
-
}
|
| 188 |
-
|
| 189 |
-
@media (min-width: 640px) {
|
| 190 |
-
.btn-icon {
|
| 191 |
-
width: 1.25rem;
|
| 192 |
-
height: 1.25rem;
|
| 193 |
-
}
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
.disclaimer {
|
| 197 |
-
color: #4A4A4A;
|
| 198 |
-
font-size: 0.625rem;
|
| 199 |
-
text-align: center;
|
| 200 |
-
margin-top: 1.5rem;
|
| 201 |
-
max-width: 20rem;
|
| 202 |
-
padding: 0 1rem;
|
| 203 |
-
line-height: 1.5;
|
| 204 |
-
}
|
| 205 |
-
|
| 206 |
-
@media (min-width: 640px) {
|
| 207 |
-
.disclaimer {
|
| 208 |
-
font-size: 0.75rem;
|
| 209 |
-
margin-top: 2rem;
|
| 210 |
-
}
|
| 211 |
-
}
|
| 212 |
-
|
| 213 |
-
/* 語音登入狀態提示 */
|
| 214 |
-
.voice-status {
|
| 215 |
-
display: none;
|
| 216 |
-
margin-top: 1rem;
|
| 217 |
-
font-size: 0.875rem;
|
| 218 |
-
color: #4A4A4A;
|
| 219 |
-
text-align: center;
|
| 220 |
-
}
|
| 221 |
-
|
| 222 |
-
.voice-status.active {
|
| 223 |
-
display: block;
|
| 224 |
-
}
|
| 225 |
-
|
| 226 |
-
/* === 頁面過渡動畫 === */
|
| 227 |
-
@keyframes fadeOut {
|
| 228 |
-
from {
|
| 229 |
-
opacity: 1;
|
| 230 |
-
transform: scale(1);
|
| 231 |
-
}
|
| 232 |
-
to {
|
| 233 |
-
opacity: 0;
|
| 234 |
-
transform: scale(0.98);
|
| 235 |
-
}
|
| 236 |
-
}
|
| 237 |
-
|
| 238 |
-
@keyframes fadeIn {
|
| 239 |
-
from {
|
| 240 |
-
opacity: 0;
|
| 241 |
-
transform: scale(1.02);
|
| 242 |
-
}
|
| 243 |
-
to {
|
| 244 |
-
opacity: 1;
|
| 245 |
-
transform: scale(1);
|
| 246 |
-
}
|
| 247 |
-
}
|
| 248 |
-
|
| 249 |
-
/* 頁面淡出狀態 */
|
| 250 |
-
body.page-transitioning {
|
| 251 |
-
animation: fadeOut 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
| 252 |
-
}
|
| 253 |
-
|
| 254 |
-
/* 頁面淡入狀態 */
|
| 255 |
-
body.page-entering {
|
| 256 |
-
animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
| 257 |
-
}
|
| 258 |
-
|
| 259 |
-
/* 載入覆蓋層 */
|
| 260 |
-
.loading-overlay {
|
| 261 |
-
position: fixed;
|
| 262 |
-
inset: 0;
|
| 263 |
-
background: linear-gradient(135deg, #FFF8E7 0%, #F5F1ED 100%);
|
| 264 |
-
display: flex;
|
| 265 |
-
flex-direction: column;
|
| 266 |
-
align-items: center;
|
| 267 |
-
justify-content: center;
|
| 268 |
-
z-index: 9999;
|
| 269 |
-
opacity: 0;
|
| 270 |
-
visibility: hidden;
|
| 271 |
-
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
| 272 |
-
visibility 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 273 |
-
}
|
| 274 |
-
|
| 275 |
-
.loading-overlay.active {
|
| 276 |
-
opacity: 1;
|
| 277 |
-
visibility: visible;
|
| 278 |
-
}
|
| 279 |
-
|
| 280 |
-
/* 蓮花載入動畫 */
|
| 281 |
-
.loading-lotus {
|
| 282 |
-
width: 80px;
|
| 283 |
-
height: 80px;
|
| 284 |
-
position: relative;
|
| 285 |
-
margin-bottom: 2rem;
|
| 286 |
-
}
|
| 287 |
-
|
| 288 |
-
.lotus-petal {
|
| 289 |
-
position: absolute;
|
| 290 |
-
width: 24px;
|
| 291 |
-
height: 40px;
|
| 292 |
-
background: linear-gradient(180deg,
|
| 293 |
-
rgba(255, 255, 255, 0.95) 0%,
|
| 294 |
-
rgba(249, 250, 251, 0.9) 100%
|
| 295 |
-
);
|
| 296 |
-
border: 1px solid rgba(0, 0, 0, 0.08);
|
| 297 |
-
border-radius: 50% 50% 50% 50% / 60% 60% 40% 40%;
|
| 298 |
-
top: 50%;
|
| 299 |
-
left: 50%;
|
| 300 |
-
transform-origin: 50% 100%;
|
| 301 |
-
animation: petalPulse 1.6s ease-in-out infinite;
|
| 302 |
-
}
|
| 303 |
-
|
| 304 |
-
.lotus-petal:nth-child(1) {
|
| 305 |
-
transform: translate(-50%, -50%) rotate(0deg);
|
| 306 |
-
animation-delay: 0s;
|
| 307 |
-
}
|
| 308 |
-
|
| 309 |
-
.lotus-petal:nth-child(2) {
|
| 310 |
-
transform: translate(-50%, -50%) rotate(45deg);
|
| 311 |
-
animation-delay: 0.2s;
|
| 312 |
-
}
|
| 313 |
-
|
| 314 |
-
.lotus-petal:nth-child(3) {
|
| 315 |
-
transform: translate(-50%, -50%) rotate(90deg);
|
| 316 |
-
animation-delay: 0.4s;
|
| 317 |
-
}
|
| 318 |
-
|
| 319 |
-
.lotus-petal:nth-child(4) {
|
| 320 |
-
transform: translate(-50%, -50%) rotate(135deg);
|
| 321 |
-
animation-delay: 0.6s;
|
| 322 |
-
}
|
| 323 |
-
|
| 324 |
-
.lotus-petal:nth-child(5) {
|
| 325 |
-
transform: translate(-50%, -50%) rotate(180deg);
|
| 326 |
-
animation-delay: 0.8s;
|
| 327 |
-
}
|
| 328 |
-
|
| 329 |
-
.lotus-petal:nth-child(6) {
|
| 330 |
-
transform: translate(-50%, -50%) rotate(225deg);
|
| 331 |
-
animation-delay: 1.0s;
|
| 332 |
-
}
|
| 333 |
-
|
| 334 |
-
.lotus-petal:nth-child(7) {
|
| 335 |
-
transform: translate(-50%, -50%) rotate(270deg);
|
| 336 |
-
animation-delay: 1.2s;
|
| 337 |
-
}
|
| 338 |
-
|
| 339 |
-
.lotus-petal:nth-child(8) {
|
| 340 |
-
transform: translate(-50%, -50%) rotate(315deg);
|
| 341 |
-
animation-delay: 1.4s;
|
| 342 |
-
}
|
| 343 |
-
|
| 344 |
-
/* 花蕊 */
|
| 345 |
-
.lotus-core {
|
| 346 |
-
position: absolute;
|
| 347 |
-
top: 50%;
|
| 348 |
-
left: 50%;
|
| 349 |
-
transform: translate(-50%, -50%);
|
| 350 |
-
width: 16px;
|
| 351 |
-
height: 16px;
|
| 352 |
-
border-radius: 50%;
|
| 353 |
-
background: radial-gradient(circle at 30% 30%,
|
| 354 |
-
#FEF9E7 0%,
|
| 355 |
-
#FCD34D 50%,
|
| 356 |
-
#F59E0B 100%
|
| 357 |
-
);
|
| 358 |
-
box-shadow:
|
| 359 |
-
0 2px 8px rgba(245, 158, 11, 0.4),
|
| 360 |
-
inset 0 1px 4px rgba(255, 255, 255, 0.6);
|
| 361 |
-
animation: coreBreath 1.6s ease-in-out infinite;
|
| 362 |
-
}
|
| 363 |
-
|
| 364 |
-
@keyframes petalPulse {
|
| 365 |
-
0%, 100% {
|
| 366 |
-
opacity: 0.7;
|
| 367 |
-
transform: translate(-50%, -50%) rotate(var(--rotate, 0deg)) translateY(-5px);
|
| 368 |
-
}
|
| 369 |
-
50% {
|
| 370 |
-
opacity: 1;
|
| 371 |
-
transform: translate(-50%, -50%) rotate(var(--rotate, 0deg)) translateY(-8px);
|
| 372 |
-
}
|
| 373 |
-
}
|
| 374 |
-
|
| 375 |
-
@keyframes coreBreath {
|
| 376 |
-
0%, 100% {
|
| 377 |
-
transform: translate(-50%, -50%) scale(1);
|
| 378 |
-
box-shadow:
|
| 379 |
-
0 2px 8px rgba(245, 158, 11, 0.4),
|
| 380 |
-
inset 0 1px 4px rgba(255, 255, 255, 0.6);
|
| 381 |
-
}
|
| 382 |
-
50% {
|
| 383 |
-
transform: translate(-50%, -50%) scale(1.1);
|
| 384 |
-
box-shadow:
|
| 385 |
-
0 3px 12px rgba(245, 158, 11, 0.6),
|
| 386 |
-
inset 0 1px 6px rgba(255, 255, 255, 0.8);
|
| 387 |
-
}
|
| 388 |
-
}
|
| 389 |
-
|
| 390 |
-
/* 載入文字 */
|
| 391 |
-
.loading-text {
|
| 392 |
-
font-family: 'Playfair Display', Georgia, serif;
|
| 393 |
-
font-size: 1.5rem;
|
| 394 |
-
color: #2C2C2C;
|
| 395 |
-
letter-spacing: 0.05em;
|
| 396 |
-
margin-bottom: 0.5rem;
|
| 397 |
-
animation: textFade 1.6s ease-in-out infinite;
|
| 398 |
-
}
|
| 399 |
-
|
| 400 |
-
.loading-subtext {
|
| 401 |
-
font-size: 0.875rem;
|
| 402 |
-
color: rgba(0, 0, 0, 0.5);
|
| 403 |
-
letter-spacing: 0.1em;
|
| 404 |
-
}
|
| 405 |
-
|
| 406 |
-
@keyframes textFade {
|
| 407 |
-
0%, 100% { opacity: 0.6; }
|
| 408 |
-
50% { opacity: 1; }
|
| 409 |
-
}
|
| 410 |
-
</style>
|
| 411 |
-
</head>
|
| 412 |
-
<body class="page-entering">
|
| 413 |
-
<!-- 載入覆蓋層 -->
|
| 414 |
-
<div class="loading-overlay" id="loadingOverlay">
|
| 415 |
-
<div class="loading-lotus">
|
| 416 |
-
<div class="lotus-petal" style="--rotate: 0deg;"></div>
|
| 417 |
-
<div class="lotus-petal" style="--rotate: 45deg;"></div>
|
| 418 |
-
<div class="lotus-petal" style="--rotate: 90deg;"></div>
|
| 419 |
-
<div class="lotus-petal" style="--rotate: 135deg;"></div>
|
| 420 |
-
<div class="lotus-petal" style="--rotate: 180deg;"></div>
|
| 421 |
-
<div class="lotus-petal" style="--rotate: 225deg;"></div>
|
| 422 |
-
<div class="lotus-petal" style="--rotate: 270deg;"></div>
|
| 423 |
-
<div class="lotus-petal" style="--rotate: 315deg;"></div>
|
| 424 |
-
<div class="lotus-core"></div>
|
| 425 |
-
</div>
|
| 426 |
-
<div class="loading-text">Bloom Ware</div>
|
| 427 |
-
<div class="loading-subtext">正在進入...</div>
|
| 428 |
-
</div>
|
| 429 |
-
|
| 430 |
-
<!-- 背景裝飾鬱金香 -->
|
| 431 |
-
<div class="tulip-decoration top-left">
|
| 432 |
-
<svg viewBox="0 0 180 240" fill="none" xmlns="http://www.w3.org/2000/svg" width="80" height="100">
|
| 433 |
-
<path d="M 70 85 Q 52 58, 58 28 Q 62 15, 68 18 Q 75 32, 80 55 Q 83 70, 82 85 Z" stroke="#3A3A3A" stroke-width="0.7" fill="rgba(255, 255, 255, 0.85)" stroke-linecap="round" stroke-linejoin="round" opacity="0.95"/>
|
| 434 |
-
<path d="M 82 85 Q 85 45, 88 20 Q 89 8, 92 12 Q 95 35, 93 65 Q 92 78, 93 85 Z" stroke="#3A3A3A" stroke-width="0.7" fill="rgba(255, 255, 255, 0.9)" stroke-linecap="round" stroke-linejoin="round" opacity="0.95"/>
|
| 435 |
-
<path d="M 93 85 Q 90 55, 92 35 Q 96 20, 102 25 Q 110 42, 115 60 Q 118 75, 108 85 Z" stroke="#3A3A3A" stroke-width="0.7" fill="rgba(255, 255, 255, 0.85)" stroke-linecap="round" stroke-linejoin="round" opacity="0.95"/>
|
| 436 |
-
<path d="M 72 75 Q 74 52, 76 32" stroke="#3A3A3A" stroke-width="0.4" fill="none" opacity="0.25" stroke-linecap="round"/>
|
| 437 |
-
<path d="M 88 78 Q 89 53, 90 28" stroke="#3A3A3A" stroke-width="0.4" fill="none" opacity="0.25" stroke-linecap="round"/>
|
| 438 |
-
<path d="M 104 75 Q 102 52, 100 32" stroke="#3A3A3A" stroke-width="0.4" fill="none" opacity="0.25" stroke-linecap="round"/>
|
| 439 |
-
<path d="M 90 85 Q 88 125, 86 165 Q 85 185, 86 220" stroke="#3A3A3A" stroke-width="1.2" fill="none" stroke-linecap="round" opacity="0.9"/>
|
| 440 |
-
<path d="M 86 145 Q 65 148, 50 156 Q 45 159, 47 162 Q 54 164, 72 160 Q 83 156, 86 152" stroke="#3A3A3A" stroke-width="0.7" fill="rgba(255, 255, 255, 0.7)" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/>
|
| 441 |
-
<path d="M 86 185 Q 107 188, 122 196 Q 127 199, 125 202 Q 118 204, 100 200 Q 89 196, 86 192" stroke="#3A3A3A" stroke-width="0.7" fill="rgba(255, 255, 255, 0.7)" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/>
|
| 442 |
-
</svg>
|
| 443 |
-
</div>
|
| 444 |
-
|
| 445 |
-
<div class="tulip-decoration bottom-right">
|
| 446 |
-
<svg viewBox="0 0 180 240" fill="none" xmlns="http://www.w3.org/2000/svg" width="80" height="100">
|
| 447 |
-
<path d="M 70 85 Q 52 58, 58 28 Q 62 15, 68 18 Q 75 32, 80 55 Q 83 70, 82 85 Z" stroke="#3A3A3A" stroke-width="0.7" fill="rgba(255, 255, 255, 0.85)" stroke-linecap="round" stroke-linejoin="round" opacity="0.95"/>
|
| 448 |
-
<path d="M 82 85 Q 85 45, 88 20 Q 89 8, 92 12 Q 95 35, 93 65 Q 92 78, 93 85 Z" stroke="#3A3A3A" stroke-width="0.7" fill="rgba(255, 255, 255, 0.9)" stroke-linecap="round" stroke-linejoin="round" opacity="0.95"/>
|
| 449 |
-
<path d="M 93 85 Q 90 55, 92 35 Q 96 20, 102 25 Q 110 42, 115 60 Q 118 75, 108 85 Z" stroke="#3A3A3A" stroke-width="0.7" fill="rgba(255, 255, 255, 0.85)" stroke-linecap="round" stroke-linejoin="round" opacity="0.95"/>
|
| 450 |
-
<path d="M 90 85 Q 88 125, 86 165 Q 85 185, 86 220" stroke="#3A3A3A" stroke-width="1.2" fill="none" stroke-linecap="round" opacity="0.9"/>
|
| 451 |
-
<path d="M 86 145 Q 65 148, 50 156 Q 45 159, 47 162 Q 54 164, 72 160 Q 83 156, 86 152" stroke="#3A3A3A" stroke-width="0.7" fill="rgba(255, 255, 255, 0.7)" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/>
|
| 452 |
-
</svg>
|
| 453 |
-
</div>
|
| 454 |
-
|
| 455 |
-
<div class="login-container">
|
| 456 |
-
<div class="login-content">
|
| 457 |
-
<!-- 品牌標題 -->
|
| 458 |
-
<div class="brand-section">
|
| 459 |
-
<h1 class="brand-title">Bloom Ware</h1>
|
| 460 |
-
<p class="brand-subtitle">MADE BY 槓上開發</p>
|
| 461 |
-
</div>
|
| 462 |
-
|
| 463 |
-
<!-- 鬱金香插圖 -->
|
| 464 |
-
<div class="tulip-illustration">
|
| 465 |
-
<svg viewBox="0 0 180 240" fill="none" xmlns="http://www.w3.org/2000/svg" width="160" height="200">
|
| 466 |
-
<!-- 主要花朵 - 優雅精緻的花瓣 -->
|
| 467 |
-
<!-- 左側花瓣 -->
|
| 468 |
-
<path d="M 70 85 Q 52 58, 58 28 Q 62 15, 68 18 Q 75 32, 80 55 Q 83 70, 82 85 Z"
|
| 469 |
-
stroke="#3A3A3A" stroke-width="0.7" fill="rgba(255, 255, 255, 0.85)"
|
| 470 |
-
stroke-linecap="round" stroke-linejoin="round" opacity="0.95"/>
|
| 471 |
-
|
| 472 |
-
<!-- 中央花瓣 -->
|
| 473 |
-
<path d="M 82 85 Q 85 45, 88 20 Q 89 8, 92 12 Q 95 35, 93 65 Q 92 78, 93 85 Z"
|
| 474 |
-
stroke="#3A3A3A" stroke-width="0.7" fill="rgba(255, 255, 255, 0.9)"
|
| 475 |
-
stroke-linecap="round" stroke-linejoin="round" opacity="0.95"/>
|
| 476 |
-
|
| 477 |
-
<!-- 右側花瓣 -->
|
| 478 |
-
<path d="M 93 85 Q 90 55, 92 35 Q 96 20, 102 25 Q 110 42, 115 60 Q 118 75, 108 85 Z"
|
| 479 |
-
stroke="#3A3A3A" stroke-width="0.7" fill="rgba(255, 255, 255, 0.85)"
|
| 480 |
-
stroke-linecap="round" stroke-linejoin="round" opacity="0.95"/>
|
| 481 |
-
|
| 482 |
-
<!-- 花瓣細節線條 -->
|
| 483 |
-
<path d="M 72 75 Q 74 52, 76 32" stroke="#3A3A3A" stroke-width="0.4" fill="none" opacity="0.25" stroke-linecap="round"/>
|
| 484 |
-
<path d="M 88 78 Q 89 53, 90 28" stroke="#3A3A3A" stroke-width="0.4" fill="none" opacity="0.25" stroke-linecap="round"/>
|
| 485 |
-
<path d="M 104 75 Q 102 52, 100 32" stroke="#3A3A3A" stroke-width="0.4" fill="none" opacity="0.25" stroke-linecap="round"/>
|
| 486 |
-
<path d="M 68 70 Q 70 55, 72 40" stroke="#3A3A3A" stroke-width="0.3" fill="none" opacity="0.2" stroke-linecap="round"/>
|
| 487 |
-
<path d="M 108 70 Q 106 55, 104 40" stroke="#3A3A3A" stroke-width="0.3" fill="none" opacity="0.2" stroke-linecap="round"/>
|
| 488 |
-
|
| 489 |
-
<!-- 莖幹 -->
|
| 490 |
-
<path d="M 90 85 Q 88 125, 86 165 Q 85 185, 86 220"
|
| 491 |
-
stroke="#3A3A3A" stroke-width="1.2" fill="none" stroke-linecap="round" opacity="0.9"/>
|
| 492 |
-
|
| 493 |
-
<!-- 左側葉子 -->
|
| 494 |
-
<path d="M 86 145 Q 65 148, 50 156 Q 45 159, 47 162 Q 54 164, 72 160 Q 83 156, 86 152"
|
| 495 |
-
stroke="#3A3A3A" stroke-width="0.7" fill="rgba(255, 255, 255, 0.7)"
|
| 496 |
-
stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/>
|
| 497 |
-
<path d="M 86 148 Q 72 152, 58 158" stroke="#3A3A3A" stroke-width="0.4" fill="none" opacity="0.3" stroke-linecap="round"/>
|
| 498 |
-
|
| 499 |
-
<!-- 右側葉子 -->
|
| 500 |
-
<path d="M 86 185 Q 107 188, 122 196 Q 127 199, 125 202 Q 118 204, 100 200 Q 89 196, 86 192"
|
| 501 |
-
stroke="#3A3A3A" stroke-width="0.7" fill="rgba(255, 255, 255, 0.7)"
|
| 502 |
-
stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/>
|
| 503 |
-
<path d="M 86 188 Q 100 192, 114 198" stroke="#3A3A3A" stroke-width="0.4" fill="none" opacity="0.3" stroke-linecap="round"/>
|
| 504 |
-
</svg>
|
| 505 |
-
</div>
|
| 506 |
-
|
| 507 |
-
<!-- 登入按鈕 -->
|
| 508 |
-
<div class="login-buttons">
|
| 509 |
-
<!-- Google 登入 -->
|
| 510 |
-
<button class="login-btn" id="googleLoginBtn">
|
| 511 |
-
<svg class="btn-icon" viewBox="0 0 24 24">
|
| 512 |
-
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
| 513 |
-
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
| 514 |
-
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
| 515 |
-
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
| 516 |
-
</svg>
|
| 517 |
-
<span>Continue with Google</span>
|
| 518 |
-
</button>
|
| 519 |
-
|
| 520 |
-
<!-- 語音登入 -->
|
| 521 |
-
<button class="login-btn" id="voiceLoginBtn">
|
| 522 |
-
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 523 |
-
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
|
| 524 |
-
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
| 525 |
-
<line x1="12" y1="19" x2="12" y2="23"></line>
|
| 526 |
-
<line x1="8" y1="23" x2="16" y2="23"></line>
|
| 527 |
-
</svg>
|
| 528 |
-
<span id="voiceLoginBtnText">Voice Login</span>
|
| 529 |
-
</button>
|
| 530 |
-
</div>
|
| 531 |
-
|
| 532 |
-
<!-- 語音登入狀態提示 -->
|
| 533 |
-
<div class="voice-status" id="voiceLoginStatus"></div>
|
| 534 |
-
|
| 535 |
-
<!-- iOS 權限狀態提示 -->
|
| 536 |
-
<div class="voice-status" id="iosPermissionStatus"></div>
|
| 537 |
-
|
| 538 |
-
<!-- 免責聲明 -->
|
| 539 |
-
<p class="disclaimer">
|
| 540 |
-
By continuing, you agree to our Terms of Service and Privacy Policy
|
| 541 |
-
</p>
|
| 542 |
-
</div>
|
| 543 |
-
</div>
|
| 544 |
-
|
| 545 |
-
<script src="js/login.js"></script>
|
| 546 |
-
</body>
|
| 547 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/frontend/login/404.html
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html><!--JHSefKGBAlaH_P1I6_EFb--><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/login/_next/static/chunks/1f600c83a5ecf168.css" data-precedence="next"/><link rel="stylesheet" href="/login/_next/static/chunks/e696efa1270501ac.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/login/_next/static/chunks/752a4bd6387d6ef7.js"/><script src="/login/_next/static/chunks/66925481c7f9f43e.js" async=""></script><script src="/login/_next/static/chunks/c518a733bdcf3dee.js" async=""></script><script src="/login/_next/static/chunks/turbopack-57ae785150f7b6ae.js" async=""></script><script src="/login/_next/static/chunks/42879de7b8087bc9.js" async=""></script><meta name="robots" content="noindex"/><meta name="next-size-adjust" content=""/><title>404: This page could not be found.</title><title>Bloom Ware - 槓上開發</title><meta name="description" content="Elegant login experience for Bloom Ware"/><meta name="generator" content="v0.app"/><link rel="icon" href="/login/icon.svg?icon.db662264.svg" sizes="any" type="image/svg+xml"/><script src="/login/_next/static/chunks/a6dad97d9634a72d.js" noModule=""></script></head><body class="font-sans antialiased"><div hidden=""><!--$--><!--/$--></div><div style="font-family:system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div><style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2></div></div></div><!--$--><!--/$--><script src="/login/_next/static/chunks/752a4bd6387d6ef7.js" id="_R_" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0])</script><script>self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[39756,[\"/login/_next/static/chunks/42879de7b8087bc9.js\"],\"default\"]\n3:I[37457,[\"/login/_next/static/chunks/42879de7b8087bc9.js\"],\"default\"]\n4:I[97367,[\"/login/_next/static/chunks/42879de7b8087bc9.js\"],\"OutletBoundary\"]\n5:\"$Sreact.suspense\"\n7:I[97367,[\"/login/_next/static/chunks/42879de7b8087bc9.js\"],\"ViewportBoundary\"]\n9:I[97367,[\"/login/_next/static/chunks/42879de7b8087bc9.js\"],\"MetadataBoundary\"]\nb:I[68027,[\"/login/_next/static/chunks/42879de7b8087bc9.js\"],\"default\"]\n:HL[\"/login/_next/static/chunks/1f600c83a5ecf168.css\",\"style\"]\n:HL[\"/login/_next/static/chunks/e696efa1270501ac.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"0:{\"P\":null,\"b\":\"JHSefKGBAlaH-P1I6_EFb\",\"c\":[\"\",\"_not-found\",\"\"],\"q\":\"\",\"i\":false,\"f\":[[[\"\",{\"children\":[\"/_not-found\",{\"children\":[\"__PAGE__\",{}]}]},\"$undefined\",\"$undefined\",true],[[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/login/_next/static/chunks/1f600c83a5ecf168.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}],[\"$\",\"link\",\"1\",{\"rel\":\"stylesheet\",\"href\":\"/login/_next/static/chunks/e696efa1270501ac.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"className\":\"font-sans antialiased\",\"children\":[\"$\",\"$L2\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L3\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],[]],\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]}]}]]}],{\"children\":[[\"$\",\"$1\",\"c\",{\"children\":[null,[\"$\",\"$L2\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L3\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]]}],{\"children\":[[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":\"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:style\",\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":\"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:1:props:style\",\"children\":404}],[\"$\",\"div\",null,{\"style\":\"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:style\",\"children\":[\"$\",\"h2\",null,{\"style\":\"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:children:props:style\",\"children\":\"This page could not be found.\"}]}]]}]}]],null,[\"$\",\"$L4\",null,{\"children\":[\"$\",\"$5\",null,{\"name\":\"Next.MetadataOutlet\",\"children\":\"$@6\"}]}]]}],{},null,false,false]},null,false,false]},null,false,false],[\"$\",\"$1\",\"h\",{\"children\":[[\"$\",\"meta\",null,{\"name\":\"robots\",\"content\":\"noindex\"}],[\"$\",\"$L7\",null,{\"children\":\"$@8\"}],[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$L9\",null,{\"children\":[\"$\",\"$5\",null,{\"name\":\"Next.Metadata\",\"children\":\"$@a\"}]}]}],[\"$\",\"meta\",null,{\"name\":\"next-size-adjust\",\"content\":\"\"}]]}],false]],\"m\":\"$undefined\",\"G\":[\"$b\",\"$undefined\"],\"s\":false,\"S\":true}\n"])</script><script>self.__next_f.push([1,"8:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"1\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]\n"])</script><script>self.__next_f.push([1,"c:I[27201,[\"/login/_next/static/chunks/42879de7b8087bc9.js\"],\"IconMark\"]\na:[[\"$\",\"title\",\"0\",{\"children\":\"Bloom Ware - 槓上開發\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"Elegant login experience for Bloom Ware\"}],[\"$\",\"meta\",\"2\",{\"name\":\"generator\",\"content\":\"v0.app\"}],[\"$\",\"link\",\"3\",{\"rel\":\"icon\",\"href\":\"/login/icon.svg?icon.db662264.svg\",\"sizes\":\"any\",\"type\":\"image/svg+xml\"}],[\"$\",\"$Lc\",\"4\",{}]]\n6:null\n"])</script></body></html>
|
static/frontend/login/404/index.html
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html><!--JHSefKGBAlaH_P1I6_EFb--><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/login/_next/static/chunks/1f600c83a5ecf168.css" data-precedence="next"/><link rel="stylesheet" href="/login/_next/static/chunks/e696efa1270501ac.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/login/_next/static/chunks/752a4bd6387d6ef7.js"/><script src="/login/_next/static/chunks/66925481c7f9f43e.js" async=""></script><script src="/login/_next/static/chunks/c518a733bdcf3dee.js" async=""></script><script src="/login/_next/static/chunks/turbopack-57ae785150f7b6ae.js" async=""></script><script src="/login/_next/static/chunks/42879de7b8087bc9.js" async=""></script><meta name="robots" content="noindex"/><meta name="next-size-adjust" content=""/><title>404: This page could not be found.</title><title>Bloom Ware - 槓上開發</title><meta name="description" content="Elegant login experience for Bloom Ware"/><meta name="generator" content="v0.app"/><link rel="icon" href="/login/icon.svg?icon.db662264.svg" sizes="any" type="image/svg+xml"/><script src="/login/_next/static/chunks/a6dad97d9634a72d.js" noModule=""></script></head><body class="font-sans antialiased"><div hidden=""><!--$--><!--/$--></div><div style="font-family:system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div><style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2></div></div></div><!--$--><!--/$--><script src="/login/_next/static/chunks/752a4bd6387d6ef7.js" id="_R_" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0])</script><script>self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[39756,[\"/login/_next/static/chunks/42879de7b8087bc9.js\"],\"default\"]\n3:I[37457,[\"/login/_next/static/chunks/42879de7b8087bc9.js\"],\"default\"]\n4:I[97367,[\"/login/_next/static/chunks/42879de7b8087bc9.js\"],\"OutletBoundary\"]\n5:\"$Sreact.suspense\"\n7:I[97367,[\"/login/_next/static/chunks/42879de7b8087bc9.js\"],\"ViewportBoundary\"]\n9:I[97367,[\"/login/_next/static/chunks/42879de7b8087bc9.js\"],\"MetadataBoundary\"]\nb:I[68027,[\"/login/_next/static/chunks/42879de7b8087bc9.js\"],\"default\"]\n:HL[\"/login/_next/static/chunks/1f600c83a5ecf168.css\",\"style\"]\n:HL[\"/login/_next/static/chunks/e696efa1270501ac.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"0:{\"P\":null,\"b\":\"JHSefKGBAlaH-P1I6_EFb\",\"c\":[\"\",\"_not-found\",\"\"],\"q\":\"\",\"i\":false,\"f\":[[[\"\",{\"children\":[\"/_not-found\",{\"children\":[\"__PAGE__\",{}]}]},\"$undefined\",\"$undefined\",true],[[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/login/_next/static/chunks/1f600c83a5ecf168.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}],[\"$\",\"link\",\"1\",{\"rel\":\"stylesheet\",\"href\":\"/login/_next/static/chunks/e696efa1270501ac.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"className\":\"font-sans antialiased\",\"children\":[\"$\",\"$L2\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L3\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],[]],\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]}]}]]}],{\"children\":[[\"$\",\"$1\",\"c\",{\"children\":[null,[\"$\",\"$L2\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L3\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]]}],{\"children\":[[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":\"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:style\",\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":\"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:1:props:style\",\"children\":404}],[\"$\",\"div\",null,{\"style\":\"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:style\",\"children\":[\"$\",\"h2\",null,{\"style\":\"$0:f:0:1:0:props:children:1:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:children:props:style\",\"children\":\"This page could not be found.\"}]}]]}]}]],null,[\"$\",\"$L4\",null,{\"children\":[\"$\",\"$5\",null,{\"name\":\"Next.MetadataOutlet\",\"children\":\"$@6\"}]}]]}],{},null,false,false]},null,false,false]},null,false,false],[\"$\",\"$1\",\"h\",{\"children\":[[\"$\",\"meta\",null,{\"name\":\"robots\",\"content\":\"noindex\"}],[\"$\",\"$L7\",null,{\"children\":\"$@8\"}],[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$L9\",null,{\"children\":[\"$\",\"$5\",null,{\"name\":\"Next.Metadata\",\"children\":\"$@a\"}]}]}],[\"$\",\"meta\",null,{\"name\":\"next-size-adjust\",\"content\":\"\"}]]}],false]],\"m\":\"$undefined\",\"G\":[\"$b\",\"$undefined\"],\"s\":false,\"S\":true}\n"])</script><script>self.__next_f.push([1,"8:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"1\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]\n"])</script><script>self.__next_f.push([1,"c:I[27201,[\"/login/_next/static/chunks/42879de7b8087bc9.js\"],\"IconMark\"]\na:[[\"$\",\"title\",\"0\",{\"children\":\"Bloom Ware - 槓上開發\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"Elegant login experience for Bloom Ware\"}],[\"$\",\"meta\",\"2\",{\"name\":\"generator\",\"content\":\"v0.app\"}],[\"$\",\"link\",\"3\",{\"rel\":\"icon\",\"href\":\"/login/icon.svg?icon.db662264.svg\",\"sizes\":\"any\",\"type\":\"image/svg+xml\"}],[\"$\",\"$Lc\",\"4\",{}]]\n6:null\n"])</script></body></html>
|
static/frontend/login/__next.__PAGE__.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
1:"$Sreact.fragment"
|
| 2 |
+
d:I[17994,["/login/_next/static/chunks/9b0c5fad00d39a77.js"],"LoginForm"]
|
| 3 |
+
e:I[97367,["/login/_next/static/chunks/42879de7b8087bc9.js"],"OutletBoundary"]
|
| 4 |
+
f:"$Sreact.suspense"
|
| 5 |
+
0:{"buildId":"JHSefKGBAlaH-P1I6_EFb","rsc":["$","$1","c",{"children":[["$","main",null,{"className":"min-h-screen bg-[#FFF8E7] flex items-center justify-center p-4 sm:p-6 lg:p-8 relative overflow-hidden","children":[["$","div",null,{"className":"hidden md:block absolute top-8 left-8 opacity-15","children":["$","svg",null,{"viewBox":"0 0 180 240","fill":"none","xmlns":"http://www.w3.org/2000/svg","className":"drop-shadow-sm w-16 h-20 sm:w-20 sm:h-24","children":[["$","path",null,{"d":"M 70 85 Q 52 58, 58 28 Q 62 15, 68 18 Q 75 32, 80 55 Q 83 70, 82 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.85)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 82 85 Q 85 45, 88 20 Q 89 8, 92 12 Q 95 35, 93 65 Q 92 78, 93 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.9)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 93 85 Q 90 55, 92 35 Q 96 20, 102 25 Q 110 42, 115 60 Q 118 75, 108 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.85)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 72 75 Q 74 52, 76 32","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 88 78 Q 89 53, 90 28","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 104 75 Q 102 52, 100 32","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 68 70 Q 70 55, 72 40","stroke":"#3A3A3A","strokeWidth":"0.3","fill":"none","opacity":"0.2","strokeLinecap":"round"}],["$","path",null,{"d":"M 108 70 Q 106 55, 104 40","stroke":"#3A3A3A","strokeWidth":"0.3","fill":"none","opacity":"0.2","strokeLinecap":"round"}],["$","path",null,{"d":"M 90 85 Q 88 125, 86 165 Q 85 185, 86 220","stroke":"#3A3A3A","strokeWidth":"1.2","fill":"none","strokeLinecap":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 145 Q 65 148, 50 156 Q 45 159, 47 162 Q 54 164, 72 160 Q 83 156, 86 152","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.7)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 148 Q 72 152, 58 158","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.3","strokeLinecap":"round"}],["$","path",null,{"d":"M 86 185 Q 107 188, 122 196 Q 127 199, 125 202 Q 118 204, 100 200 Q 89 196, 86 192","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.7)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 188 Q 100 192, 114 198","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.3","strokeLinecap":"round"}]]}]}],["$","div",null,{"className":"hidden md:block absolute bottom-8 right-8 opacity-15 rotate-180","children":["$","svg",null,{"viewBox":"0 0 180 240","fill":"none","xmlns":"http://www.w3.org/2000/svg","className":"drop-shadow-sm w-16 h-20 sm:w-20 sm:h-24","children":[["$","path",null,{"d":"M 70 85 Q 52 58, 58 28 Q 62 15, 68 18 Q 75 32, 80 55 Q 83 70, 82 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.85)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 82 85 Q 85 45, 88 20 Q 89 8, 92 12 Q 95 35, 93 65 Q 92 78, 93 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.9)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 93 85 Q 90 55, 92 35 Q 96 20, 102 25 Q 110 42, 115 60 Q 118 75, 108 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.85)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 72 75 Q 74 52, 76 32","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 88 78 Q 89 53, 90 28","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 104 75 Q 102 52, 100 32","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 68 70 Q 70 55, 72 40","stroke":"#3A3A3A","strokeWidth":"0.3","fill":"none","opacity":"0.2","strokeLinecap":"round"}],"$L2","$L3","$L4","$L5","$L6","$L7"]}]}],"$L8","$L9","$La"]}],["$Lb"],"$Lc"]}],"loading":null,"isPartial":false}
|
| 6 |
+
2:["$","path",null,{"d":"M 108 70 Q 106 55, 104 40","stroke":"#3A3A3A","strokeWidth":"0.3","fill":"none","opacity":"0.2","strokeLinecap":"round"}]
|
| 7 |
+
3:["$","path",null,{"d":"M 90 85 Q 88 125, 86 165 Q 85 185, 86 220","stroke":"#3A3A3A","strokeWidth":"1.2","fill":"none","strokeLinecap":"round","opacity":"0.9"}]
|
| 8 |
+
4:["$","path",null,{"d":"M 86 145 Q 65 148, 50 156 Q 45 159, 47 162 Q 54 164, 72 160 Q 83 156, 86 152","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.7)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.9"}]
|
| 9 |
+
5:["$","path",null,{"d":"M 86 148 Q 72 152, 58 158","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.3","strokeLinecap":"round"}]
|
| 10 |
+
6:["$","path",null,{"d":"M 86 185 Q 107 188, 122 196 Q 127 199, 125 202 Q 118 204, 100 200 Q 89 196, 86 192","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.7)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.9"}]
|
| 11 |
+
7:["$","path",null,{"d":"M 86 188 Q 100 192, 114 198","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.3","strokeLinecap":"round"}]
|
| 12 |
+
8:["$","div",null,{"className":"hidden lg:block absolute top-1/4 right-12 opacity-10","children":["$","svg",null,{"viewBox":"0 0 180 240","fill":"none","xmlns":"http://www.w3.org/2000/svg","className":"drop-shadow-sm w-16 h-20 sm:w-20 sm:h-24","children":[["$","path",null,{"d":"M 70 85 Q 52 58, 58 28 Q 62 15, 68 18 Q 75 32, 80 55 Q 83 70, 82 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.85)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 82 85 Q 85 45, 88 20 Q 89 8, 92 12 Q 95 35, 93 65 Q 92 78, 93 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.9)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 93 85 Q 90 55, 92 35 Q 96 20, 102 25 Q 110 42, 115 60 Q 118 75, 108 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.85)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 72 75 Q 74 52, 76 32","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 88 78 Q 89 53, 90 28","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 104 75 Q 102 52, 100 32","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 68 70 Q 70 55, 72 40","stroke":"#3A3A3A","strokeWidth":"0.3","fill":"none","opacity":"0.2","strokeLinecap":"round"}],["$","path",null,{"d":"M 108 70 Q 106 55, 104 40","stroke":"#3A3A3A","strokeWidth":"0.3","fill":"none","opacity":"0.2","strokeLinecap":"round"}],["$","path",null,{"d":"M 90 85 Q 88 125, 86 165 Q 85 185, 86 220","stroke":"#3A3A3A","strokeWidth":"1.2","fill":"none","strokeLinecap":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 145 Q 65 148, 50 156 Q 45 159, 47 162 Q 54 164, 72 160 Q 83 156, 86 152","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.7)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 148 Q 72 152, 58 158","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.3","strokeLinecap":"round"}],["$","path",null,{"d":"M 86 185 Q 107 188, 122 196 Q 127 199, 125 202 Q 118 204, 100 200 Q 89 196, 86 192","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.7)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 188 Q 100 192, 114 198","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.3","strokeLinecap":"round"}]]}]}]
|
| 13 |
+
9:["$","div",null,{"className":"hidden lg:block absolute bottom-1/4 left-12 opacity-10 rotate-12","children":["$","svg",null,{"viewBox":"0 0 180 240","fill":"none","xmlns":"http://www.w3.org/2000/svg","className":"drop-shadow-sm w-16 h-20 sm:w-20 sm:h-24","children":[["$","path",null,{"d":"M 70 85 Q 52 58, 58 28 Q 62 15, 68 18 Q 75 32, 80 55 Q 83 70, 82 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.85)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 82 85 Q 85 45, 88 20 Q 89 8, 92 12 Q 95 35, 93 65 Q 92 78, 93 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.9)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 93 85 Q 90 55, 92 35 Q 96 20, 102 25 Q 110 42, 115 60 Q 118 75, 108 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.85)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 72 75 Q 74 52, 76 32","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 88 78 Q 89 53, 90 28","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 104 75 Q 102 52, 100 32","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 68 70 Q 70 55, 72 40","stroke":"#3A3A3A","strokeWidth":"0.3","fill":"none","opacity":"0.2","strokeLinecap":"round"}],["$","path",null,{"d":"M 108 70 Q 106 55, 104 40","stroke":"#3A3A3A","strokeWidth":"0.3","fill":"none","opacity":"0.2","strokeLinecap":"round"}],["$","path",null,{"d":"M 90 85 Q 88 125, 86 165 Q 85 185, 86 220","stroke":"#3A3A3A","strokeWidth":"1.2","fill":"none","strokeLinecap":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 145 Q 65 148, 50 156 Q 45 159, 47 162 Q 54 164, 72 160 Q 83 156, 86 152","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.7)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 148 Q 72 152, 58 158","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.3","strokeLinecap":"round"}],["$","path",null,{"d":"M 86 185 Q 107 188, 122 196 Q 127 199, 125 202 Q 118 204, 100 200 Q 89 196, 86 192","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.7)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 188 Q 100 192, 114 198","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.3","strokeLinecap":"round"}]]}]}]
|
| 14 |
+
a:["$","div",null,{"className":"w-full max-w-sm sm:max-w-md relative z-10","children":["$","$Ld",null,{}]}]
|
| 15 |
+
b:["$","script","script-0",{"src":"/login/_next/static/chunks/9b0c5fad00d39a77.js","async":true}]
|
| 16 |
+
c:["$","$Le",null,{"children":["$","$f",null,{"name":"Next.MetadataOutlet","children":"$@10"}]}]
|
| 17 |
+
10:null
|
static/frontend/login/__next._full.txt
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
1:"$Sreact.fragment"
|
| 2 |
+
2:I[39756,["/login/_next/static/chunks/42879de7b8087bc9.js"],"default"]
|
| 3 |
+
3:I[37457,["/login/_next/static/chunks/42879de7b8087bc9.js"],"default"]
|
| 4 |
+
c:I[68027,[],"default"]
|
| 5 |
+
:HL["/login/_next/static/chunks/1f600c83a5ecf168.css","style"]
|
| 6 |
+
:HL["/login/_next/static/chunks/e696efa1270501ac.css","style"]
|
| 7 |
+
:HL["/login/_next/static/media/2a65768255d6b625-s.p.d19752fb.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
| 8 |
+
:HL["/login/_next/static/media/797e433ab948586e-s.p.dbea232f.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
| 9 |
+
:HL["/login/_next/static/media/caa3a2e1cccd8315-s.p.853070df.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
| 10 |
+
0:{"P":null,"b":"JHSefKGBAlaH-P1I6_EFb","c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/login/_next/static/chunks/1f600c83a5ecf168.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/login/_next/static/chunks/e696efa1270501ac.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"font-sans antialiased","children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":[["$","$1","c",{"children":[["$","main",null,{"className":"min-h-screen bg-[#FFF8E7] flex items-center justify-center p-4 sm:p-6 lg:p-8 relative overflow-hidden","children":[["$","div",null,{"className":"hidden md:block absolute top-8 left-8 opacity-15","children":["$","svg",null,{"viewBox":"0 0 180 240","fill":"none","xmlns":"http://www.w3.org/2000/svg","className":"drop-shadow-sm w-16 h-20 sm:w-20 sm:h-24","children":[["$","path",null,{"d":"M 70 85 Q 52 58, 58 28 Q 62 15, 68 18 Q 75 32, 80 55 Q 83 70, 82 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.85)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 82 85 Q 85 45, 88 20 Q 89 8, 92 12 Q 95 35, 93 65 Q 92 78, 93 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.9)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 93 85 Q 90 55, 92 35 Q 96 20, 102 25 Q 110 42, 115 60 Q 118 75, 108 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.85)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 72 75 Q 74 52, 76 32","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 88 78 Q 89 53, 90 28","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 104 75 Q 102 52, 100 32","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 68 70 Q 70 55, 72 40","stroke":"#3A3A3A","strokeWidth":"0.3","fill":"none","opacity":"0.2","strokeLinecap":"round"}],["$","path",null,{"d":"M 108 70 Q 106 55, 104 40","stroke":"#3A3A3A","strokeWidth":"0.3","fill":"none","opacity":"0.2","strokeLinecap":"round"}],["$","path",null,{"d":"M 90 85 Q 88 125, 86 165 Q 85 185, 86 220","stroke":"#3A3A3A","strokeWidth":"1.2","fill":"none","strokeLinecap":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 145 Q 65 148, 50 156 Q 45 159, 47 162 Q 54 164, 72 160 Q 83 156, 86 152","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.7)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 148 Q 72 152, 58 158","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.3","strokeLinecap":"round"}],["$","path",null,{"d":"M 86 185 Q 107 188, 122 196 Q 127 199, 125 202 Q 118 204, 100 200 Q 89 196, 86 192","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.7)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.9"}],"$L4"]}]}],"$L5","$L6","$L7","$L8"]}],["$L9"],"$La"]}],{},null,false,false]},null,false,false],"$Lb",false]],"m":"$undefined","G":["$c",[]],"s":false,"S":true}
|
| 11 |
+
d:I[17994,["/login/_next/static/chunks/9b0c5fad00d39a77.js"],"LoginForm"]
|
| 12 |
+
e:I[97367,["/login/_next/static/chunks/42879de7b8087bc9.js"],"OutletBoundary"]
|
| 13 |
+
f:"$Sreact.suspense"
|
| 14 |
+
11:I[97367,["/login/_next/static/chunks/42879de7b8087bc9.js"],"ViewportBoundary"]
|
| 15 |
+
13:I[97367,["/login/_next/static/chunks/42879de7b8087bc9.js"],"MetadataBoundary"]
|
| 16 |
+
4:["$","path",null,{"d":"M 86 188 Q 100 192, 114 198","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.3","strokeLinecap":"round"}]
|
| 17 |
+
5:["$","div",null,{"className":"hidden md:block absolute bottom-8 right-8 opacity-15 rotate-180","children":["$","svg",null,{"viewBox":"0 0 180 240","fill":"none","xmlns":"http://www.w3.org/2000/svg","className":"drop-shadow-sm w-16 h-20 sm:w-20 sm:h-24","children":[["$","path",null,{"d":"M 70 85 Q 52 58, 58 28 Q 62 15, 68 18 Q 75 32, 80 55 Q 83 70, 82 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.85)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 82 85 Q 85 45, 88 20 Q 89 8, 92 12 Q 95 35, 93 65 Q 92 78, 93 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.9)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 93 85 Q 90 55, 92 35 Q 96 20, 102 25 Q 110 42, 115 60 Q 118 75, 108 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.85)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 72 75 Q 74 52, 76 32","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 88 78 Q 89 53, 90 28","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 104 75 Q 102 52, 100 32","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 68 70 Q 70 55, 72 40","stroke":"#3A3A3A","strokeWidth":"0.3","fill":"none","opacity":"0.2","strokeLinecap":"round"}],["$","path",null,{"d":"M 108 70 Q 106 55, 104 40","stroke":"#3A3A3A","strokeWidth":"0.3","fill":"none","opacity":"0.2","strokeLinecap":"round"}],["$","path",null,{"d":"M 90 85 Q 88 125, 86 165 Q 85 185, 86 220","stroke":"#3A3A3A","strokeWidth":"1.2","fill":"none","strokeLinecap":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 145 Q 65 148, 50 156 Q 45 159, 47 162 Q 54 164, 72 160 Q 83 156, 86 152","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.7)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 148 Q 72 152, 58 158","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.3","strokeLinecap":"round"}],["$","path",null,{"d":"M 86 185 Q 107 188, 122 196 Q 127 199, 125 202 Q 118 204, 100 200 Q 89 196, 86 192","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.7)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 188 Q 100 192, 114 198","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.3","strokeLinecap":"round"}]]}]}]
|
| 18 |
+
6:["$","div",null,{"className":"hidden lg:block absolute top-1/4 right-12 opacity-10","children":["$","svg",null,{"viewBox":"0 0 180 240","fill":"none","xmlns":"http://www.w3.org/2000/svg","className":"drop-shadow-sm w-16 h-20 sm:w-20 sm:h-24","children":[["$","path",null,{"d":"M 70 85 Q 52 58, 58 28 Q 62 15, 68 18 Q 75 32, 80 55 Q 83 70, 82 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.85)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 82 85 Q 85 45, 88 20 Q 89 8, 92 12 Q 95 35, 93 65 Q 92 78, 93 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.9)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 93 85 Q 90 55, 92 35 Q 96 20, 102 25 Q 110 42, 115 60 Q 118 75, 108 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.85)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 72 75 Q 74 52, 76 32","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 88 78 Q 89 53, 90 28","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 104 75 Q 102 52, 100 32","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 68 70 Q 70 55, 72 40","stroke":"#3A3A3A","strokeWidth":"0.3","fill":"none","opacity":"0.2","strokeLinecap":"round"}],["$","path",null,{"d":"M 108 70 Q 106 55, 104 40","stroke":"#3A3A3A","strokeWidth":"0.3","fill":"none","opacity":"0.2","strokeLinecap":"round"}],["$","path",null,{"d":"M 90 85 Q 88 125, 86 165 Q 85 185, 86 220","stroke":"#3A3A3A","strokeWidth":"1.2","fill":"none","strokeLinecap":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 145 Q 65 148, 50 156 Q 45 159, 47 162 Q 54 164, 72 160 Q 83 156, 86 152","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.7)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 148 Q 72 152, 58 158","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.3","strokeLinecap":"round"}],["$","path",null,{"d":"M 86 185 Q 107 188, 122 196 Q 127 199, 125 202 Q 118 204, 100 200 Q 89 196, 86 192","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.7)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 188 Q 100 192, 114 198","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.3","strokeLinecap":"round"}]]}]}]
|
| 19 |
+
7:["$","div",null,{"className":"hidden lg:block absolute bottom-1/4 left-12 opacity-10 rotate-12","children":["$","svg",null,{"viewBox":"0 0 180 240","fill":"none","xmlns":"http://www.w3.org/2000/svg","className":"drop-shadow-sm w-16 h-20 sm:w-20 sm:h-24","children":[["$","path",null,{"d":"M 70 85 Q 52 58, 58 28 Q 62 15, 68 18 Q 75 32, 80 55 Q 83 70, 82 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.85)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 82 85 Q 85 45, 88 20 Q 89 8, 92 12 Q 95 35, 93 65 Q 92 78, 93 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.9)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 93 85 Q 90 55, 92 35 Q 96 20, 102 25 Q 110 42, 115 60 Q 118 75, 108 85 Z","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.85)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.95"}],["$","path",null,{"d":"M 72 75 Q 74 52, 76 32","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 88 78 Q 89 53, 90 28","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 104 75 Q 102 52, 100 32","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.25","strokeLinecap":"round"}],["$","path",null,{"d":"M 68 70 Q 70 55, 72 40","stroke":"#3A3A3A","strokeWidth":"0.3","fill":"none","opacity":"0.2","strokeLinecap":"round"}],["$","path",null,{"d":"M 108 70 Q 106 55, 104 40","stroke":"#3A3A3A","strokeWidth":"0.3","fill":"none","opacity":"0.2","strokeLinecap":"round"}],["$","path",null,{"d":"M 90 85 Q 88 125, 86 165 Q 85 185, 86 220","stroke":"#3A3A3A","strokeWidth":"1.2","fill":"none","strokeLinecap":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 145 Q 65 148, 50 156 Q 45 159, 47 162 Q 54 164, 72 160 Q 83 156, 86 152","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.7)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 148 Q 72 152, 58 158","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.3","strokeLinecap":"round"}],["$","path",null,{"d":"M 86 185 Q 107 188, 122 196 Q 127 199, 125 202 Q 118 204, 100 200 Q 89 196, 86 192","stroke":"#3A3A3A","strokeWidth":"0.7","fill":"rgba(255, 255, 255, 0.7)","strokeLinecap":"round","strokeLinejoin":"round","opacity":"0.9"}],["$","path",null,{"d":"M 86 188 Q 100 192, 114 198","stroke":"#3A3A3A","strokeWidth":"0.4","fill":"none","opacity":"0.3","strokeLinecap":"round"}]]}]}]
|
| 20 |
+
8:["$","div",null,{"className":"w-full max-w-sm sm:max-w-md relative z-10","children":["$","$Ld",null,{}]}]
|
| 21 |
+
9:["$","script","script-0",{"src":"/login/_next/static/chunks/9b0c5fad00d39a77.js","async":true,"nonce":"$undefined"}]
|
| 22 |
+
a:["$","$Le",null,{"children":["$","$f",null,{"name":"Next.MetadataOutlet","children":"$@10"}]}]
|
| 23 |
+
b:["$","$1","h",{"children":[null,["$","$L11",null,{"children":"$@12"}],["$","div",null,{"hidden":true,"children":["$","$L13",null,{"children":["$","$f",null,{"name":"Next.Metadata","children":"$@14"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}]
|
| 24 |
+
12:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
| 25 |
+
15:I[27201,["/login/_next/static/chunks/42879de7b8087bc9.js"],"IconMark"]
|
| 26 |
+
14:[["$","title","0",{"children":"Bloom Ware - 槓上開發"}],["$","meta","1",{"name":"description","content":"Elegant login experience for Bloom Ware"}],["$","meta","2",{"name":"generator","content":"v0.app"}],["$","link","3",{"rel":"icon","href":"/login/icon.svg?icon.db662264.svg","sizes":"any","type":"image/svg+xml"}],["$","$L15","4",{}]]
|
| 27 |
+
10:null
|
static/frontend/login/__next._index.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
1:"$Sreact.fragment"
|
| 2 |
+
2:I[39756,["/login/_next/static/chunks/42879de7b8087bc9.js"],"default"]
|
| 3 |
+
3:I[37457,["/login/_next/static/chunks/42879de7b8087bc9.js"],"default"]
|
| 4 |
+
:HL["/login/_next/static/chunks/1f600c83a5ecf168.css","style"]
|
| 5 |
+
:HL["/login/_next/static/chunks/e696efa1270501ac.css","style"]
|
| 6 |
+
0:{"buildId":"JHSefKGBAlaH-P1I6_EFb","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/login/_next/static/chunks/1f600c83a5ecf168.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/login/_next/static/chunks/e696efa1270501ac.css","precedence":"next"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"font-sans antialiased","children":["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]]}],"loading":null,"isPartial":false}
|
static/frontend/login/__next._tree.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
1:"$Sreact.fragment"
|
| 2 |
+
2:I[97367,["/login/_next/static/chunks/42879de7b8087bc9.js"],"ViewportBoundary"]
|
| 3 |
+
4:I[97367,["/login/_next/static/chunks/42879de7b8087bc9.js"],"MetadataBoundary"]
|
| 4 |
+
5:"$Sreact.suspense"
|
| 5 |
+
7:I[27201,["/login/_next/static/chunks/42879de7b8087bc9.js"],"IconMark"]
|
| 6 |
+
:HL["/login/_next/static/chunks/1f600c83a5ecf168.css","style"]
|
| 7 |
+
:HL["/login/_next/static/chunks/e696efa1270501ac.css","style"]
|
| 8 |
+
:HL["/login/_next/static/media/2a65768255d6b625-s.p.d19752fb.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
| 9 |
+
:HL["/login/_next/static/media/797e433ab948586e-s.p.dbea232f.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
| 10 |
+
:HL["/login/_next/static/media/caa3a2e1cccd8315-s.p.853070df.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
| 11 |
+
0:{"buildId":"JHSefKGBAlaH-P1I6_EFb","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":true},"head":["$","$1","h",{"children":[null,["$","$L2",null,{"children":"$@3"}],["$","div",null,{"hidden":true,"children":["$","$L4",null,{"children":["$","$5",null,{"name":"Next.Metadata","children":"$@6"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"isHeadPartial":false,"staleTime":300}
|
| 12 |
+
3:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
| 13 |
+
6:[["$","title","0",{"children":"Bloom Ware - 槓上開發"}],["$","meta","1",{"name":"description","content":"Elegant login experience for Bloom Ware"}],["$","meta","2",{"name":"generator","content":"v0.app"}],["$","link","3",{"rel":"icon","href":"/login/icon.svg?icon.db662264.svg","sizes":"any","type":"image/svg+xml"}],["$","$L7","4",{}]]
|
static/frontend/login/_next/static/JHSefKGBAlaH-P1I6_EFb/_buildManifest.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
self.__BUILD_MANIFEST = {
|
| 2 |
+
"__rewrites": {
|
| 3 |
+
"afterFiles": [],
|
| 4 |
+
"beforeFiles": [],
|
| 5 |
+
"fallback": []
|
| 6 |
+
},
|
| 7 |
+
"sortedPages": [
|
| 8 |
+
"/_app",
|
| 9 |
+
"/_error"
|
| 10 |
+
]
|
| 11 |
+
};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
|
static/frontend/login/_next/static/JHSefKGBAlaH-P1I6_EFb/_clientMiddlewareManifest.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
static/frontend/login/_next/static/JHSefKGBAlaH-P1I6_EFb/_ssgManifest.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
self.__SSG_MANIFEST=new Set([]);self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
|
static/frontend/login/_next/static/chunks/1f600c83a5ecf168.css
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@font-face{font-family:Geist;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/8a480f0b521d4e75-s.8e0177b5.woff2)format("woff2");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Geist;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/7178b3e590c64307-s.b97b3418.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Geist;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/caa3a2e1cccd8315-s.p.853070df.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Geist Fallback;src:local(Arial);ascent-override:95.94%;descent-override:28.16%;line-gap-override:0.0%;size-adjust:104.76%}.geist_a7695b8e-module__Entzca__className{font-family:Geist,Geist Fallback;font-style:normal}
|
| 2 |
+
@font-face{font-family:Geist Mono;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/4fa387ec64143e14-s.c1fdd6c2.woff2)format("woff2");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Geist Mono;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/bbc41e54d2fcbd21-s.799d8ef8.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Geist Mono;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/797e433ab948586e-s.p.dbea232f.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Geist Mono Fallback;src:local(Arial);ascent-override:74.67%;descent-override:21.92%;line-gap-override:0.0%;size-adjust:134.59%}.geist_mono_354fc78-module__zrY5Sa__className{font-family:Geist Mono,Geist Mono Fallback;font-style:normal}
|
| 3 |
+
@font-face{font-family:Playfair Display;font-style:normal;font-weight:400 900;font-display:swap;src:url(../media/65c558afe41e89d6-s.e2c8389a.woff2)format("woff2");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Playfair Display;font-style:normal;font-weight:400 900;font-display:swap;src:url(../media/14e23f9b59180572-s.9c448f3c.woff2)format("woff2");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Playfair Display;font-style:normal;font-weight:400 900;font-display:swap;src:url(../media/b49b0d9b851e4899-s.4f3fa681.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Playfair Display;font-style:normal;font-weight:400 900;font-display:swap;src:url(../media/2a65768255d6b625-s.p.d19752fb.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Playfair Display Fallback;src:local(Times New Roman);ascent-override:97.25%;descent-override:22.56%;line-gap-override:0.0%;size-adjust:111.26%}.playfair_display_4f85f8b1-module__d60DSa__className{font-family:Playfair Display,Playfair Display Fallback;font-style:normal}
|
static/frontend/login/_next/static/chunks/42879de7b8087bc9.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,33525,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"warnOnce",{enumerable:!0,get:function(){return n}});let n=e=>{}},91915,(e,r,t)=>{"use strict";function n(e,r={}){if(r.onlyHashChange)return void e();let t=document.documentElement;if("smooth"!==t.dataset.scrollBehavior)return void e();let a=t.style.scrollBehavior;t.style.scrollBehavior="auto",r.dontForceLayout||t.getClientRects(),e(),t.style.scrollBehavior=a}Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"disableSmoothScrollDuringRouteTransition",{enumerable:!0,get:function(){return n}}),e.r(33525)},68017,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"HTTPAccessFallbackBoundary",{enumerable:!0,get:function(){return i}});let n=e.r(90809),a=e.r(43476),o=n._(e.r(71645)),c=e.r(90373),u=e.r(54394);e.r(33525);let l=e.r(8372);class s extends o.default.Component{constructor(e){super(e),this.state={triggeredStatus:void 0,previousPathname:e.pathname}}componentDidCatch(){}static getDerivedStateFromError(e){if((0,u.isHTTPAccessFallbackError)(e))return{triggeredStatus:(0,u.getAccessFallbackHTTPStatus)(e)};throw e}static getDerivedStateFromProps(e,r){return e.pathname!==r.previousPathname&&r.triggeredStatus?{triggeredStatus:void 0,previousPathname:e.pathname}:{triggeredStatus:r.triggeredStatus,previousPathname:e.pathname}}render(){let{notFound:e,forbidden:r,unauthorized:t,children:n}=this.props,{triggeredStatus:o}=this.state,c={[u.HTTPAccessErrorStatus.NOT_FOUND]:e,[u.HTTPAccessErrorStatus.FORBIDDEN]:r,[u.HTTPAccessErrorStatus.UNAUTHORIZED]:t};if(o){let l=o===u.HTTPAccessErrorStatus.NOT_FOUND&&e,s=o===u.HTTPAccessErrorStatus.FORBIDDEN&&r,i=o===u.HTTPAccessErrorStatus.UNAUTHORIZED&&t;return l||s||i?(0,a.jsxs)(a.Fragment,{children:[(0,a.jsx)("meta",{name:"robots",content:"noindex"}),!1,c[o]]}):n}return n}}function i({notFound:e,forbidden:r,unauthorized:t,children:n}){let u=(0,c.useUntrackedPathname)(),i=(0,o.useContext)(l.MissingSlotContext);return e||r||t?(0,a.jsx)(s,{pathname:u,notFound:e,forbidden:r,unauthorized:t,missingSlots:i,children:n}):(0,a.jsx)(a.Fragment,{children:n})}("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),r.exports=t.default)},91798,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"useRouterBFCache",{enumerable:!0,get:function(){return a}});let n=e.r(71645);function a(e,r){let[t,a]=(0,n.useState)(()=>({tree:e,stateKey:r,next:null}));if(t.tree===e)return t;let o={tree:e,stateKey:r,next:null},c=1,u=t,l=o;for(;null!==u&&c<1;){if(u.stateKey===r){l.next=u.next;break}{c++;let e={tree:u.tree,stateKey:u.stateKey,next:null};l.next=e,l=e}u=u.next}return a(o),o}("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),r.exports=t.default)},39756,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"default",{enumerable:!0,get:function(){return T}});let n=e.r(55682),a=e.r(90809),o=e.r(43476),c=e.r(88540),u=a._(e.r(71645)),l=n._(e.r(74080)),s=e.r(8372),i=e.r(87288),d=e.r(1244),f=e.r(72383),p=e.r(56019),h=e.r(91915),m=e.r(58442),g=e.r(68017),y=e.r(70725),b=e.r(84356),P=e.r(41538),_=e.r(91798);e.r(74180);let v=e.r(61994),O=e.r(33906),S=l.default.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,E=["bottom","height","left","right","top","width","x","y"];function R(e,r){let t=e.getBoundingClientRect();return t.top>=0&&t.top<=r}class j extends u.default.Component{componentDidMount(){this.handlePotentialScroll()}componentDidUpdate(){this.props.focusAndScrollRef.apply&&this.handlePotentialScroll()}render(){return this.props.children}constructor(...e){super(...e),this.handlePotentialScroll=()=>{let{focusAndScrollRef:e,segmentPath:r}=this.props;if(e.apply){if(0!==e.segmentPaths.length&&!e.segmentPaths.some(e=>r.every((r,t)=>(0,p.matchSegment)(r,e[t]))))return;let t=null,n=e.hashFragment;if(n&&(t="top"===n?document.body:document.getElementById(n)??document.getElementsByName(n)[0]),t||(t="undefined"==typeof window?null:(0,S.findDOMNode)(this)),!(t instanceof Element))return;for(;!(t instanceof HTMLElement)||function(e){if(["sticky","fixed"].includes(getComputedStyle(e).position))return!0;let r=e.getBoundingClientRect();return E.every(e=>0===r[e])}(t);){if(null===t.nextElementSibling)return;t=t.nextElementSibling}e.apply=!1,e.hashFragment=null,e.segmentPaths=[],(0,h.disableSmoothScrollDuringRouteTransition)(()=>{if(n)return void t.scrollIntoView();let e=document.documentElement,r=e.clientHeight;!R(t,r)&&(e.scrollTop=0,R(t,r)||t.scrollIntoView())},{dontForceLayout:!0,onlyHashChange:e.onlyHashChange}),e.onlyHashChange=!1,t.focus()}}}}function w({segmentPath:e,children:r}){let t=(0,u.useContext)(s.GlobalLayoutRouterContext);if(!t)throw Object.defineProperty(Error("invariant global layout router not mounted"),"__NEXT_ERROR_CODE",{value:"E473",enumerable:!1,configurable:!0});return(0,o.jsx)(j,{segmentPath:e,focusAndScrollRef:t.focusAndScrollRef,children:r})}function C({tree:e,segmentPath:r,debugNameContext:t,cacheNode:n,params:a,url:l,isActive:f}){let h=(0,u.useContext)(s.GlobalLayoutRouterContext);if((0,u.useContext)(v.NavigationPromisesContext),!h)throw Object.defineProperty(Error("invariant global layout router not mounted"),"__NEXT_ERROR_CODE",{value:"E473",enumerable:!1,configurable:!0});let{tree:m}=h,g=null!==n.prefetchRsc?n.prefetchRsc:n.rsc,y=(0,u.useDeferredValue)(n.rsc,g),_="object"==typeof y&&null!==y&&"function"==typeof y.then?(0,u.use)(y):y;if(!_){if(f){let e=n.lazyData;if(null===e){let t=function e(r,t){if(r){let[n,a]=r,o=2===r.length;if((0,p.matchSegment)(t[0],n)&&t[1].hasOwnProperty(a)){if(o){let r=e(void 0,t[1][a]);return[t[0],{...t[1],[a]:[r[0],r[1],r[2],"refetch"]}]}return[t[0],{...t[1],[a]:e(r.slice(2),t[1][a])}]}}return t}(["",...r],m),a=(0,b.hasInterceptionRouteInCurrentTree)(m),o=Date.now();n.lazyData=e=(0,i.fetchServerResponse)(new URL(l,location.origin),{flightRouterState:t,nextUrl:a?h.previousNextUrl||h.nextUrl:null}).then(e=>((0,u.startTransition)(()=>{(0,P.dispatchAppRouterAction)({type:c.ACTION_SERVER_PATCH,previousTree:m,serverResponse:e,navigatedAt:o})}),e)),(0,u.use)(e)}}(0,u.use)(d.unresolvedThenable)}return(0,o.jsx)(s.LayoutRouterContext.Provider,{value:{parentTree:e,parentCacheNode:n,parentSegmentPath:r,parentParams:a,debugNameContext:t,url:l,isActive:f},children:_})}function x({name:e,loading:r,children:t}){let n;if(n="object"==typeof r&&null!==r&&"function"==typeof r.then?(0,u.use)(r):r){let r=n[0],a=n[1],c=n[2];return(0,o.jsx)(u.Suspense,{name:e,fallback:(0,o.jsxs)(o.Fragment,{children:[a,c,r]}),children:t})}return(0,o.jsx)(o.Fragment,{children:t})}function T({parallelRouterKey:e,error:r,errorStyles:t,errorScripts:n,templateStyles:a,templateScripts:c,template:l,notFound:i,forbidden:d,unauthorized:p,segmentViewBoundaries:h}){let b=(0,u.useContext)(s.LayoutRouterContext);if(!b)throw Object.defineProperty(Error("invariant expected layout router to be mounted"),"__NEXT_ERROR_CODE",{value:"E56",enumerable:!1,configurable:!0});let{parentTree:P,parentCacheNode:v,parentSegmentPath:S,parentParams:E,url:R,isActive:j,debugNameContext:T}=b,A=v.parallelRoutes,M=A.get(e);M||(M=new Map,A.set(e,M));let F=P[0],D=null===S?[e]:S.concat([F,e]),k=P[1][e],N=k[0],I=(0,y.createRouterCacheKey)(N,!0),U=(0,_.useRouterBFCache)(k,I),H=[];do{let e=U.tree,u=U.stateKey,h=e[0],b=(0,y.createRouterCacheKey)(h),P=M.get(b);if(void 0===P){let e={lazyData:null,rsc:null,prefetchRsc:null,head:null,prefetchHead:null,parallelRoutes:new Map,loading:null,navigatedAt:-1};P=e,M.set(b,e)}let _=E;if(Array.isArray(h)){let e=h[0],r=h[1],t=h[2],n=(0,O.getParamValueFromCacheKey)(r,t);null!==n&&(_={...E,[e]:n})}let S=function(e){if("/"===e)return"/";if("string"==typeof e)if("(slot)"===e)return;else return e+"/";return e[1]+"/"}(h),A=S??T,F=void 0===S?void 0:T,k=v.loading,N=(0,o.jsxs)(s.TemplateContext.Provider,{value:(0,o.jsxs)(w,{segmentPath:D,children:[(0,o.jsx)(f.ErrorBoundary,{errorComponent:r,errorStyles:t,errorScripts:n,children:(0,o.jsx)(x,{name:F,loading:k,children:(0,o.jsx)(g.HTTPAccessFallbackBoundary,{notFound:i,forbidden:d,unauthorized:p,children:(0,o.jsxs)(m.RedirectBoundary,{children:[(0,o.jsx)(C,{url:R,tree:e,params:_,cacheNode:P,segmentPath:D,debugNameContext:A,isActive:j&&u===I}),null]})})})}),null]}),children:[a,c,l]},u);H.push(N),U=U.next}while(null!==U)return H}("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),r.exports=t.default)},37457,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"default",{enumerable:!0,get:function(){return u}});let n=e.r(90809),a=e.r(43476),o=n._(e.r(71645)),c=e.r(8372);function u(){let e=(0,o.useContext)(c.TemplateContext);return(0,a.jsx)(a.Fragment,{children:e})}("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),r.exports=t.default)},93504,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"createRenderSearchParamsFromClient",{enumerable:!0,get:function(){return a}});let n=new WeakMap;function a(e){let r=n.get(e);if(r)return r;let t=Promise.resolve(e);return n.set(e,t),t}("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),r.exports=t.default)},66996,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"createRenderSearchParamsFromClient",{enumerable:!0,get:function(){return n}});let n=e.r(93504).createRenderSearchParamsFromClient;("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),r.exports=t.default)},6831,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"createRenderParamsFromClient",{enumerable:!0,get:function(){return a}});let n=new WeakMap;function a(e){let r=n.get(e);if(r)return r;let t=Promise.resolve(e);return n.set(e,t),t}("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),r.exports=t.default)},97689,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"createRenderParamsFromClient",{enumerable:!0,get:function(){return n}});let n=e.r(6831).createRenderParamsFromClient;("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),r.exports=t.default)},42715,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"ReflectAdapter",{enumerable:!0,get:function(){return n}});class n{static get(e,r,t){let n=Reflect.get(e,r,t);return"function"==typeof n?n.bind(e):n}static set(e,r,t,n){return Reflect.set(e,r,t,n)}static has(e,r){return Reflect.has(e,r)}static deleteProperty(e,r){return Reflect.deleteProperty(e,r)}}},76361,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"createDedupedByCallsiteServerErrorLoggerDev",{enumerable:!0,get:function(){return l}});let n=function(e,r){if(e&&e.__esModule)return e;if(null===e||"object"!=typeof e&&"function"!=typeof e)return{default:e};var t=a(void 0);if(t&&t.has(e))return t.get(e);var n={__proto__:null},o=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var c in e)if("default"!==c&&Object.prototype.hasOwnProperty.call(e,c)){var u=o?Object.getOwnPropertyDescriptor(e,c):null;u&&(u.get||u.set)?Object.defineProperty(n,c,u):n[c]=e[c]}return n.default=e,t&&t.set(e,n),n}(e.r(71645));function a(e){if("function"!=typeof WeakMap)return null;var r=new WeakMap,t=new WeakMap;return(a=function(e){return e?t:r})(e)}let o={current:null},c="function"==typeof n.cache?n.cache:e=>e,u=console.warn;function l(e){return function(...r){u(e(...r))}}c(e=>{try{u(o.current)}finally{o.current=null}})},65932,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0});var n={describeHasCheckingStringProperty:function(){return u},describeStringPropertyAccess:function(){return c},wellKnownProperties:function(){return l}};for(var a in n)Object.defineProperty(t,a,{enumerable:!0,get:n[a]});let o=/^[A-Za-z_$][A-Za-z0-9_$]*$/;function c(e,r){return o.test(r)?`\`${e}.${r}\``:`\`${e}[${JSON.stringify(r)}]\``}function u(e,r){let t=JSON.stringify(r);return`\`Reflect.has(${e}, ${t})\`, \`${t} in ${e}\`, or similar`}let l=new Set(["hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toString","valueOf","toLocaleString","then","catch","finally","status","displayName","_debugInfo","toJSON","$$typeof","__esModule"])},83066,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"afterTaskAsyncStorageInstance",{enumerable:!0,get:function(){return n}});let n=(0,e.r(90317).createAsyncLocalStorage)()},41643,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"afterTaskAsyncStorage",{enumerable:!0,get:function(){return n.afterTaskAsyncStorageInstance}});let n=e.r(83066)},50999,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0});var n={isRequestAPICallableInsideAfter:function(){return s},throwForSearchParamsAccessInUseCache:function(){return l},throwWithStaticGenerationBailoutErrorWithDynamicError:function(){return u}};for(var a in n)Object.defineProperty(t,a,{enumerable:!0,get:n[a]});let o=e.r(43248),c=e.r(41643);function u(e,r){throw Object.defineProperty(new o.StaticGenBailoutError(`Route ${e} with \`dynamic = "error"\` couldn't be rendered statically because it used ${r}. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering`),"__NEXT_ERROR_CODE",{value:"E543",enumerable:!1,configurable:!0})}function l(e,r){let t=Object.defineProperty(Error(`Route ${e.route} used \`searchParams\` inside "use cache". Accessing dynamic request data inside a cache scope is not supported. If you need some search params inside a cached function await \`searchParams\` outside of the cached function and pass only the required search params as arguments to the cached function. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache`),"__NEXT_ERROR_CODE",{value:"E842",enumerable:!1,configurable:!0});throw Error.captureStackTrace(t,r),e.invalidDynamicUsageError??=t,t}function s(){let e=c.afterTaskAsyncStorage.getStore();return(null==e?void 0:e.rootTaskSpawnPhase)==="action"}},69882,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0});var n={createPrerenderSearchParamsForClientPage:function(){return g},createSearchParamsFromClient:function(){return p},createServerSearchParamsForMetadata:function(){return h},createServerSearchParamsForServerPage:function(){return m},makeErroringSearchParamsForUseCache:function(){return v}};for(var a in n)Object.defineProperty(t,a,{enumerable:!0,get:n[a]});let o=e.r(42715),c=e.r(67673),u=e.r(62141),l=e.r(12718),s=e.r(63138),i=e.r(76361),d=e.r(65932),f=e.r(50999);function p(e,r){let t=u.workUnitAsyncStorage.getStore();if(t)switch(t.type){case"prerender":case"prerender-client":case"prerender-ppr":case"prerender-legacy":return y(r,t);case"prerender-runtime":throw Object.defineProperty(new l.InvariantError("createSearchParamsFromClient should not be called in a runtime prerender."),"__NEXT_ERROR_CODE",{value:"E769",enumerable:!1,configurable:!0});case"cache":case"private-cache":case"unstable-cache":throw Object.defineProperty(new l.InvariantError("createSearchParamsFromClient should not be called in cache contexts."),"__NEXT_ERROR_CODE",{value:"E739",enumerable:!1,configurable:!0});case"request":return b(e,r,t)}(0,u.throwInvariantForMissingStore)()}e.r(42852);let h=m;function m(e,r){let t=u.workUnitAsyncStorage.getStore();if(t)switch(t.type){case"prerender":case"prerender-client":case"prerender-ppr":case"prerender-legacy":return y(r,t);case"cache":case"private-cache":case"unstable-cache":throw Object.defineProperty(new l.InvariantError("createServerSearchParamsForServerPage should not be called in cache contexts."),"__NEXT_ERROR_CODE",{value:"E747",enumerable:!1,configurable:!0});case"prerender-runtime":var n,a;return n=e,a=t,(0,c.delayUntilRuntimeStage)(a,O(n));case"request":return b(e,r,t)}(0,u.throwInvariantForMissingStore)()}function g(e){if(e.forceStatic)return Promise.resolve({});let r=u.workUnitAsyncStorage.getStore();if(r)switch(r.type){case"prerender":case"prerender-client":return(0,s.makeHangingPromise)(r.renderSignal,e.route,"`searchParams`");case"prerender-runtime":throw Object.defineProperty(new l.InvariantError("createPrerenderSearchParamsForClientPage should not be called in a runtime prerender."),"__NEXT_ERROR_CODE",{value:"E768",enumerable:!1,configurable:!0});case"cache":case"private-cache":case"unstable-cache":throw Object.defineProperty(new l.InvariantError("createPrerenderSearchParamsForClientPage should not be called in cache contexts."),"__NEXT_ERROR_CODE",{value:"E746",enumerable:!1,configurable:!0});case"prerender-ppr":case"prerender-legacy":case"request":return Promise.resolve({})}(0,u.throwInvariantForMissingStore)()}function y(e,r){if(e.forceStatic)return Promise.resolve({});switch(r.type){case"prerender":case"prerender-client":var t=e,n=r;let a=P.get(n);if(a)return a;let u=(0,s.makeHangingPromise)(n.renderSignal,t.route,"`searchParams`"),l=new Proxy(u,{get(e,r,t){if(Object.hasOwn(u,r))return o.ReflectAdapter.get(e,r,t);switch(r){case"then":return(0,c.annotateDynamicAccess)("`await searchParams`, `searchParams.then`, or similar",n),o.ReflectAdapter.get(e,r,t);case"status":return(0,c.annotateDynamicAccess)("`use(searchParams)`, `searchParams.status`, or similar",n),o.ReflectAdapter.get(e,r,t);default:return o.ReflectAdapter.get(e,r,t)}}});return P.set(n,l),l;case"prerender-ppr":case"prerender-legacy":var i=e,d=r;let p=P.get(i);if(p)return p;let h=Promise.resolve({}),m=new Proxy(h,{get(e,r,t){if(Object.hasOwn(h,r))return o.ReflectAdapter.get(e,r,t);if("string"==typeof r&&"then"===r){let e="`await searchParams`, `searchParams.then`, or similar";i.dynamicShouldError?(0,f.throwWithStaticGenerationBailoutErrorWithDynamicError)(i.route,e):"prerender-ppr"===d.type?(0,c.postponeWithTracking)(i.route,e,d.dynamicTracking):(0,c.throwToInterruptStaticGeneration)(e,i,d)}return o.ReflectAdapter.get(e,r,t)}});return P.set(i,m),m;default:return r}}function b(e,r,t){return r.forceStatic?Promise.resolve({}):O(e)}let P=new WeakMap,_=new WeakMap;function v(e){let r=_.get(e);if(r)return r;let t=Promise.resolve({}),n=new Proxy(t,{get:function r(n,a,c){return Object.hasOwn(t,a)||"string"!=typeof a||"then"!==a&&d.wellKnownProperties.has(a)||(0,f.throwForSearchParamsAccessInUseCache)(e,r),o.ReflectAdapter.get(n,a,c)}});return _.set(e,n),n}function O(e){let r=P.get(e);if(r)return r;let t=Promise.resolve(e);return P.set(e,t),t}(0,i.createDedupedByCallsiteServerErrorLoggerDev)(function(e,r){let t=e?`Route "${e}" `:"This route ";return Object.defineProperty(Error(`${t}used ${r}. \`searchParams\` is a Promise and must be unwrapped with \`await\` or \`React.use()\` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis`),"__NEXT_ERROR_CODE",{value:"E848",enumerable:!1,configurable:!0})})},74804,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"dynamicAccessAsyncStorageInstance",{enumerable:!0,get:function(){return n}});let n=(0,e.r(90317).createAsyncLocalStorage)()},88276,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"dynamicAccessAsyncStorage",{enumerable:!0,get:function(){return n.dynamicAccessAsyncStorageInstance}});let n=e.r(74804)},41489,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0});var n={createParamsFromClient:function(){return h},createPrerenderParamsForClientSegment:function(){return b},createServerParamsForMetadata:function(){return m},createServerParamsForRoute:function(){return g},createServerParamsForServerSegment:function(){return y}};for(var a in n)Object.defineProperty(t,a,{enumerable:!0,get:n[a]});let o=e.r(63599),c=e.r(42715),u=e.r(67673),l=e.r(62141),s=e.r(12718),i=e.r(65932),d=e.r(63138),f=e.r(76361),p=e.r(88276);function h(e,r){let t=l.workUnitAsyncStorage.getStore();if(t)switch(t.type){case"prerender":case"prerender-client":case"prerender-ppr":case"prerender-legacy":return P(e,r,t);case"cache":case"private-cache":case"unstable-cache":throw Object.defineProperty(new s.InvariantError("createParamsFromClient should not be called in cache contexts."),"__NEXT_ERROR_CODE",{value:"E736",enumerable:!1,configurable:!0});case"prerender-runtime":throw Object.defineProperty(new s.InvariantError("createParamsFromClient should not be called in a runtime prerender."),"__NEXT_ERROR_CODE",{value:"E770",enumerable:!1,configurable:!0});case"request":return S(e)}(0,l.throwInvariantForMissingStore)()}e.r(42852);let m=y;function g(e,r){let t=l.workUnitAsyncStorage.getStore();if(t)switch(t.type){case"prerender":case"prerender-client":case"prerender-ppr":case"prerender-legacy":return P(e,r,t);case"cache":case"private-cache":case"unstable-cache":throw Object.defineProperty(new s.InvariantError("createServerParamsForRoute should not be called in cache contexts."),"__NEXT_ERROR_CODE",{value:"E738",enumerable:!1,configurable:!0});case"prerender-runtime":return _(e,t);case"request":return S(e)}(0,l.throwInvariantForMissingStore)()}function y(e,r){let t=l.workUnitAsyncStorage.getStore();if(t)switch(t.type){case"prerender":case"prerender-client":case"prerender-ppr":case"prerender-legacy":return P(e,r,t);case"cache":case"private-cache":case"unstable-cache":throw Object.defineProperty(new s.InvariantError("createServerParamsForServerSegment should not be called in cache contexts."),"__NEXT_ERROR_CODE",{value:"E743",enumerable:!1,configurable:!0});case"prerender-runtime":return _(e,t);case"request":return S(e)}(0,l.throwInvariantForMissingStore)()}function b(e){let r=o.workAsyncStorage.getStore();if(!r)throw Object.defineProperty(new s.InvariantError("Missing workStore in createPrerenderParamsForClientSegment"),"__NEXT_ERROR_CODE",{value:"E773",enumerable:!1,configurable:!0});let t=l.workUnitAsyncStorage.getStore();if(t)switch(t.type){case"prerender":case"prerender-client":let n=t.fallbackRouteParams;if(n){for(let a in e)if(n.has(a))return(0,d.makeHangingPromise)(t.renderSignal,r.route,"`params`")}break;case"cache":case"private-cache":case"unstable-cache":throw Object.defineProperty(new s.InvariantError("createPrerenderParamsForClientSegment should not be called in cache contexts."),"__NEXT_ERROR_CODE",{value:"E734",enumerable:!1,configurable:!0})}return Promise.resolve(e)}function P(e,r,t){switch(t.type){case"prerender":case"prerender-client":{let n=t.fallbackRouteParams;if(n){for(let a in e)if(n.has(a))return function(e,r,t){let n=v.get(e);if(n)return n;let a=new Proxy((0,d.makeHangingPromise)(t.renderSignal,r.route,"`params`"),O);return v.set(e,a),a}(e,r,t)}break}case"prerender-ppr":{let n=t.fallbackRouteParams;if(n){for(let a in e)if(n.has(a))return function(e,r,t,n){let a=v.get(e);if(a)return a;let o={...e},c=Promise.resolve(o);return v.set(e,c),Object.keys(e).forEach(e=>{i.wellKnownProperties.has(e)||r.has(e)&&Object.defineProperty(o,e,{get(){let r=(0,i.describeStringPropertyAccess)("params",e);"prerender-ppr"===n.type?(0,u.postponeWithTracking)(t.route,r,n.dynamicTracking):(0,u.throwToInterruptStaticGeneration)(r,t,n)},enumerable:!0})}),c}(e,n,r,t)}}}return S(e)}function _(e,r){return(0,u.delayUntilRuntimeStage)(r,S(e))}let v=new WeakMap,O={get:function(e,r,t){if("then"===r||"catch"===r||"finally"===r){let n=c.ReflectAdapter.get(e,r,t);return({[r]:(...r)=>{let t=p.dynamicAccessAsyncStorage.getStore();return t&&t.abortController.abort(Object.defineProperty(Error("Accessed fallback `params` during prerendering."),"__NEXT_ERROR_CODE",{value:"E691",enumerable:!1,configurable:!0})),new Proxy(n.apply(e,r),O)}})[r]}return c.ReflectAdapter.get(e,r,t)}};function S(e){let r=v.get(e);if(r)return r;let t=Promise.resolve(e);return v.set(e,t),t}(0,f.createDedupedByCallsiteServerErrorLoggerDev)(function(e,r){let t=e?`Route "${e}" `:"This route ";return Object.defineProperty(Error(`${t}used ${r}. \`params\` is a Promise and must be unwrapped with \`await\` or \`React.use()\` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis`),"__NEXT_ERROR_CODE",{value:"E834",enumerable:!1,configurable:!0})})},47257,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"ClientPageRoot",{enumerable:!0,get:function(){return s}});let n=e.r(43476),a=e.r(12718),o=e.r(8372),c=e.r(71645),u=e.r(33906),l=e.r(61994);function s({Component:r,serverProvidedParams:t}){let s,i;if(null!==t)s=t.searchParams,i=t.params;else{let e=(0,c.use)(o.LayoutRouterContext);i=null!==e?e.parentParams:{},s=(0,u.urlSearchParamsToParsedUrlQuery)((0,c.use)(l.SearchParamsContext))}if("undefined"==typeof window){let t,o,{workAsyncStorage:c}=e.r(63599),u=c.getStore();if(!u)throw Object.defineProperty(new a.InvariantError("Expected workStore to exist when handling searchParams in a client Page."),"__NEXT_ERROR_CODE",{value:"E564",enumerable:!1,configurable:!0});let{createSearchParamsFromClient:l}=e.r(69882);t=l(s,u);let{createParamsFromClient:d}=e.r(41489);return o=d(i,u),(0,n.jsx)(r,{params:o,searchParams:t})}{let{createRenderSearchParamsFromClient:t}=e.r(66996),a=t(s),{createRenderParamsFromClient:o}=e.r(97689),c=o(i);return(0,n.jsx)(r,{params:c,searchParams:a})}}("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),r.exports=t.default)},92825,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"ClientSegmentRoot",{enumerable:!0,get:function(){return u}});let n=e.r(43476),a=e.r(12718),o=e.r(8372),c=e.r(71645);function u({Component:r,slots:t,serverProvidedParams:u}){let l;if(null!==u)l=u.params;else{let e=(0,c.use)(o.LayoutRouterContext);l=null!==e?e.parentParams:{}}if("undefined"==typeof window){let o,{workAsyncStorage:c}=e.r(63599),u=c.getStore();if(!u)throw Object.defineProperty(new a.InvariantError("Expected workStore to exist when handling params in a client segment such as a Layout or Template."),"__NEXT_ERROR_CODE",{value:"E600",enumerable:!1,configurable:!0});let{createParamsFromClient:s}=e.r(41489);return o=s(l,u),(0,n.jsx)(r,{...t,params:o})}{let{createRenderParamsFromClient:a}=e.r(97689),o=a(l);return(0,n.jsx)(r,{...t,params:o})}}("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),r.exports=t.default)},27201,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"IconMark",{enumerable:!0,get:function(){return a}});let n=e.r(43476),a=()=>"undefined"!=typeof window?null:(0,n.jsx)("meta",{name:"«nxt-icon»"})}]);
|
static/frontend/login/_next/static/chunks/66925481c7f9f43e.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/frontend/login/_next/static/chunks/752a4bd6387d6ef7.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,68027,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"default",{enumerable:!0,get:function(){return s}});let n=e.r(43476),o=e.r(12354),u={fontFamily:'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',height:"100vh",textAlign:"center",display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center"},i={fontSize:"14px",fontWeight:400,lineHeight:"28px",margin:"0 8px"},s=function({error:e}){let t=e?.digest;return(0,n.jsxs)("html",{id:"__next_error__",children:[(0,n.jsx)("head",{}),(0,n.jsxs)("body",{children:[(0,n.jsx)(o.HandleISRError,{error:e}),(0,n.jsx)("div",{style:u,children:(0,n.jsxs)("div",{children:[(0,n.jsxs)("h2",{style:i,children:["Application error: a ",t?"server":"client","-side exception has occurred while loading ",window.location.hostname," (see the"," ",t?"server logs":"browser console"," for more information)."]}),t?(0,n.jsx)("p",{style:i,children:`Digest: ${t}`}):null]})})]})]})};("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},35451,(e,t,r)=>{var n={229:function(e){var t,r,n,o=e.exports={};function u(){throw Error("setTimeout has not been defined")}function i(){throw Error("clearTimeout has not been defined")}try{t="function"==typeof setTimeout?setTimeout:u}catch(e){t=u}try{r="function"==typeof clearTimeout?clearTimeout:i}catch(e){r=i}function s(e){if(t===setTimeout)return setTimeout(e,0);if((t===u||!t)&&setTimeout)return t=setTimeout,setTimeout(e,0);try{return t(e,0)}catch(r){try{return t.call(null,e,0)}catch(r){return t.call(this,e,0)}}}var c=[],l=!1,a=-1;function f(){l&&n&&(l=!1,n.length?c=n.concat(c):a=-1,c.length&&p())}function p(){if(!l){var e=s(f);l=!0;for(var t=c.length;t;){for(n=c,c=[];++a<t;)n&&n[a].run();a=-1,t=c.length}n=null,l=!1,function(e){if(r===clearTimeout)return clearTimeout(e);if((r===i||!r)&&clearTimeout)return r=clearTimeout,clearTimeout(e);try{r(e)}catch(t){try{return r.call(null,e)}catch(t){return r.call(this,e)}}}(e)}}function d(e,t){this.fun=e,this.array=t}function y(){}o.nextTick=function(e){var t=Array(arguments.length-1);if(arguments.length>1)for(var r=1;r<arguments.length;r++)t[r-1]=arguments[r];c.push(new d(e,t)),1!==c.length||l||s(p)},d.prototype.run=function(){this.fun.apply(null,this.array)},o.title="browser",o.browser=!0,o.env={},o.argv=[],o.version="",o.versions={},o.on=y,o.addListener=y,o.once=y,o.off=y,o.removeListener=y,o.removeAllListeners=y,o.emit=y,o.prependListener=y,o.prependOnceListener=y,o.listeners=function(e){return[]},o.binding=function(e){throw Error("process.binding is not supported")},o.cwd=function(){return"/"},o.chdir=function(e){throw Error("process.chdir is not supported")},o.umask=function(){return 0}}},o={};function u(e){var t=o[e];if(void 0!==t)return t.exports;var r=o[e]={exports:{}},i=!0;try{n[e](r,r.exports,u),i=!1}finally{i&&delete o[e]}return r.exports}u.ab="/ROOT/node_modules/next/dist/compiled/process/",t.exports=u(229)},47167,(e,t,r)=>{"use strict";var n,o;t.exports=(null==(n=e.g.process)?void 0:n.env)&&"object"==typeof(null==(o=e.g.process)?void 0:o.env)?e.g.process:e.r(35451)},45689,(e,t,r)=>{"use strict";var n=Symbol.for("react.transitional.element");function o(e,t,r){var o=null;if(void 0!==r&&(o=""+r),void 0!==t.key&&(o=""+t.key),"key"in t)for(var u in r={},t)"key"!==u&&(r[u]=t[u]);else r=t;return{$$typeof:n,type:e,key:o,ref:void 0!==(t=r.ref)?t:null,props:r}}r.Fragment=Symbol.for("react.fragment"),r.jsx=o,r.jsxs=o},43476,(e,t,r)=>{"use strict";t.exports=e.r(45689)},90317,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={bindSnapshot:function(){return l},createAsyncLocalStorage:function(){return c},createSnapshot:function(){return a}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=Object.defineProperty(Error("Invariant: AsyncLocalStorage accessed in runtime where it is not available"),"__NEXT_ERROR_CODE",{value:"E504",enumerable:!1,configurable:!0});class i{disable(){throw u}getStore(){}run(){throw u}exit(){throw u}enterWith(){throw u}static bind(e){return e}}let s="undefined"!=typeof globalThis&&globalThis.AsyncLocalStorage;function c(){return s?new s:new i}function l(e){return s?s.bind(e):i.bind(e)}function a(){return s?s.snapshot():function(e,...t){return e(...t)}}},42344,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"workAsyncStorageInstance",{enumerable:!0,get:function(){return n}});let n=(0,e.r(90317).createAsyncLocalStorage)()},63599,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"workAsyncStorage",{enumerable:!0,get:function(){return n.workAsyncStorageInstance}});let n=e.r(42344)},12354,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"HandleISRError",{enumerable:!0,get:function(){return o}});let n="undefined"==typeof window?e.r(63599).workAsyncStorage:void 0;function o({error:e}){if(n){let t=n.getStore();if(t?.isStaticGeneration)throw e&&console.error(e),e}return null}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},50740,(e,t,r)=>{"use strict";var n=e.i(47167),o=Symbol.for("react.transitional.element"),u=Symbol.for("react.portal"),i=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),c=Symbol.for("react.profiler"),l=Symbol.for("react.consumer"),a=Symbol.for("react.context"),f=Symbol.for("react.forward_ref"),p=Symbol.for("react.suspense"),d=Symbol.for("react.memo"),y=Symbol.for("react.lazy"),h=Symbol.for("react.activity"),v=Symbol.for("react.view_transition"),b=Symbol.iterator,m={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},_=Object.assign,g={};function S(e,t,r){this.props=e,this.context=t,this.refs=g,this.updater=r||m}function j(){}function w(e,t,r){this.props=e,this.context=t,this.refs=g,this.updater=r||m}S.prototype.isReactComponent={},S.prototype.setState=function(e,t){if("object"!=typeof e&&"function"!=typeof e&&null!=e)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")},S.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")},j.prototype=S.prototype;var E=w.prototype=new j;E.constructor=w,_(E,S.prototype),E.isPureReactComponent=!0;var x=Array.isArray;function T(){}var O={H:null,A:null,T:null,S:null},k=Object.prototype.hasOwnProperty;function R(e,t,r){var n=r.ref;return{$$typeof:o,type:e,key:t,ref:void 0!==n?n:null,props:r}}function A(e){return"object"==typeof e&&null!==e&&e.$$typeof===o}var H=/\/+/g;function C(e,t){var r,n;return"object"==typeof e&&null!==e&&null!=e.key?(r=""+e.key,n={"=":"=0",":":"=2"},"$"+r.replace(/[=:]/g,function(e){return n[e]})):t.toString(36)}function P(e,t,r){if(null==e)return e;var n=[],i=0;return!function e(t,r,n,i,s){var c,l,a,f=typeof t;("undefined"===f||"boolean"===f)&&(t=null);var p=!1;if(null===t)p=!0;else switch(f){case"bigint":case"string":case"number":p=!0;break;case"object":switch(t.$$typeof){case o:case u:p=!0;break;case y:return e((p=t._init)(t._payload),r,n,i,s)}}if(p)return s=s(t),p=""===i?"."+C(t,0):i,x(s)?(n="",null!=p&&(n=p.replace(H,"$&/")+"/"),e(s,r,n,"",function(e){return e})):null!=s&&(A(s)&&(c=s,l=n+(null==s.key||t&&t.key===s.key?"":(""+s.key).replace(H,"$&/")+"/")+p,s=R(c.type,l,c.props)),r.push(s)),1;p=0;var d=""===i?".":i+":";if(x(t))for(var h=0;h<t.length;h++)f=d+C(i=t[h],h),p+=e(i,r,n,f,s);else if("function"==typeof(h=null===(a=t)||"object"!=typeof a?null:"function"==typeof(a=b&&a[b]||a["@@iterator"])?a:null))for(t=h.call(t),h=0;!(i=t.next()).done;)f=d+C(i=i.value,h++),p+=e(i,r,n,f,s);else if("object"===f){if("function"==typeof t.then)return e(function(e){switch(e.status){case"fulfilled":return e.value;case"rejected":throw e.reason;default:switch("string"==typeof e.status?e.then(T,T):(e.status="pending",e.then(function(t){"pending"===e.status&&(e.status="fulfilled",e.value=t)},function(t){"pending"===e.status&&(e.status="rejected",e.reason=t)})),e.status){case"fulfilled":return e.value;case"rejected":throw e.reason}}throw e}(t),r,n,i,s);throw Error("Objects are not valid as a React child (found: "+("[object Object]"===(r=String(t))?"object with keys {"+Object.keys(t).join(", ")+"}":r)+"). If you meant to render a collection of children, use an array instead.")}return p}(e,n,"","",function(e){return t.call(r,e,i++)}),n}function $(e){if(-1===e._status){var t=e._result;(t=t()).then(function(t){(0===e._status||-1===e._status)&&(e._status=1,e._result=t)},function(t){(0===e._status||-1===e._status)&&(e._status=2,e._result=t)}),-1===e._status&&(e._status=0,e._result=t)}if(1===e._status)return e._result.default;throw e._result}var I="function"==typeof reportError?reportError:function(e){if("object"==typeof window&&"function"==typeof window.ErrorEvent){var t=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:"object"==typeof e&&null!==e&&"string"==typeof e.message?String(e.message):String(e),error:e});if(!window.dispatchEvent(t))return}else if("object"==typeof n.default&&"function"==typeof n.default.emit)return void n.default.emit("uncaughtException",e);console.error(e)};function M(e){var t=O.T,r={};r.types=null!==t?t.types:null,O.T=r;try{var n=e(),o=O.S;null!==o&&o(r,n),"object"==typeof n&&null!==n&&"function"==typeof n.then&&n.then(T,I)}catch(e){I(e)}finally{null!==t&&null!==r.types&&(t.types=r.types),O.T=t}}function L(e){var t=O.T;if(null!==t){var r=t.types;null===r?t.types=[e]:-1===r.indexOf(e)&&r.push(e)}else M(L.bind(null,e))}r.Activity=h,r.Children={map:P,forEach:function(e,t,r){P(e,function(){t.apply(this,arguments)},r)},count:function(e){var t=0;return P(e,function(){t++}),t},toArray:function(e){return P(e,function(e){return e})||[]},only:function(e){if(!A(e))throw Error("React.Children.only expected to receive a single React element child.");return e}},r.Component=S,r.Fragment=i,r.Profiler=c,r.PureComponent=w,r.StrictMode=s,r.Suspense=p,r.ViewTransition=v,r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=O,r.__COMPILER_RUNTIME={__proto__:null,c:function(e){return O.H.useMemoCache(e)}},r.addTransitionType=L,r.cache=function(e){return function(){return e.apply(null,arguments)}},r.cacheSignal=function(){return null},r.cloneElement=function(e,t,r){if(null==e)throw Error("The argument must be a React element, but you passed "+e+".");var n=_({},e.props),o=e.key;if(null!=t)for(u in void 0!==t.key&&(o=""+t.key),t)k.call(t,u)&&"key"!==u&&"__self"!==u&&"__source"!==u&&("ref"!==u||void 0!==t.ref)&&(n[u]=t[u]);var u=arguments.length-2;if(1===u)n.children=r;else if(1<u){for(var i=Array(u),s=0;s<u;s++)i[s]=arguments[s+2];n.children=i}return R(e.type,o,n)},r.createContext=function(e){return(e={$$typeof:a,_currentValue:e,_currentValue2:e,_threadCount:0,Provider:null,Consumer:null}).Provider=e,e.Consumer={$$typeof:l,_context:e},e},r.createElement=function(e,t,r){var n,o={},u=null;if(null!=t)for(n in void 0!==t.key&&(u=""+t.key),t)k.call(t,n)&&"key"!==n&&"__self"!==n&&"__source"!==n&&(o[n]=t[n]);var i=arguments.length-2;if(1===i)o.children=r;else if(1<i){for(var s=Array(i),c=0;c<i;c++)s[c]=arguments[c+2];o.children=s}if(e&&e.defaultProps)for(n in i=e.defaultProps)void 0===o[n]&&(o[n]=i[n]);return R(e,u,o)},r.createRef=function(){return{current:null}},r.forwardRef=function(e){return{$$typeof:f,render:e}},r.isValidElement=A,r.lazy=function(e){return{$$typeof:y,_payload:{_status:-1,_result:e},_init:$}},r.memo=function(e,t){return{$$typeof:d,type:e,compare:void 0===t?null:t}},r.startTransition=M,r.unstable_useCacheRefresh=function(){return O.H.useCacheRefresh()},r.use=function(e){return O.H.use(e)},r.useActionState=function(e,t,r){return O.H.useActionState(e,t,r)},r.useCallback=function(e,t){return O.H.useCallback(e,t)},r.useContext=function(e){return O.H.useContext(e)},r.useDebugValue=function(){},r.useDeferredValue=function(e,t){return O.H.useDeferredValue(e,t)},r.useEffect=function(e,t){return O.H.useEffect(e,t)},r.useEffectEvent=function(e){return O.H.useEffectEvent(e)},r.useId=function(){return O.H.useId()},r.useImperativeHandle=function(e,t,r){return O.H.useImperativeHandle(e,t,r)},r.useInsertionEffect=function(e,t){return O.H.useInsertionEffect(e,t)},r.useLayoutEffect=function(e,t){return O.H.useLayoutEffect(e,t)},r.useMemo=function(e,t){return O.H.useMemo(e,t)},r.useOptimistic=function(e,t){return O.H.useOptimistic(e,t)},r.useReducer=function(e,t,r){return O.H.useReducer(e,t,r)},r.useRef=function(e){return O.H.useRef(e)},r.useState=function(e){return O.H.useState(e)},r.useSyncExternalStore=function(e,t,r){return O.H.useSyncExternalStore(e,t,r)},r.useTransition=function(){return O.H.useTransition()},r.version="19.3.0-canary-2bcbf254-20251020"},71645,(e,t,r)=>{"use strict";t.exports=e.r(50740)}]);
|
static/frontend/login/_next/static/chunks/9b0c5fad00d39a77.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,17994,e=>{"use strict";let r,t,o;var n,l=e.i(43476),s=e.i(71645);function a(e,r){if("function"==typeof e)return e(r);null!=e&&(e.current=r)}var i=s.forwardRef((e,r)=>{let{children:t,...o}=e,n=s.Children.toArray(t),a=n.find(p);if(a){let e=a.props.children,t=n.map(r=>r!==a?r:s.Children.count(e)>1?s.Children.only(null):s.isValidElement(e)?e.props.children:null);return(0,l.jsx)(c,{...o,ref:r,children:s.isValidElement(e)?s.cloneElement(e,void 0,t):null})}return(0,l.jsx)(c,{...o,ref:r,children:t})});i.displayName="Slot";var c=s.forwardRef((e,r)=>{let{children:t,...o}=e;if(s.isValidElement(t)){var n;let e,l,i=(n=t,(l=(e=Object.getOwnPropertyDescriptor(n.props,"ref")?.get)&&"isReactWarning"in e&&e.isReactWarning)?n.ref:(l=(e=Object.getOwnPropertyDescriptor(n,"ref")?.get)&&"isReactWarning"in e&&e.isReactWarning)?n.props.ref:n.props.ref||n.ref);return s.cloneElement(t,{...function(e,r){let t={...r};for(let o in r){let n=e[o],l=r[o];/^on[A-Z]/.test(o)?n&&l?t[o]=(...e)=>{l(...e),n(...e)}:n&&(t[o]=n):"style"===o?t[o]={...n,...l}:"className"===o&&(t[o]=[n,l].filter(Boolean).join(" "))}return{...e,...t}}(o,t.props),ref:r?function(...e){return r=>{let t=!1,o=e.map(e=>{let o=a(e,r);return t||"function"!=typeof o||(t=!0),o});if(t)return()=>{for(let r=0;r<o.length;r++){let t=o[r];"function"==typeof t?t():a(e[r],null)}}}}(r,i):i})}return s.Children.count(t)>1?s.Children.only(null):null});c.displayName="SlotClone";var d=({children:e})=>(0,l.jsx)(l.Fragment,{children:e});function p(e){return s.isValidElement(e)&&e.type===d}function u(){for(var e,r,t=0,o="",n=arguments.length;t<n;t++)(e=arguments[t])&&(r=function e(r){var t,o,n="";if("string"==typeof r||"number"==typeof r)n+=r;else if("object"==typeof r)if(Array.isArray(r)){var l=r.length;for(t=0;t<l;t++)r[t]&&(o=e(r[t]))&&(n&&(n+=" "),n+=o)}else for(o in r)r[o]&&(n&&(n+=" "),n+=o);return n}(e))&&(o&&(o+=" "),o+=r);return o}let b=e=>"boolean"==typeof e?`${e}`:0===e?"0":e,m=(e,r)=>{if(0===e.length)return r.classGroupId;let t=e[0],o=r.nextPart.get(t),n=o?m(e.slice(1),o):void 0;if(n)return n;if(0===r.validators.length)return;let l=e.join("-");return r.validators.find(({validator:e})=>e(l))?.classGroupId},g=/^\[(.+)\]$/,h=(e,r,t,o)=>{e.forEach(e=>{if("string"==typeof e){(""===e?r:f(r,e)).classGroupId=t;return}"function"==typeof e?x(e)?h(e(o),r,t,o):r.validators.push({validator:e,classGroupId:t}):Object.entries(e).forEach(([e,n])=>{h(n,f(r,e),t,o)})})},f=(e,r)=>{let t=e;return r.split("-").forEach(e=>{t.nextPart.has(e)||t.nextPart.set(e,{nextPart:new Map,validators:[]}),t=t.nextPart.get(e)}),t},x=e=>e.isThemeGetter,v=(e,r)=>r?e.map(([e,t])=>[e,t.map(e=>"string"==typeof e?r+e:"object"==typeof e?Object.fromEntries(Object.entries(e).map(([e,t])=>[r+e,t])):e)]):e,y=e=>{if(e.length<=1)return e;let r=[],t=[];return e.forEach(e=>{"["===e[0]?(r.push(...t.sort(),e),t=[]):t.push(e)}),r.push(...t.sort()),r},w=/\s+/;function k(){let e,r,t=0,o="";for(;t<arguments.length;)(e=arguments[t++])&&(r=j(e))&&(o&&(o+=" "),o+=r);return o}let j=e=>{let r;if("string"==typeof e)return e;let t="";for(let o=0;o<e.length;o++)e[o]&&(r=j(e[o]))&&(t&&(t+=" "),t+=r);return t},A=e=>{let r=r=>r[e]||[];return r.isThemeGetter=!0,r},z=/^\[(?:([a-z-]+):)?(.+)\]$/i,C=/^\d+\/\d+$/,N=new Set(["px","full","screen"]),S=/^(\d+(\.\d+)?)?(xs|sm|md|lg|xl)$/,M=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/,Q=/^(rgba?|hsla?|hwb|(ok)?(lab|lch))\(.+\)$/,L=/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/,W=/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/,E=e=>_(e)||N.has(e)||C.test(e),O=e=>H(e,"length",K),_=e=>!!e&&!Number.isNaN(Number(e)),P=e=>H(e,"number",_),R=e=>!!e&&Number.isInteger(Number(e)),G=e=>e.endsWith("%")&&_(e.slice(0,-1)),I=e=>z.test(e),$=e=>S.test(e),B=new Set(["length","size","percentage"]),T=e=>H(e,B,q),V=e=>H(e,"position",q),Z=new Set(["image","url"]),F=e=>H(e,Z,Y),U=e=>H(e,"",J),D=()=>!0,H=(e,r,t)=>{let o=z.exec(e);return!!o&&(o[1]?"string"==typeof r?o[1]===r:r.has(o[1]):t(o[2]))},K=e=>M.test(e)&&!Q.test(e),q=()=>!1,J=e=>L.test(e),Y=e=>W.test(e),X=function(e,...r){let t,o,n,l=function(a){let i;return o=(t={cache:(e=>{if(e<1)return{get:()=>void 0,set:()=>{}};let r=0,t=new Map,o=new Map,n=(n,l)=>{t.set(n,l),++r>e&&(r=0,o=t,t=new Map)};return{get(e){let r=t.get(e);return void 0!==r?r:void 0!==(r=o.get(e))?(n(e,r),r):void 0},set(e,r){t.has(e)?t.set(e,r):n(e,r)}}})((i=r.reduce((e,r)=>r(e),e())).cacheSize),parseClassName:(e=>{let{separator:r,experimentalParseClassName:t}=e,o=1===r.length,n=r[0],l=r.length,s=e=>{let t,s=[],a=0,i=0;for(let c=0;c<e.length;c++){let d=e[c];if(0===a){if(d===n&&(o||e.slice(c,c+l)===r)){s.push(e.slice(i,c)),i=c+l;continue}if("/"===d){t=c;continue}}"["===d?a++:"]"===d&&a--}let c=0===s.length?e:e.substring(i),d=c.startsWith("!"),p=d?c.substring(1):c;return{modifiers:s,hasImportantModifier:d,baseClassName:p,maybePostfixModifierPosition:t&&t>i?t-i:void 0}};return t?e=>t({className:e,parseClassName:s}):s})(i),...(e=>{let r=(e=>{let{theme:r,prefix:t}=e,o={nextPart:new Map,validators:[]};return v(Object.entries(e.classGroups),t).forEach(([e,t])=>{h(t,o,e,r)}),o})(e),{conflictingClassGroups:t,conflictingClassGroupModifiers:o}=e;return{getClassGroupId:e=>{let t=e.split("-");return""===t[0]&&1!==t.length&&t.shift(),m(t,r)||(e=>{if(g.test(e)){let r=g.exec(e)[1],t=r?.substring(0,r.indexOf(":"));if(t)return"arbitrary.."+t}})(e)},getConflictingClassGroupIds:(e,r)=>{let n=t[e]||[];return r&&o[e]?[...n,...o[e]]:n}}})(i)}).cache.get,n=t.cache.set,l=s,s(a)};function s(e){let r=o(e);if(r)return r;let l=((e,r)=>{let{parseClassName:t,getClassGroupId:o,getConflictingClassGroupIds:n}=r,l=[],s=e.trim().split(w),a="";for(let e=s.length-1;e>=0;e-=1){let r=s[e],{modifiers:i,hasImportantModifier:c,baseClassName:d,maybePostfixModifierPosition:p}=t(r),u=!!p,b=o(u?d.substring(0,p):d);if(!b){if(!u||!(b=o(d))){a=r+(a.length>0?" "+a:a);continue}u=!1}let m=y(i).join(":"),g=c?m+"!":m,h=g+b;if(l.includes(h))continue;l.push(h);let f=n(b,u);for(let e=0;e<f.length;++e){let r=f[e];l.push(g+r)}a=r+(a.length>0?" "+a:a)}return a})(e,t);return n(e,l),l}return function(){return l(k.apply(null,arguments))}}(()=>{let e=A("colors"),r=A("spacing"),t=A("blur"),o=A("brightness"),n=A("borderColor"),l=A("borderRadius"),s=A("borderSpacing"),a=A("borderWidth"),i=A("contrast"),c=A("grayscale"),d=A("hueRotate"),p=A("invert"),u=A("gap"),b=A("gradientColorStops"),m=A("gradientColorStopPositions"),g=A("inset"),h=A("margin"),f=A("opacity"),x=A("padding"),v=A("saturate"),y=A("scale"),w=A("sepia"),k=A("skew"),j=A("space"),z=A("translate"),C=()=>["auto","contain","none"],N=()=>["auto","hidden","clip","visible","scroll"],S=()=>["auto",I,r],M=()=>[I,r],Q=()=>["",E,O],L=()=>["auto",_,I],W=()=>["bottom","center","left","left-bottom","left-top","right","right-bottom","right-top","top"],B=()=>["solid","dashed","dotted","double","none"],Z=()=>["normal","multiply","screen","overlay","darken","lighten","color-dodge","color-burn","hard-light","soft-light","difference","exclusion","hue","saturation","color","luminosity"],H=()=>["start","end","center","between","around","evenly","stretch"],K=()=>["","0",I],q=()=>["auto","avoid","all","avoid-page","page","left","right","column"],J=()=>[_,I];return{cacheSize:500,separator:":",theme:{colors:[D],spacing:[E,O],blur:["none","",$,I],brightness:J(),borderColor:[e],borderRadius:["none","","full",$,I],borderSpacing:M(),borderWidth:Q(),contrast:J(),grayscale:K(),hueRotate:J(),invert:K(),gap:M(),gradientColorStops:[e],gradientColorStopPositions:[G,O],inset:S(),margin:S(),opacity:J(),padding:M(),saturate:J(),scale:J(),sepia:K(),skew:J(),space:M(),translate:M()},classGroups:{aspect:[{aspect:["auto","square","video",I]}],container:["container"],columns:[{columns:[$]}],"break-after":[{"break-after":q()}],"break-before":[{"break-before":q()}],"break-inside":[{"break-inside":["auto","avoid","avoid-page","avoid-column"]}],"box-decoration":[{"box-decoration":["slice","clone"]}],box:[{box:["border","content"]}],display:["block","inline-block","inline","flex","inline-flex","table","inline-table","table-caption","table-cell","table-column","table-column-group","table-footer-group","table-header-group","table-row-group","table-row","flow-root","grid","inline-grid","contents","list-item","hidden"],float:[{float:["right","left","none","start","end"]}],clear:[{clear:["left","right","both","none","start","end"]}],isolation:["isolate","isolation-auto"],"object-fit":[{object:["contain","cover","fill","none","scale-down"]}],"object-position":[{object:[...W(),I]}],overflow:[{overflow:N()}],"overflow-x":[{"overflow-x":N()}],"overflow-y":[{"overflow-y":N()}],overscroll:[{overscroll:C()}],"overscroll-x":[{"overscroll-x":C()}],"overscroll-y":[{"overscroll-y":C()}],position:["static","fixed","absolute","relative","sticky"],inset:[{inset:[g]}],"inset-x":[{"inset-x":[g]}],"inset-y":[{"inset-y":[g]}],start:[{start:[g]}],end:[{end:[g]}],top:[{top:[g]}],right:[{right:[g]}],bottom:[{bottom:[g]}],left:[{left:[g]}],visibility:["visible","invisible","collapse"],z:[{z:["auto",R,I]}],basis:[{basis:S()}],"flex-direction":[{flex:["row","row-reverse","col","col-reverse"]}],"flex-wrap":[{flex:["wrap","wrap-reverse","nowrap"]}],flex:[{flex:["1","auto","initial","none",I]}],grow:[{grow:K()}],shrink:[{shrink:K()}],order:[{order:["first","last","none",R,I]}],"grid-cols":[{"grid-cols":[D]}],"col-start-end":[{col:["auto",{span:["full",R,I]},I]}],"col-start":[{"col-start":L()}],"col-end":[{"col-end":L()}],"grid-rows":[{"grid-rows":[D]}],"row-start-end":[{row:["auto",{span:[R,I]},I]}],"row-start":[{"row-start":L()}],"row-end":[{"row-end":L()}],"grid-flow":[{"grid-flow":["row","col","dense","row-dense","col-dense"]}],"auto-cols":[{"auto-cols":["auto","min","max","fr",I]}],"auto-rows":[{"auto-rows":["auto","min","max","fr",I]}],gap:[{gap:[u]}],"gap-x":[{"gap-x":[u]}],"gap-y":[{"gap-y":[u]}],"justify-content":[{justify:["normal",...H()]}],"justify-items":[{"justify-items":["start","end","center","stretch"]}],"justify-self":[{"justify-self":["auto","start","end","center","stretch"]}],"align-content":[{content:["normal",...H(),"baseline"]}],"align-items":[{items:["start","end","center","baseline","stretch"]}],"align-self":[{self:["auto","start","end","center","stretch","baseline"]}],"place-content":[{"place-content":[...H(),"baseline"]}],"place-items":[{"place-items":["start","end","center","baseline","stretch"]}],"place-self":[{"place-self":["auto","start","end","center","stretch"]}],p:[{p:[x]}],px:[{px:[x]}],py:[{py:[x]}],ps:[{ps:[x]}],pe:[{pe:[x]}],pt:[{pt:[x]}],pr:[{pr:[x]}],pb:[{pb:[x]}],pl:[{pl:[x]}],m:[{m:[h]}],mx:[{mx:[h]}],my:[{my:[h]}],ms:[{ms:[h]}],me:[{me:[h]}],mt:[{mt:[h]}],mr:[{mr:[h]}],mb:[{mb:[h]}],ml:[{ml:[h]}],"space-x":[{"space-x":[j]}],"space-x-reverse":["space-x-reverse"],"space-y":[{"space-y":[j]}],"space-y-reverse":["space-y-reverse"],w:[{w:["auto","min","max","fit","svw","lvw","dvw",I,r]}],"min-w":[{"min-w":[I,r,"min","max","fit"]}],"max-w":[{"max-w":[I,r,"none","full","min","max","fit","prose",{screen:[$]},$]}],h:[{h:[I,r,"auto","min","max","fit","svh","lvh","dvh"]}],"min-h":[{"min-h":[I,r,"min","max","fit","svh","lvh","dvh"]}],"max-h":[{"max-h":[I,r,"min","max","fit","svh","lvh","dvh"]}],size:[{size:[I,r,"auto","min","max","fit"]}],"font-size":[{text:["base",$,O]}],"font-smoothing":["antialiased","subpixel-antialiased"],"font-style":["italic","not-italic"],"font-weight":[{font:["thin","extralight","light","normal","medium","semibold","bold","extrabold","black",P]}],"font-family":[{font:[D]}],"fvn-normal":["normal-nums"],"fvn-ordinal":["ordinal"],"fvn-slashed-zero":["slashed-zero"],"fvn-figure":["lining-nums","oldstyle-nums"],"fvn-spacing":["proportional-nums","tabular-nums"],"fvn-fraction":["diagonal-fractions","stacked-fractions"],tracking:[{tracking:["tighter","tight","normal","wide","wider","widest",I]}],"line-clamp":[{"line-clamp":["none",_,P]}],leading:[{leading:["none","tight","snug","normal","relaxed","loose",E,I]}],"list-image":[{"list-image":["none",I]}],"list-style-type":[{list:["none","disc","decimal",I]}],"list-style-position":[{list:["inside","outside"]}],"placeholder-color":[{placeholder:[e]}],"placeholder-opacity":[{"placeholder-opacity":[f]}],"text-alignment":[{text:["left","center","right","justify","start","end"]}],"text-color":[{text:[e]}],"text-opacity":[{"text-opacity":[f]}],"text-decoration":["underline","overline","line-through","no-underline"],"text-decoration-style":[{decoration:[...B(),"wavy"]}],"text-decoration-thickness":[{decoration:["auto","from-font",E,O]}],"underline-offset":[{"underline-offset":["auto",E,I]}],"text-decoration-color":[{decoration:[e]}],"text-transform":["uppercase","lowercase","capitalize","normal-case"],"text-overflow":["truncate","text-ellipsis","text-clip"],"text-wrap":[{text:["wrap","nowrap","balance","pretty"]}],indent:[{indent:M()}],"vertical-align":[{align:["baseline","top","middle","bottom","text-top","text-bottom","sub","super",I]}],whitespace:[{whitespace:["normal","nowrap","pre","pre-line","pre-wrap","break-spaces"]}],break:[{break:["normal","words","all","keep"]}],hyphens:[{hyphens:["none","manual","auto"]}],content:[{content:["none",I]}],"bg-attachment":[{bg:["fixed","local","scroll"]}],"bg-clip":[{"bg-clip":["border","padding","content","text"]}],"bg-opacity":[{"bg-opacity":[f]}],"bg-origin":[{"bg-origin":["border","padding","content"]}],"bg-position":[{bg:[...W(),V]}],"bg-repeat":[{bg:["no-repeat",{repeat:["","x","y","round","space"]}]}],"bg-size":[{bg:["auto","cover","contain",T]}],"bg-image":[{bg:["none",{"gradient-to":["t","tr","r","br","b","bl","l","tl"]},F]}],"bg-color":[{bg:[e]}],"gradient-from-pos":[{from:[m]}],"gradient-via-pos":[{via:[m]}],"gradient-to-pos":[{to:[m]}],"gradient-from":[{from:[b]}],"gradient-via":[{via:[b]}],"gradient-to":[{to:[b]}],rounded:[{rounded:[l]}],"rounded-s":[{"rounded-s":[l]}],"rounded-e":[{"rounded-e":[l]}],"rounded-t":[{"rounded-t":[l]}],"rounded-r":[{"rounded-r":[l]}],"rounded-b":[{"rounded-b":[l]}],"rounded-l":[{"rounded-l":[l]}],"rounded-ss":[{"rounded-ss":[l]}],"rounded-se":[{"rounded-se":[l]}],"rounded-ee":[{"rounded-ee":[l]}],"rounded-es":[{"rounded-es":[l]}],"rounded-tl":[{"rounded-tl":[l]}],"rounded-tr":[{"rounded-tr":[l]}],"rounded-br":[{"rounded-br":[l]}],"rounded-bl":[{"rounded-bl":[l]}],"border-w":[{border:[a]}],"border-w-x":[{"border-x":[a]}],"border-w-y":[{"border-y":[a]}],"border-w-s":[{"border-s":[a]}],"border-w-e":[{"border-e":[a]}],"border-w-t":[{"border-t":[a]}],"border-w-r":[{"border-r":[a]}],"border-w-b":[{"border-b":[a]}],"border-w-l":[{"border-l":[a]}],"border-opacity":[{"border-opacity":[f]}],"border-style":[{border:[...B(),"hidden"]}],"divide-x":[{"divide-x":[a]}],"divide-x-reverse":["divide-x-reverse"],"divide-y":[{"divide-y":[a]}],"divide-y-reverse":["divide-y-reverse"],"divide-opacity":[{"divide-opacity":[f]}],"divide-style":[{divide:B()}],"border-color":[{border:[n]}],"border-color-x":[{"border-x":[n]}],"border-color-y":[{"border-y":[n]}],"border-color-s":[{"border-s":[n]}],"border-color-e":[{"border-e":[n]}],"border-color-t":[{"border-t":[n]}],"border-color-r":[{"border-r":[n]}],"border-color-b":[{"border-b":[n]}],"border-color-l":[{"border-l":[n]}],"divide-color":[{divide:[n]}],"outline-style":[{outline:["",...B()]}],"outline-offset":[{"outline-offset":[E,I]}],"outline-w":[{outline:[E,O]}],"outline-color":[{outline:[e]}],"ring-w":[{ring:Q()}],"ring-w-inset":["ring-inset"],"ring-color":[{ring:[e]}],"ring-opacity":[{"ring-opacity":[f]}],"ring-offset-w":[{"ring-offset":[E,O]}],"ring-offset-color":[{"ring-offset":[e]}],shadow:[{shadow:["","inner","none",$,U]}],"shadow-color":[{shadow:[D]}],opacity:[{opacity:[f]}],"mix-blend":[{"mix-blend":[...Z(),"plus-lighter","plus-darker"]}],"bg-blend":[{"bg-blend":Z()}],filter:[{filter:["","none"]}],blur:[{blur:[t]}],brightness:[{brightness:[o]}],contrast:[{contrast:[i]}],"drop-shadow":[{"drop-shadow":["","none",$,I]}],grayscale:[{grayscale:[c]}],"hue-rotate":[{"hue-rotate":[d]}],invert:[{invert:[p]}],saturate:[{saturate:[v]}],sepia:[{sepia:[w]}],"backdrop-filter":[{"backdrop-filter":["","none"]}],"backdrop-blur":[{"backdrop-blur":[t]}],"backdrop-brightness":[{"backdrop-brightness":[o]}],"backdrop-contrast":[{"backdrop-contrast":[i]}],"backdrop-grayscale":[{"backdrop-grayscale":[c]}],"backdrop-hue-rotate":[{"backdrop-hue-rotate":[d]}],"backdrop-invert":[{"backdrop-invert":[p]}],"backdrop-opacity":[{"backdrop-opacity":[f]}],"backdrop-saturate":[{"backdrop-saturate":[v]}],"backdrop-sepia":[{"backdrop-sepia":[w]}],"border-collapse":[{border:["collapse","separate"]}],"border-spacing":[{"border-spacing":[s]}],"border-spacing-x":[{"border-spacing-x":[s]}],"border-spacing-y":[{"border-spacing-y":[s]}],"table-layout":[{table:["auto","fixed"]}],caption:[{caption:["top","bottom"]}],transition:[{transition:["none","all","","colors","opacity","shadow","transform",I]}],duration:[{duration:J()}],ease:[{ease:["linear","in","out","in-out",I]}],delay:[{delay:J()}],animate:[{animate:["none","spin","ping","pulse","bounce",I]}],transform:[{transform:["","gpu","none"]}],scale:[{scale:[y]}],"scale-x":[{"scale-x":[y]}],"scale-y":[{"scale-y":[y]}],rotate:[{rotate:[R,I]}],"translate-x":[{"translate-x":[z]}],"translate-y":[{"translate-y":[z]}],"skew-x":[{"skew-x":[k]}],"skew-y":[{"skew-y":[k]}],"transform-origin":[{origin:["center","top","top-right","right","bottom-right","bottom","bottom-left","left","top-left",I]}],accent:[{accent:["auto",e]}],appearance:[{appearance:["none","auto"]}],cursor:[{cursor:["auto","default","pointer","wait","text","move","help","not-allowed","none","context-menu","progress","cell","crosshair","vertical-text","alias","copy","no-drop","grab","grabbing","all-scroll","col-resize","row-resize","n-resize","e-resize","s-resize","w-resize","ne-resize","nw-resize","se-resize","sw-resize","ew-resize","ns-resize","nesw-resize","nwse-resize","zoom-in","zoom-out",I]}],"caret-color":[{caret:[e]}],"pointer-events":[{"pointer-events":["none","auto"]}],resize:[{resize:["none","y","x",""]}],"scroll-behavior":[{scroll:["auto","smooth"]}],"scroll-m":[{"scroll-m":M()}],"scroll-mx":[{"scroll-mx":M()}],"scroll-my":[{"scroll-my":M()}],"scroll-ms":[{"scroll-ms":M()}],"scroll-me":[{"scroll-me":M()}],"scroll-mt":[{"scroll-mt":M()}],"scroll-mr":[{"scroll-mr":M()}],"scroll-mb":[{"scroll-mb":M()}],"scroll-ml":[{"scroll-ml":M()}],"scroll-p":[{"scroll-p":M()}],"scroll-px":[{"scroll-px":M()}],"scroll-py":[{"scroll-py":M()}],"scroll-ps":[{"scroll-ps":M()}],"scroll-pe":[{"scroll-pe":M()}],"scroll-pt":[{"scroll-pt":M()}],"scroll-pr":[{"scroll-pr":M()}],"scroll-pb":[{"scroll-pb":M()}],"scroll-pl":[{"scroll-pl":M()}],"snap-align":[{snap:["start","end","center","align-none"]}],"snap-stop":[{snap:["normal","always"]}],"snap-type":[{snap:["none","x","y","both"]}],"snap-strictness":[{snap:["mandatory","proximity"]}],touch:[{touch:["auto","none","manipulation"]}],"touch-x":[{"touch-pan":["x","left","right"]}],"touch-y":[{"touch-pan":["y","up","down"]}],"touch-pz":["touch-pinch-zoom"],select:[{select:["none","text","all","auto"]}],"will-change":[{"will-change":["auto","scroll","contents","transform",I]}],fill:[{fill:[e,"none"]}],"stroke-w":[{stroke:[E,O,P]}],stroke:[{stroke:[e,"none"]}],sr:["sr-only","not-sr-only"],"forced-color-adjust":[{"forced-color-adjust":["auto","none"]}]},conflictingClassGroups:{overflow:["overflow-x","overflow-y"],overscroll:["overscroll-x","overscroll-y"],inset:["inset-x","inset-y","start","end","top","right","bottom","left"],"inset-x":["right","left"],"inset-y":["top","bottom"],flex:["basis","grow","shrink"],gap:["gap-x","gap-y"],p:["px","py","ps","pe","pt","pr","pb","pl"],px:["pr","pl"],py:["pt","pb"],m:["mx","my","ms","me","mt","mr","mb","ml"],mx:["mr","ml"],my:["mt","mb"],size:["w","h"],"font-size":["leading"],"fvn-normal":["fvn-ordinal","fvn-slashed-zero","fvn-figure","fvn-spacing","fvn-fraction"],"fvn-ordinal":["fvn-normal"],"fvn-slashed-zero":["fvn-normal"],"fvn-figure":["fvn-normal"],"fvn-spacing":["fvn-normal"],"fvn-fraction":["fvn-normal"],"line-clamp":["display","overflow"],rounded:["rounded-s","rounded-e","rounded-t","rounded-r","rounded-b","rounded-l","rounded-ss","rounded-se","rounded-ee","rounded-es","rounded-tl","rounded-tr","rounded-br","rounded-bl"],"rounded-s":["rounded-ss","rounded-es"],"rounded-e":["rounded-se","rounded-ee"],"rounded-t":["rounded-tl","rounded-tr"],"rounded-r":["rounded-tr","rounded-br"],"rounded-b":["rounded-br","rounded-bl"],"rounded-l":["rounded-tl","rounded-bl"],"border-spacing":["border-spacing-x","border-spacing-y"],"border-w":["border-w-s","border-w-e","border-w-t","border-w-r","border-w-b","border-w-l"],"border-w-x":["border-w-r","border-w-l"],"border-w-y":["border-w-t","border-w-b"],"border-color":["border-color-s","border-color-e","border-color-t","border-color-r","border-color-b","border-color-l"],"border-color-x":["border-color-r","border-color-l"],"border-color-y":["border-color-t","border-color-b"],"scroll-m":["scroll-mx","scroll-my","scroll-ms","scroll-me","scroll-mt","scroll-mr","scroll-mb","scroll-ml"],"scroll-mx":["scroll-mr","scroll-ml"],"scroll-my":["scroll-mt","scroll-mb"],"scroll-p":["scroll-px","scroll-py","scroll-ps","scroll-pe","scroll-pt","scroll-pr","scroll-pb","scroll-pl"],"scroll-px":["scroll-pr","scroll-pl"],"scroll-py":["scroll-pt","scroll-pb"],touch:["touch-x","touch-y","touch-pz"],"touch-x":["touch"],"touch-y":["touch"],"touch-pz":["touch"]},conflictingClassGroupModifiers:{"font-size":["leading"]}}}),ee=(r="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",t={variants:{variant:{default:"bg-primary text-primary-foreground hover:bg-primary/90",destructive:"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",outline:"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",secondary:"bg-secondary text-secondary-foreground hover:bg-secondary/80",ghost:"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",link:"text-primary underline-offset-4 hover:underline"},size:{default:"h-9 px-4 py-2 has-[>svg]:px-3",sm:"h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",lg:"h-10 rounded-md px-6 has-[>svg]:px-4",icon:"size-9","icon-sm":"size-8","icon-lg":"size-10"}},defaultVariants:{variant:"default",size:"default"}},e=>{var o;if((null==t?void 0:t.variants)==null)return u(r,null==e?void 0:e.class,null==e?void 0:e.className);let{variants:n,defaultVariants:l}=t,s=Object.keys(n).map(r=>{let t=null==e?void 0:e[r],o=null==l?void 0:l[r];if(null===t)return null;let s=b(t)||b(o);return n[r][s]}),a=e&&Object.entries(e).reduce((e,r)=>{let[t,o]=r;return void 0===o||(e[t]=o),e},{});return u(r,s,null==t||null==(o=t.compoundVariants)?void 0:o.reduce((e,r)=>{let{class:t,className:o,...n}=r;return Object.entries(n).every(e=>{let[r,t]=e;return Array.isArray(t)?t.includes({...l,...a}[r]):({...l,...a})[r]===t})?[...e,t,o]:e},[]),null==e?void 0:e.class,null==e?void 0:e.className)});function er({className:e,variant:r,size:t,asChild:o=!1,...n}){let s=o?i:"button";return(0,l.jsx)(s,{"data-slot":"button",className:function(...e){return X(u(e))}(ee({variant:r,size:t,className:e})),...n})}function et({size:e="large"}){return(0,l.jsxs)("svg",{viewBox:"0 0 180 240",fill:"none",xmlns:"http://www.w3.org/2000/svg",className:`drop-shadow-sm ${"large"===e?"w-32 h-40 sm:w-40 sm:h-52 md:w-44 md:h-56":"w-16 h-20 sm:w-20 sm:h-24"}`,children:[(0,l.jsx)("path",{d:"M 70 85 Q 52 58, 58 28 Q 62 15, 68 18 Q 75 32, 80 55 Q 83 70, 82 85 Z",stroke:"#3A3A3A",strokeWidth:"0.7",fill:"rgba(255, 255, 255, 0.85)",strokeLinecap:"round",strokeLinejoin:"round",opacity:"0.95"}),(0,l.jsx)("path",{d:"M 82 85 Q 85 45, 88 20 Q 89 8, 92 12 Q 95 35, 93 65 Q 92 78, 93 85 Z",stroke:"#3A3A3A",strokeWidth:"0.7",fill:"rgba(255, 255, 255, 0.9)",strokeLinecap:"round",strokeLinejoin:"round",opacity:"0.95"}),(0,l.jsx)("path",{d:"M 93 85 Q 90 55, 92 35 Q 96 20, 102 25 Q 110 42, 115 60 Q 118 75, 108 85 Z",stroke:"#3A3A3A",strokeWidth:"0.7",fill:"rgba(255, 255, 255, 0.85)",strokeLinecap:"round",strokeLinejoin:"round",opacity:"0.95"}),(0,l.jsx)("path",{d:"M 72 75 Q 74 52, 76 32",stroke:"#3A3A3A",strokeWidth:"0.4",fill:"none",opacity:"0.25",strokeLinecap:"round"}),(0,l.jsx)("path",{d:"M 88 78 Q 89 53, 90 28",stroke:"#3A3A3A",strokeWidth:"0.4",fill:"none",opacity:"0.25",strokeLinecap:"round"}),(0,l.jsx)("path",{d:"M 104 75 Q 102 52, 100 32",stroke:"#3A3A3A",strokeWidth:"0.4",fill:"none",opacity:"0.25",strokeLinecap:"round"}),(0,l.jsx)("path",{d:"M 68 70 Q 70 55, 72 40",stroke:"#3A3A3A",strokeWidth:"0.3",fill:"none",opacity:"0.2",strokeLinecap:"round"}),(0,l.jsx)("path",{d:"M 108 70 Q 106 55, 104 40",stroke:"#3A3A3A",strokeWidth:"0.3",fill:"none",opacity:"0.2",strokeLinecap:"round"}),(0,l.jsx)("path",{d:"M 90 85 Q 88 125, 86 165 Q 85 185, 86 220",stroke:"#3A3A3A",strokeWidth:"1.2",fill:"none",strokeLinecap:"round",opacity:"0.9"}),(0,l.jsx)("path",{d:"M 86 145 Q 65 148, 50 156 Q 45 159, 47 162 Q 54 164, 72 160 Q 83 156, 86 152",stroke:"#3A3A3A",strokeWidth:"0.7",fill:"rgba(255, 255, 255, 0.7)",strokeLinecap:"round",strokeLinejoin:"round",opacity:"0.9"}),(0,l.jsx)("path",{d:"M 86 148 Q 72 152, 58 158",stroke:"#3A3A3A",strokeWidth:"0.4",fill:"none",opacity:"0.3",strokeLinecap:"round"}),(0,l.jsx)("path",{d:"M 86 185 Q 107 188, 122 196 Q 127 199, 125 202 Q 118 204, 100 200 Q 89 196, 86 192",stroke:"#3A3A3A",strokeWidth:"0.7",fill:"rgba(255, 255, 255, 0.7)",strokeLinecap:"round",strokeLinejoin:"round",opacity:"0.9"}),(0,l.jsx)("path",{d:"M 86 188 Q 100 192, 114 198",stroke:"#3A3A3A",strokeWidth:"0.4",fill:"none",opacity:"0.3",strokeLinecap:"round"})]})}let eo=(...e)=>e.filter((e,r,t)=>!!e&&""!==e.trim()&&t.indexOf(e)===r).join(" ").trim();var en={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};let el=(0,s.forwardRef)(({color:e="currentColor",size:r=24,strokeWidth:t=2,absoluteStrokeWidth:o,className:n="",children:l,iconNode:a,...i},c)=>(0,s.createElement)("svg",{ref:c,...en,width:r,height:r,stroke:e,strokeWidth:o?24*Number(t)/Number(r):t,className:eo("lucide",n),...i},[...a.map(([e,r])=>(0,s.createElement)(e,r)),...Array.isArray(l)?l:[l]])),es=(n=[["path",{d:"M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z",key:"131961"}],["path",{d:"M19 10v2a7 7 0 0 1-14 0v-2",key:"1vc78b"}],["line",{x1:"12",x2:"12",y1:"19",y2:"22",key:"x3vr5v"}]],(o=(0,s.forwardRef)(({className:e,...r},t)=>(0,s.createElement)(el,{ref:t,iconNode:n,className:eo(`lucide-${"Mic".replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}`,e),...r}))).displayName="Mic",o);function ea(){(0,s.useEffect)(()=>{let r=new URLSearchParams(window.location.search),t=r.get("code"),o=r.get("state"),n=r.get("error");if(n){console.error("❌ OAuth 錯誤:",n),alert(`Google 登入失敗: ${n}`),window.history.replaceState({},"",window.location.pathname);return}t&&o&&(console.log("🔍 檢測到 OAuth callback,處理授權碼..."),e(t,o))},[]);let e=async(e,r)=>{try{let t=sessionStorage.getItem("oauth_state"),o=sessionStorage.getItem("oauth_code_verifier");if(console.log("🔐 驗證 state 參數..."),r!==t)throw Error("State 參數不匹配,可能存在 CSRF 攻擊");console.log("📤 發送授權碼到後端...");let n=await fetch("/auth/google/callback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:e,state:r,code_verifier:o})}),l=await n.json();if(l.success)console.log("✅ 登入成功!"),localStorage.setItem("jwt_token",l.access_token),sessionStorage.removeItem("oauth_state"),sessionStorage.removeItem("oauth_code_verifier"),window.history.replaceState({},"",window.location.pathname),window.location.href="/static/";else throw Error(l.error||"登入失敗")}catch(e){console.error("❌ OAuth callback 處理失敗:",e),alert(`登入處理失敗: ${e}`),window.history.replaceState({},"",window.location.pathname)}},r=async()=>{try{console.log("🚀 開始 Google OAuth 登入流程...");let e=await fetch("/auth/google/url"),r=await e.json();if(!r.success)throw Error(r.error||"獲取授權 URL 失敗");console.log("✅ 獲取授權 URL 成功"),sessionStorage.setItem("oauth_state",r.state),sessionStorage.setItem("oauth_code_verifier",r.code_verifier),console.log("🔐 PKCE 參數已存儲"),console.log("🌐 重定向到 Google 授權頁面..."),window.location.href=r.auth_url}catch(e){console.error("❌ OAuth 初始化失敗:",e),alert("Google 登入初始化失敗,請稍後再試")}};return(0,l.jsxs)("div",{className:"flex flex-col items-center space-y-6 sm:space-y-8",children:[(0,l.jsxs)("div",{className:"text-center space-y-1 sm:space-y-2",children:[(0,l.jsx)("h1",{className:"font-serif text-4xl sm:text-5xl md:text-6xl text-[#2C2C2C] tracking-wide text-balance",children:"Bloom Ware"}),(0,l.jsx)("p",{className:"text-[#4A4A4A] text-xs sm:text-sm tracking-widest uppercase",children:"MADE BY 槓上開發"})]}),(0,l.jsx)("div",{className:"my-4 sm:my-6 md:my-8",children:(0,l.jsx)(et,{size:"large"})}),(0,l.jsxs)("div",{className:"w-full space-y-3 sm:space-y-4",children:[(0,l.jsxs)(er,{onClick:r,className:"w-full h-11 sm:h-12 bg-white hover:bg-gray-50 text-[#2C2C2C] shadow-md hover:shadow-lg transition-all duration-200 rounded-lg border border-gray-200 text-sm sm:text-base",variant:"outline",children:[(0,l.jsxs)("svg",{className:"w-4 h-4 sm:w-5 sm:h-5 mr-2 sm:mr-3",viewBox:"0 0 24 24",children:[(0,l.jsx)("path",{fill:"#4285F4",d:"M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"}),(0,l.jsx)("path",{fill:"#34A853",d:"M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"}),(0,l.jsx)("path",{fill:"#FBBC05",d:"M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"}),(0,l.jsx)("path",{fill:"#EA4335",d:"M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"})]}),(0,l.jsx)("span",{className:"font-medium",children:"Continue with Google"})]}),(0,l.jsxs)(er,{onClick:()=>{console.log("🎤 開始語音登入..."),localStorage.setItem("jwt_token","anonymous_voice_login"),window.location.href="/static/"},className:"w-full h-11 sm:h-12 bg-white hover:bg-gray-50 text-[#2C2C2C] shadow-md hover:shadow-lg transition-all duration-200 rounded-lg border border-gray-200 text-sm sm:text-base",variant:"outline",children:[(0,l.jsx)(es,{className:"w-4 h-4 sm:w-5 sm:h-5 mr-2 sm:mr-3"}),(0,l.jsx)("span",{className:"font-medium",children:"Voice Login"})]})]}),(0,l.jsx)("p",{className:"text-[#4A4A4A] text-[10px] sm:text-xs text-center mt-6 sm:mt-8 max-w-xs text-balance px-4",children:"By continuing, you agree to our Terms of Service and Privacy Policy"})]})}e.s(["LoginForm",()=>ea],17994)}]);
|
static/frontend/login/_next/static/chunks/a6dad97d9634a72d.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/frontend/login/_next/static/chunks/c518a733bdcf3dee.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,12718,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"InvariantError",{enumerable:!0,get:function(){return n}});class n extends Error{constructor(e,t){super(`Invariant: ${e.endsWith(".")?e:e+"."} This is a bug in Next.js.`,t),this.name="InvariantError"}}},55682,(e,t,r)=>{"use strict";r._=function(e){return e&&e.__esModule?e:{default:e}}},32061,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={BailoutToCSRError:function(){return a},isBailoutToCSRError:function(){return i}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u="BAILOUT_TO_CLIENT_SIDE_RENDERING";class a extends Error{constructor(e){super(`Bail out to client-side rendering: ${e}`),this.reason=e,this.digest=u}}function i(e){return"object"==typeof e&&null!==e&&"digest"in e&&e.digest===u}},18800,(e,t,r)=>{"use strict";var n=e.r(71645);function o(e){var t="https://react.dev/errors/"+e;if(1<arguments.length){t+="?args[]="+encodeURIComponent(arguments[1]);for(var r=2;r<arguments.length;r++)t+="&args[]="+encodeURIComponent(arguments[r])}return"Minified React error #"+e+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function u(){}var a={d:{f:u,r:function(){throw Error(o(522))},D:u,C:u,L:u,m:u,X:u,S:u,M:u},p:0,findDOMNode:null},i=Symbol.for("react.portal"),s=n.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function l(e,t){return"font"===e?"":"string"==typeof t?"use-credentials"===t?t:"":void 0}r.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=a,r.createPortal=function(e,t){var r=2<arguments.length&&void 0!==arguments[2]?arguments[2]:null;if(!t||1!==t.nodeType&&9!==t.nodeType&&11!==t.nodeType)throw Error(o(299));return function(e,t,r){var n=3<arguments.length&&void 0!==arguments[3]?arguments[3]:null;return{$$typeof:i,key:null==n?null:""+n,children:e,containerInfo:t,implementation:r}}(e,t,null,r)},r.flushSync=function(e){var t=s.T,r=a.p;try{if(s.T=null,a.p=2,e)return e()}finally{s.T=t,a.p=r,a.d.f()}},r.preconnect=function(e,t){"string"==typeof e&&(t=t?"string"==typeof(t=t.crossOrigin)?"use-credentials"===t?t:"":void 0:null,a.d.C(e,t))},r.prefetchDNS=function(e){"string"==typeof e&&a.d.D(e)},r.preinit=function(e,t){if("string"==typeof e&&t&&"string"==typeof t.as){var r=t.as,n=l(r,t.crossOrigin),o="string"==typeof t.integrity?t.integrity:void 0,u="string"==typeof t.fetchPriority?t.fetchPriority:void 0;"style"===r?a.d.S(e,"string"==typeof t.precedence?t.precedence:void 0,{crossOrigin:n,integrity:o,fetchPriority:u}):"script"===r&&a.d.X(e,{crossOrigin:n,integrity:o,fetchPriority:u,nonce:"string"==typeof t.nonce?t.nonce:void 0})}},r.preinitModule=function(e,t){if("string"==typeof e)if("object"==typeof t&&null!==t){if(null==t.as||"script"===t.as){var r=l(t.as,t.crossOrigin);a.d.M(e,{crossOrigin:r,integrity:"string"==typeof t.integrity?t.integrity:void 0,nonce:"string"==typeof t.nonce?t.nonce:void 0})}}else null==t&&a.d.M(e)},r.preload=function(e,t){if("string"==typeof e&&"object"==typeof t&&null!==t&&"string"==typeof t.as){var r=t.as,n=l(r,t.crossOrigin);a.d.L(e,r,{crossOrigin:n,integrity:"string"==typeof t.integrity?t.integrity:void 0,nonce:"string"==typeof t.nonce?t.nonce:void 0,type:"string"==typeof t.type?t.type:void 0,fetchPriority:"string"==typeof t.fetchPriority?t.fetchPriority:void 0,referrerPolicy:"string"==typeof t.referrerPolicy?t.referrerPolicy:void 0,imageSrcSet:"string"==typeof t.imageSrcSet?t.imageSrcSet:void 0,imageSizes:"string"==typeof t.imageSizes?t.imageSizes:void 0,media:"string"==typeof t.media?t.media:void 0})}},r.preloadModule=function(e,t){if("string"==typeof e)if(t){var r=l(t.as,t.crossOrigin);a.d.m(e,{as:"string"==typeof t.as&&"script"!==t.as?t.as:void 0,crossOrigin:r,integrity:"string"==typeof t.integrity?t.integrity:void 0})}else a.d.m(e)},r.requestFormReset=function(e){a.d.r(e)},r.unstable_batchedUpdates=function(e,t){return e(t)},r.useFormState=function(e,t,r){return s.H.useFormState(e,t,r)},r.useFormStatus=function(){return s.H.useHostTransitionStatus()},r.version="19.3.0-canary-2bcbf254-20251020"},74080,(e,t,r)=>{"use strict";!function e(){if("undefined"!=typeof __REACT_DEVTOOLS_GLOBAL_HOOK__&&"function"==typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE)try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(e){console.error(e)}}(),t.exports=e.r(18800)},64893,(e,t,r)=>{"use strict";var n=e.r(74080),o={stream:!0};function u(t){var r=e.r(t);return"function"!=typeof r.then||"fulfilled"===r.status?null:(r.then(function(e){r.status="fulfilled",r.value=e},function(e){r.status="rejected",r.reason=e}),r)}var a=new WeakSet,i=new WeakSet;function s(){}function l(t){for(var r=t[1],n=[],o=0;o<r.length;o++){var l=e.L(r[o]);if(i.has(l)||n.push(l),!a.has(l)){var c=i.add.bind(i,l);l.then(c,s),a.add(l)}}return 4===t.length?0===n.length?u(t[0]):Promise.all(n).then(function(){return u(t[0])}):0<n.length?Promise.all(n):null}function c(t){var r=e.r(t[0]);if(4===t.length&&"function"==typeof r.then)if("fulfilled"===r.status)r=r.value;else throw r.reason;return"*"===t[2]?r:""===t[2]?r.__esModule?r.default:r:r[t[2]]}var f=n.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,d=Symbol.for("react.transitional.element"),p=Symbol.for("react.lazy"),_=Symbol.iterator,h=Symbol.asyncIterator,y=Array.isArray,b=Object.getPrototypeOf,g=Object.prototype,v=new WeakMap;function m(e,t,r){v.has(e)||v.set(e,{id:t,originalBind:e.bind,bound:r})}function E(e,t,r){this.status=e,this.value=t,this.reason=r}function R(e){switch(e.status){case"resolved_model":C(e);break;case"resolved_module":x(e)}switch(e.status){case"fulfilled":return e.value;case"pending":case"blocked":case"halted":throw e;default:throw e.reason}}function O(e,t){for(var r=0;r<e.length;r++){var n=e[r];"function"==typeof n?n(t):L(n,t)}}function S(e,t){for(var r=0;r<e.length;r++){var n=e[r];"function"==typeof n?n(t):$(n,t)}}function P(e,t){var r=t.handler.chunk;if(null===r)return null;if(r===e)return t.handler;if(null!==(t=r.value))for(r=0;r<t.length;r++){var n=t[r];if("function"!=typeof n&&null!==(n=P(e,n)))return n}return null}function T(e,t,r){switch(e.status){case"fulfilled":O(t,e.value);break;case"blocked":for(var n=0;n<t.length;n++){var o=t[n];if("function"!=typeof o){var u=P(e,o);null!==u&&(L(o,u.value),t.splice(n,1),n--,null!==r&&-1!==(o=r.indexOf(o))&&r.splice(o,1))}}case"pending":if(e.value)for(n=0;n<t.length;n++)e.value.push(t[n]);else e.value=t;if(e.reason){if(r)for(t=0;t<r.length;t++)e.reason.push(r[t])}else e.reason=r;break;case"rejected":r&&S(r,e.reason)}}function j(e,t,r){"pending"!==t.status&&"blocked"!==t.status?t.reason.error(r):(e=t.reason,t.status="rejected",t.reason=r,null!==e&&S(e,r))}function w(e,t,r){return new E("resolved_model",(r?'{"done":true,"value":':'{"done":false,"value":')+t+"}",e)}function A(e,t,r,n){M(e,t,(n?'{"done":true,"value":':'{"done":false,"value":')+r+"}")}function M(e,t,r){if("pending"!==t.status)t.reason.enqueueModel(r);else{var n=t.value,o=t.reason;t.status="resolved_model",t.value=r,t.reason=e,null!==n&&(C(t),T(t,n,o))}}function D(e,t,r){if("pending"===t.status||"blocked"===t.status){e=t.value;var n=t.reason;t.status="resolved_module",t.value=r,null!==e&&(x(t),T(t,e,n))}}E.prototype=Object.create(Promise.prototype),E.prototype.then=function(e,t){switch(this.status){case"resolved_model":C(this);break;case"resolved_module":x(this)}switch(this.status){case"fulfilled":"function"==typeof e&&e(this.value);break;case"pending":case"blocked":"function"==typeof e&&(null===this.value&&(this.value=[]),this.value.push(e)),"function"==typeof t&&(null===this.reason&&(this.reason=[]),this.reason.push(t));break;case"halted":break;default:"function"==typeof t&&t(this.reason)}};var N=null;function C(e){var t=N;N=null;var r=e.value,n=e.reason;e.status="blocked",e.value=null,e.reason=null;try{var o=JSON.parse(r,n._fromJSON),u=e.value;if(null!==u)for(e.value=null,e.reason=null,r=0;r<u.length;r++){var a=u[r];"function"==typeof a?a(o):L(a,o,e)}if(null!==N){if(N.errored)throw N.reason;if(0<N.deps){N.value=o,N.chunk=e;return}}e.status="fulfilled",e.value=o}catch(t){e.status="rejected",e.reason=t}finally{N=t}}function x(e){try{var t=c(e.value);e.status="fulfilled",e.value=t}catch(t){e.status="rejected",e.reason=t}}function k(e,t){e._closed=!0,e._closedReason=t,e._chunks.forEach(function(r){"pending"===r.status&&j(e,r,t)})}function U(e){return{$$typeof:p,_payload:e,_init:R}}function I(e,t){var r=e._chunks,n=r.get(t);return n||(n=e._closed?new E("rejected",null,e._closedReason):new E("pending",null,null),r.set(t,n)),n}function L(e,t){for(var r=e.response,n=e.handler,o=e.parentObject,u=e.key,a=e.map,i=e.path,s=1;s<i.length;s++){for(;"object"==typeof t&&null!==t&&t.$$typeof===p;)if((t=t._payload)===n.chunk)t=n.value;else{switch(t.status){case"resolved_model":C(t);break;case"resolved_module":x(t)}switch(t.status){case"fulfilled":t=t.value;continue;case"blocked":var l=P(t,e);if(null!==l){t=l.value;continue}case"pending":i.splice(0,s-1),null===t.value?t.value=[e]:t.value.push(e),null===t.reason?t.reason=[e]:t.reason.push(e);return;case"halted":return;default:$(e,t.reason);return}}t=t[i[s]]}for(;"object"==typeof t&&null!==t&&t.$$typeof===p;)if((e=t._payload)===n.chunk)t=n.value;else{switch(e.status){case"resolved_model":C(e);break;case"resolved_module":x(e)}if("fulfilled"===e.status){t=e.value;continue}break}r=a(r,t,o,u),o[u]=r,""===u&&null===n.value&&(n.value=r),o[0]===d&&"object"==typeof n.value&&null!==n.value&&n.value.$$typeof===d&&(o=n.value,"3"===u)&&(o.props=r),n.deps--,0===n.deps&&null!==(u=n.chunk)&&"blocked"===u.status&&(o=u.value,u.status="fulfilled",u.value=n.value,u.reason=n.reason,null!==o&&O(o,n.value))}function $(e,t){var r=e.handler;e=e.response,r.errored||(r.errored=!0,r.value=null,r.reason=t,null!==(r=r.chunk)&&"blocked"===r.status&&j(e,r,t))}function F(e,t,r,n,o,u){if(N){var a=N;a.deps++}else a=N={parent:null,chunk:null,value:null,reason:null,deps:1,errored:!1};return t={response:n,handler:a,parentObject:t,key:r,map:o,path:u},null===e.value?e.value=[t]:e.value.push(t),null===e.reason?e.reason=[t]:e.reason.push(t),null}function H(e,t,r,n){if(!e._serverReferenceConfig)return function(e,t){function r(){var e=Array.prototype.slice.call(arguments);return o?"fulfilled"===o.status?t(n,o.value.concat(e)):Promise.resolve(o).then(function(r){return t(n,r.concat(e))}):t(n,e)}var n=e.id,o=e.bound;return m(r,n,o),r}(t,e._callServer);var o=function(e,t){var r="",n=e[t];if(n)r=n.name;else{var o=t.lastIndexOf("#");if(-1!==o&&(r=t.slice(o+1),n=e[t.slice(0,o)]),!n)throw Error('Could not find the module "'+t+'" in the React Server Manifest. This is probably a bug in the React Server Components bundler.')}return n.async?[n.id,n.chunks,r,1]:[n.id,n.chunks,r]}(e._serverReferenceConfig,t.id),u=l(o);if(u)t.bound&&(u=Promise.all([u,t.bound]));else{if(!t.bound)return m(u=c(o),t.id,t.bound),u;u=Promise.resolve(t.bound)}if(N){var a=N;a.deps++}else a=N={parent:null,chunk:null,value:null,reason:null,deps:1,errored:!1};return u.then(function(){var e=c(o);if(t.bound){var u=t.bound.value.slice(0);u.unshift(null),e=e.bind.apply(e,u)}m(e,t.id,t.bound),r[n]=e,""===n&&null===a.value&&(a.value=e),r[0]===d&&"object"==typeof a.value&&null!==a.value&&a.value.$$typeof===d&&(u=a.value,"3"===n)&&(u.props=e),a.deps--,0===a.deps&&null!==(e=a.chunk)&&"blocked"===e.status&&(u=e.value,e.status="fulfilled",e.value=a.value,null!==u&&O(u,a.value))},function(t){if(!a.errored){a.errored=!0,a.value=null,a.reason=t;var r=a.chunk;null!==r&&"blocked"===r.status&&j(e,r,t)}}),null}function B(e,t,r,n,o){var u=parseInt((t=t.split(":"))[0],16);switch((u=I(e,u)).status){case"resolved_model":C(u);break;case"resolved_module":x(u)}switch(u.status){case"fulfilled":u=u.value;for(var a=1;a<t.length;a++){for(;"object"==typeof u&&null!==u&&u.$$typeof===p;){switch((u=u._payload).status){case"resolved_model":C(u);break;case"resolved_module":x(u)}switch(u.status){case"fulfilled":u=u.value;break;case"blocked":case"pending":return F(u,r,n,e,o,t.slice(a-1));case"halted":return N?(e=N,e.deps++):N={parent:null,chunk:null,value:null,reason:null,deps:1,errored:!1},null;default:return N?(N.errored=!0,N.value=null,N.reason=u.reason):N={parent:null,chunk:null,value:null,reason:u.reason,deps:0,errored:!0},null}}u=u[t[a]]}for(;"object"==typeof u&&null!==u&&u.$$typeof===p;){switch((t=u._payload).status){case"resolved_model":C(t);break;case"resolved_module":x(t)}if("fulfilled"===t.status){u=t.value;continue}break}return o(e,u,r,n);case"pending":case"blocked":return F(u,r,n,e,o,t);case"halted":return N?(e=N,e.deps++):N={parent:null,chunk:null,value:null,reason:null,deps:1,errored:!1},null;default:return N?(N.errored=!0,N.value=null,N.reason=u.reason):N={parent:null,chunk:null,value:null,reason:u.reason,deps:0,errored:!0},null}}function X(e,t){return new Map(t)}function W(e,t){return new Set(t)}function G(e,t){return new Blob(t.slice(1),{type:t[0]})}function Y(e,t){e=new FormData;for(var r=0;r<t.length;r++)e.append(t[r][0],t[r][1]);return e}function q(e,t){return t[Symbol.iterator]()}function K(e,t){return t}function z(){throw Error('Trying to call a function from "use server" but the callServer option was not implemented in your router runtime.')}function V(e,t,r,n,o,u,a){var i,s=new Map;this._bundlerConfig=e,this._serverReferenceConfig=t,this._moduleLoading=r,this._callServer=void 0!==n?n:z,this._encodeFormAction=o,this._nonce=u,this._chunks=s,this._stringDecoder=new TextDecoder,this._fromJSON=null,this._closed=!1,this._closedReason=null,this._tempRefs=a,this._fromJSON=(i=this,function(e,t){if("string"==typeof t){var r=i,n=this,o=e,u=t;if("$"===u[0]){if("$"===u)return null!==N&&"0"===o&&(N={parent:N,chunk:null,value:null,reason:null,deps:0,errored:!1}),d;switch(u[1]){case"$":return u.slice(1);case"L":return U(r=I(r,n=parseInt(u.slice(2),16)));case"@":return I(r,n=parseInt(u.slice(2),16));case"S":return Symbol.for(u.slice(2));case"F":return B(r,u=u.slice(2),n,o,H);case"T":if(n="$"+u.slice(2),null==(r=r._tempRefs))throw Error("Missing a temporary reference set but the RSC response returned a temporary reference. Pass a temporaryReference option with the set that was used with the reply.");return r.get(n);case"Q":return B(r,u=u.slice(2),n,o,X);case"W":return B(r,u=u.slice(2),n,o,W);case"B":return B(r,u=u.slice(2),n,o,G);case"K":return B(r,u=u.slice(2),n,o,Y);case"Z":return er();case"i":return B(r,u=u.slice(2),n,o,q);case"I":return 1/0;case"-":return"$-0"===u?-0:-1/0;case"N":return NaN;case"u":return;case"D":return new Date(Date.parse(u.slice(2)));case"n":return BigInt(u.slice(2));default:return B(r,u=u.slice(1),n,o,K)}}return u}if("object"==typeof t&&null!==t){if(t[0]===d){if(e={$$typeof:d,type:t[1],key:t[2],ref:null,props:t[3]},null!==N){if(N=(t=N).parent,t.errored)e=U(e=new E("rejected",null,t.reason));else if(0<t.deps){var a=new E("blocked",null,null);t.value=e,t.chunk=a,e=U(a)}}}else e=t;return e}return t})}function J(e,t,r){var n=(e=e._chunks).get(t);n&&"pending"!==n.status?n.reason.enqueueValue(r):(r=new E("fulfilled",r,null),e.set(t,r))}function Q(e,t,r,n){var o=(e=e._chunks).get(t);o?"pending"===o.status&&(t=o.value,o.status="fulfilled",o.value=r,o.reason=n,null!==t&&O(t,o.value)):(r=new E("fulfilled",r,n),e.set(t,r))}function Z(e,t,r){var n=null;r=new ReadableStream({type:r,start:function(e){n=e}});var o=null;Q(e,t,r,{enqueueValue:function(e){null===o?n.enqueue(e):o.then(function(){n.enqueue(e)})},enqueueModel:function(t){if(null===o){var r=new E("resolved_model",t,e);C(r),"fulfilled"===r.status?n.enqueue(r.value):(r.then(function(e){return n.enqueue(e)},function(e){return n.error(e)}),o=r)}else{r=o;var u=new E("pending",null,null);u.then(function(e){return n.enqueue(e)},function(e){return n.error(e)}),o=u,r.then(function(){o===u&&(o=null),M(e,u,t)})}},close:function(){if(null===o)n.close();else{var e=o;o=null,e.then(function(){return n.close()})}},error:function(e){if(null===o)n.error(e);else{var t=o;o=null,t.then(function(){return n.error(e)})}}})}function ee(){return this}function et(e,t,r){var n=[],o=!1,u=0,a={};a[h]=function(){var e,t=0;return(e={next:e=function(e){if(void 0!==e)throw Error("Values cannot be passed to next() of AsyncIterables passed to Client Components.");if(t===n.length){if(o)return new E("fulfilled",{done:!0,value:void 0},null);n[t]=new E("pending",null,null)}return n[t++]}})[h]=ee,e},Q(e,t,r?a[h]():a,{enqueueValue:function(e){if(u===n.length)n[u]=new E("fulfilled",{done:!1,value:e},null);else{var t=n[u],r=t.value,o=t.reason;t.status="fulfilled",t.value={done:!1,value:e},null!==r&&T(t,r,o)}u++},enqueueModel:function(t){u===n.length?n[u]=w(e,t,!1):A(e,n[u],t,!1),u++},close:function(t){for(o=!0,u===n.length?n[u]=w(e,t,!0):A(e,n[u],t,!0),u++;u<n.length;)A(e,n[u++],'"$undefined"',!0)},error:function(t){for(o=!0,u===n.length&&(n[u]=new E("pending",null,null));u<n.length;)j(e,n[u++],t)}})}function er(){var e=Error("An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.");return e.stack="Error: "+e.message,e}function en(e,t){for(var r=e.length,n=t.length,o=0;o<r;o++)n+=e[o].byteLength;n=new Uint8Array(n);for(var u=o=0;u<r;u++){var a=e[u];n.set(a,o),o+=a.byteLength}return n.set(t,o),n}function eo(e,t,r,n,o,u){J(e,t,o=new o((r=0===r.length&&0==n.byteOffset%u?n:en(r,n)).buffer,r.byteOffset,r.byteLength/u))}function eu(e){k(e,Error("Connection closed."))}function ea(e){return new V(null,null,null,e&&e.callServer?e.callServer:void 0,void 0,void 0,e&&e.temporaryReferences?e.temporaryReferences:void 0)}function ei(e,t,r){function n(t){k(e,t)}var u={_rowState:0,_rowID:0,_rowTag:0,_rowLength:0,_buffer:[]},a=t.getReader();a.read().then(function t(i){var s=i.value;if(i.done)return r();var c=0,d=u._rowState;i=u._rowID;for(var p=u._rowTag,_=u._rowLength,h=u._buffer,y=s.length;c<y;){var b=-1;switch(d){case 0:58===(b=s[c++])?d=1:i=i<<4|(96<b?b-87:b-48);continue;case 1:84===(d=s[c])||65===d||79===d||111===d||85===d||83===d||115===d||76===d||108===d||71===d||103===d||77===d||109===d||86===d?(p=d,d=2,c++):64<d&&91>d||35===d||114===d||120===d?(p=d,d=3,c++):(p=0,d=3);continue;case 2:44===(b=s[c++])?d=4:_=_<<4|(96<b?b-87:b-48);continue;case 3:b=s.indexOf(10,c);break;case 4:(b=c+_)>s.length&&(b=-1)}var g=s.byteOffset+c;if(-1<b)(function(e,t,r,n,u,a){switch(n){case 65:J(e,r,en(u,a).buffer);return;case 79:eo(e,r,u,a,Int8Array,1);return;case 111:J(e,r,0===u.length?a:en(u,a));return;case 85:eo(e,r,u,a,Uint8ClampedArray,1);return;case 83:eo(e,r,u,a,Int16Array,2);return;case 115:eo(e,r,u,a,Uint16Array,2);return;case 76:eo(e,r,u,a,Int32Array,4);return;case 108:eo(e,r,u,a,Uint32Array,4);return;case 71:eo(e,r,u,a,Float32Array,4);return;case 103:eo(e,r,u,a,Float64Array,8);return;case 77:eo(e,r,u,a,BigInt64Array,8);return;case 109:eo(e,r,u,a,BigUint64Array,8);return;case 86:eo(e,r,u,a,DataView,1);return}t=e._stringDecoder;for(var i="",s=0;s<u.length;s++)i+=t.decode(u[s],o);switch(u=i+=t.decode(a),n){case 73:var c=e,d=r,p=u,_=c._chunks,h=_.get(d);p=JSON.parse(p,c._fromJSON);var y=function(e,t){if(e){var r=e[t[0]];if(e=r&&r[t[2]])r=e.name;else{if(!(e=r&&r["*"]))throw Error('Could not find the module "'+t[0]+'" in the React Server Consumer Manifest. This is probably a bug in the React Server Components bundler.');r=t[2]}return 4===t.length?[e.id,e.chunks,r,1]:[e.id,e.chunks,r]}return t}(c._bundlerConfig,p);if(p=l(y)){if(h){var b=h;b.status="blocked"}else b=new E("blocked",null,null),_.set(d,b);p.then(function(){return D(c,b,y)},function(e){return j(c,b,e)})}else h?D(c,h,y):(h=new E("resolved_module",y,null),_.set(d,h));break;case 72:switch(r=u[0],e=JSON.parse(u=u.slice(1),e._fromJSON),u=f.d,r){case"D":u.D(e);break;case"C":"string"==typeof e?u.C(e):u.C(e[0],e[1]);break;case"L":r=e[0],n=e[1],3===e.length?u.L(r,n,e[2]):u.L(r,n);break;case"m":"string"==typeof e?u.m(e):u.m(e[0],e[1]);break;case"X":"string"==typeof e?u.X(e):u.X(e[0],e[1]);break;case"S":"string"==typeof e?u.S(e):u.S(e[0],0===e[1]?void 0:e[1],3===e.length?e[2]:void 0);break;case"M":"string"==typeof e?u.M(e):u.M(e[0],e[1])}break;case 69:a=(n=e._chunks).get(r),u=JSON.parse(u),(t=er()).digest=u.digest,a?j(e,a,t):(e=new E("rejected",null,t),n.set(r,e));break;case 84:(n=(e=e._chunks).get(r))&&"pending"!==n.status?n.reason.enqueueValue(u):(u=new E("fulfilled",u,null),e.set(r,u));break;case 78:case 68:case 74:case 87:throw Error("Failed to read a RSC payload created by a development version of React on the server while using a production version on the client. Always use matching versions on the server and the client.");case 82:Z(e,r,void 0);break;case 114:Z(e,r,"bytes");break;case 88:et(e,r,!1);break;case 120:et(e,r,!0);break;case 67:(r=e._chunks.get(r))&&"fulfilled"===r.status&&r.reason.close(""===u?'"$undefined"':u);break;default:(a=(n=e._chunks).get(r))?M(e,a,u):(e=new E("resolved_model",u,e),n.set(r,e))}})(e,u,i,p,h,_=new Uint8Array(s.buffer,g,b-c)),c=b,3===d&&c++,_=i=p=d=0,h.length=0;else{s=new Uint8Array(s.buffer,g,s.byteLength-c),h.push(s),_-=s.byteLength;break}}return u._rowState=d,u._rowID=i,u._rowTag=p,u._rowLength=_,a.read().then(t).catch(n)}).catch(n)}r.createFromFetch=function(e,t){var r=ea(t);return e.then(function(e){ei(r,e.body,eu.bind(null,r))},function(e){k(r,e)}),I(r,0)},r.createFromReadableStream=function(e,t){return ei(t=ea(t),e,eu.bind(null,t)),I(t,0)},r.createServerReference=function(e,t){function r(){var r=Array.prototype.slice.call(arguments);return t(e,r)}return m(r,e,null),r},r.createTemporaryReferenceSet=function(){return new Map},r.encodeReply=function(e,t){return new Promise(function(r,n){var o=function(e,t,r,n,o){function u(e,t){t=new Blob([new Uint8Array(t.buffer,t.byteOffset,t.byteLength)]);var r=s++;return null===c&&(c=new FormData),c.append(""+r,t),"$"+e+r.toString(16)}function a(e,t){if(null===t)return null;if("object"==typeof t){switch(t.$$typeof){case d:if(void 0!==r&&-1===e.indexOf(":")){var E,R,O,S,P,T=f.get(this);if(void 0!==T)return r.set(T+":"+e,t),"$T"}throw Error("React Element cannot be passed to Server Functions from the Client without a temporary reference set. Pass a TemporaryReferenceSet to the options.");case p:T=t._payload;var j=t._init;null===c&&(c=new FormData),l++;try{var w=j(T),A=s++,M=i(w,A);return c.append(""+A,M),"$"+A.toString(16)}catch(e){if("object"==typeof e&&null!==e&&"function"==typeof e.then){l++;var D=s++;return T=function(){try{var e=i(t,D),r=c;r.append(""+D,e),l--,0===l&&n(r)}catch(e){o(e)}},e.then(T,T),"$"+D.toString(16)}return o(e),null}finally{l--}}if("function"==typeof t.then){null===c&&(c=new FormData),l++;var N=s++;return t.then(function(e){try{var t=i(e,N);(e=c).append(""+N,t),l--,0===l&&n(e)}catch(e){o(e)}},o),"$@"+N.toString(16)}if(void 0!==(T=f.get(t)))if(m!==t)return T;else m=null;else -1===e.indexOf(":")&&void 0!==(T=f.get(this))&&(e=T+":"+e,f.set(t,e),void 0!==r&&r.set(e,t));if(y(t))return t;if(t instanceof FormData){null===c&&(c=new FormData);var C=c,x=""+(e=s++)+"_";return t.forEach(function(e,t){C.append(x+t,e)}),"$K"+e.toString(16)}if(t instanceof Map)return e=s++,T=i(Array.from(t),e),null===c&&(c=new FormData),c.append(""+e,T),"$Q"+e.toString(16);if(t instanceof Set)return e=s++,T=i(Array.from(t),e),null===c&&(c=new FormData),c.append(""+e,T),"$W"+e.toString(16);if(t instanceof ArrayBuffer)return e=new Blob([t]),T=s++,null===c&&(c=new FormData),c.append(""+T,e),"$A"+T.toString(16);if(t instanceof Int8Array)return u("O",t);if(t instanceof Uint8Array)return u("o",t);if(t instanceof Uint8ClampedArray)return u("U",t);if(t instanceof Int16Array)return u("S",t);if(t instanceof Uint16Array)return u("s",t);if(t instanceof Int32Array)return u("L",t);if(t instanceof Uint32Array)return u("l",t);if(t instanceof Float32Array)return u("G",t);if(t instanceof Float64Array)return u("g",t);if(t instanceof BigInt64Array)return u("M",t);if(t instanceof BigUint64Array)return u("m",t);if(t instanceof DataView)return u("V",t);if("function"==typeof Blob&&t instanceof Blob)return null===c&&(c=new FormData),e=s++,c.append(""+e,t),"$B"+e.toString(16);if(e=null===(E=t)||"object"!=typeof E?null:"function"==typeof(E=_&&E[_]||E["@@iterator"])?E:null)return(T=e.call(t))===t?(e=s++,T=i(Array.from(T),e),null===c&&(c=new FormData),c.append(""+e,T),"$i"+e.toString(16)):Array.from(T);if("function"==typeof ReadableStream&&t instanceof ReadableStream)return function(e){try{var t,r,u,i,f,d,p,_=e.getReader({mode:"byob"})}catch(i){return t=e.getReader(),null===c&&(c=new FormData),r=c,l++,u=s++,t.read().then(function e(i){if(i.done)r.append(""+u,"C"),0==--l&&n(r);else try{var s=JSON.stringify(i.value,a);r.append(""+u,s),t.read().then(e,o)}catch(e){o(e)}},o),"$R"+u.toString(16)}return i=_,null===c&&(c=new FormData),f=c,l++,d=s++,p=[],i.read(new Uint8Array(1024)).then(function e(t){t.done?(t=s++,f.append(""+t,new Blob(p)),f.append(""+d,'"$o'+t.toString(16)+'"'),f.append(""+d,"C"),0==--l&&n(f)):(p.push(t.value),i.read(new Uint8Array(1024)).then(e,o))},o),"$r"+d.toString(16)}(t);if("function"==typeof(e=t[h]))return R=t,O=e.call(t),null===c&&(c=new FormData),S=c,l++,P=s++,R=R===O,O.next().then(function e(t){if(t.done){if(void 0===t.value)S.append(""+P,"C");else try{var r=JSON.stringify(t.value,a);S.append(""+P,"C"+r)}catch(e){o(e);return}0==--l&&n(S)}else try{var u=JSON.stringify(t.value,a);S.append(""+P,u),O.next().then(e,o)}catch(e){o(e)}},o),"$"+(R?"x":"X")+P.toString(16);if((e=b(t))!==g&&(null===e||null!==b(e))){if(void 0===r)throw Error("Only plain objects, and a few built-ins, can be passed to Server Functions. Classes or null prototypes are not supported.");return"$T"}return t}if("string"==typeof t)return"Z"===t[t.length-1]&&this[e]instanceof Date?"$D"+t:e="$"===t[0]?"$"+t:t;if("boolean"==typeof t)return t;if("number"==typeof t)return Number.isFinite(t)?0===t&&-1/0==1/t?"$-0":t:1/0===t?"$Infinity":-1/0===t?"$-Infinity":"$NaN";if(void 0===t)return"$undefined";if("function"==typeof t){if(void 0!==(T=v.get(t)))return e=JSON.stringify({id:T.id,bound:T.bound},a),null===c&&(c=new FormData),T=s++,c.set(""+T,e),"$F"+T.toString(16);if(void 0!==r&&-1===e.indexOf(":")&&void 0!==(T=f.get(this)))return r.set(T+":"+e,t),"$T";throw Error("Client Functions cannot be passed directly to Server Functions. Only Functions passed from the Server can be passed back again.")}if("symbol"==typeof t){if(void 0!==r&&-1===e.indexOf(":")&&void 0!==(T=f.get(this)))return r.set(T+":"+e,t),"$T";throw Error("Symbols cannot be passed to a Server Function without a temporary reference set. Pass a TemporaryReferenceSet to the options.")}if("bigint"==typeof t)return"$n"+t.toString(10);throw Error("Type "+typeof t+" is not supported as an argument to a Server Function.")}function i(e,t){return"object"==typeof e&&null!==e&&(t="$"+t.toString(16),f.set(e,t),void 0!==r&&r.set(t,e)),m=e,JSON.stringify(e,a)}var s=1,l=0,c=null,f=new WeakMap,m=e,E=i(e,0);return null===c?n(E):(c.set("0",E),0===l&&n(c)),function(){0<l&&(l=0,null===c?n(E):n(c))}}(e,0,t&&t.temporaryReferences?t.temporaryReferences:void 0,r,n);if(t&&t.signal){var u=t.signal;if(u.aborted)o(u.reason);else{var a=function(){o(u.reason),u.removeEventListener("abort",a)};u.addEventListener("abort",a)}}})},r.registerServerReference=function(e,t){return m(e,t,null),e}},21413,(e,t,r)=>{"use strict";t.exports=e.r(64893)},35326,(e,t,r)=>{"use strict";t.exports=e.r(21413)},54394,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={HTTPAccessErrorStatus:function(){return u},HTTP_ERROR_FALLBACK_ERROR_CODE:function(){return i},getAccessFallbackErrorTypeByStatus:function(){return c},getAccessFallbackHTTPStatus:function(){return l},isHTTPAccessFallbackError:function(){return s}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u={NOT_FOUND:404,FORBIDDEN:403,UNAUTHORIZED:401},a=new Set(Object.values(u)),i="NEXT_HTTP_ERROR_FALLBACK";function s(e){if("object"!=typeof e||null===e||!("digest"in e)||"string"!=typeof e.digest)return!1;let[t,r]=e.digest.split(";");return t===i&&a.has(Number(r))}function l(e){return Number(e.digest.split(";")[1])}function c(e){switch(e){case 401:return"unauthorized";case 403:return"forbidden";case 404:return"not-found";default:return}}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},76963,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"RedirectStatusCode",{enumerable:!0,get:function(){return o}});var n,o=((n={})[n.SeeOther=303]="SeeOther",n[n.TemporaryRedirect=307]="TemporaryRedirect",n[n.PermanentRedirect=308]="PermanentRedirect",n);("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},68391,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n,o={REDIRECT_ERROR_CODE:function(){return i},RedirectType:function(){return s},isRedirectError:function(){return l}};for(var u in o)Object.defineProperty(r,u,{enumerable:!0,get:o[u]});let a=e.r(76963),i="NEXT_REDIRECT";var s=((n={}).push="push",n.replace="replace",n);function l(e){if("object"!=typeof e||null===e||!("digest"in e)||"string"!=typeof e.digest)return!1;let t=e.digest.split(";"),[r,n]=t,o=t.slice(2,-2).join(";"),u=Number(t.at(-2));return r===i&&("replace"===n||"push"===n)&&"string"==typeof o&&!isNaN(u)&&u in a.RedirectStatusCode}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},65713,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"isNextRouterError",{enumerable:!0,get:function(){return u}});let n=e.r(54394),o=e.r(68391);function u(e){return(0,o.isRedirectError)(e)||(0,n.isHTTPAccessFallbackError)(e)}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},61994,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={NavigationPromisesContext:function(){return l},PathParamsContext:function(){return s},PathnameContext:function(){return i},SearchParamsContext:function(){return a},createDevToolsInstrumentedPromise:function(){return c}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(71645),a=(0,u.createContext)(null),i=(0,u.createContext)(null),s=(0,u.createContext)(null),l=(0,u.createContext)(null);function c(e,t){let r=Promise.resolve(t);return r.status="fulfilled",r.value=t,r.displayName=`${e} (SSR)`,r}},45955,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"workUnitAsyncStorageInstance",{enumerable:!0,get:function(){return n}});let n=(0,e.r(90317).createAsyncLocalStorage)()},21768,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={ACTION_HEADER:function(){return a},FLIGHT_HEADERS:function(){return _},NEXT_ACTION_NOT_FOUND_HEADER:function(){return E},NEXT_DID_POSTPONE_HEADER:function(){return b},NEXT_HMR_REFRESH_HASH_COOKIE:function(){return f},NEXT_HMR_REFRESH_HEADER:function(){return c},NEXT_HTML_REQUEST_ID_HEADER:function(){return O},NEXT_IS_PRERENDER_HEADER:function(){return m},NEXT_REQUEST_ID_HEADER:function(){return R},NEXT_REWRITTEN_PATH_HEADER:function(){return g},NEXT_REWRITTEN_QUERY_HEADER:function(){return v},NEXT_ROUTER_PREFETCH_HEADER:function(){return s},NEXT_ROUTER_SEGMENT_PREFETCH_HEADER:function(){return l},NEXT_ROUTER_STALE_TIME_HEADER:function(){return y},NEXT_ROUTER_STATE_TREE_HEADER:function(){return i},NEXT_RSC_UNION_QUERY:function(){return h},NEXT_URL:function(){return d},RSC_CONTENT_TYPE_HEADER:function(){return p},RSC_HEADER:function(){return u}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u="rsc",a="next-action",i="next-router-state-tree",s="next-router-prefetch",l="next-router-segment-prefetch",c="next-hmr-refresh",f="__next_hmr_refresh_hash__",d="next-url",p="text/x-component",_=[u,i,s,c,l],h="_rsc",y="x-nextjs-stale-time",b="x-nextjs-postponed",g="x-nextjs-rewritten-path",v="x-nextjs-rewritten-query",m="x-nextjs-prerender",E="x-nextjs-action-not-found",R="x-nextjs-request-id",O="x-nextjs-html-request-id";("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},62141,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={getCacheSignal:function(){return y},getDraftModeProviderForCacheScope:function(){return h},getHmrRefreshHash:function(){return d},getPrerenderResumeDataCache:function(){return c},getRenderResumeDataCache:function(){return f},getRuntimeStagePromise:function(){return b},getServerComponentsHmrCache:function(){return _},isHmrRefresh:function(){return p},throwForMissingRequestStore:function(){return s},throwInvariantForMissingStore:function(){return l},workUnitAsyncStorage:function(){return u.workUnitAsyncStorageInstance}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(45955),a=e.r(21768),i=e.r(12718);function s(e){throw Object.defineProperty(Error(`\`${e}\` was called outside a request scope. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context`),"__NEXT_ERROR_CODE",{value:"E251",enumerable:!1,configurable:!0})}function l(){throw Object.defineProperty(new i.InvariantError("Expected workUnitAsyncStorage to have a store."),"__NEXT_ERROR_CODE",{value:"E696",enumerable:!1,configurable:!0})}function c(e){switch(e.type){case"prerender":case"prerender-runtime":case"prerender-ppr":case"prerender-client":return e.prerenderResumeDataCache;case"request":if(e.prerenderResumeDataCache)return e.prerenderResumeDataCache;case"prerender-legacy":case"cache":case"private-cache":case"unstable-cache":return null;default:return e}}function f(e){switch(e.type){case"request":case"prerender":case"prerender-runtime":case"prerender-client":if(e.renderResumeDataCache)return e.renderResumeDataCache;case"prerender-ppr":return e.prerenderResumeDataCache??null;case"cache":case"private-cache":case"unstable-cache":case"prerender-legacy":return null;default:return e}}function d(e,t){if(e.dev)switch(t.type){case"cache":case"private-cache":case"prerender":case"prerender-runtime":return t.hmrRefreshHash;case"request":var r;return null==(r=t.cookies.get(a.NEXT_HMR_REFRESH_HASH_COOKIE))?void 0:r.value}}function p(e,t){if(e.dev)switch(t.type){case"cache":case"private-cache":case"request":return t.isHmrRefresh??!1}return!1}function _(e,t){if(e.dev)switch(t.type){case"cache":case"private-cache":case"request":return t.serverComponentsHmrCache}}function h(e,t){if(e.isDraftMode)switch(t.type){case"cache":case"private-cache":case"unstable-cache":case"prerender-runtime":case"request":return t.draftMode}}function y(e){switch(e.type){case"prerender":case"prerender-client":case"prerender-runtime":return e.cacheSignal;case"request":if(e.cacheSignal)return e.cacheSignal;case"prerender-ppr":case"prerender-legacy":case"cache":case"private-cache":case"unstable-cache":return null;default:return e}}function b(e){switch(e.type){case"prerender-runtime":case"private-cache":return e.runtimeStagePromise;case"prerender":case"prerender-client":case"prerender-ppr":case"prerender-legacy":case"request":case"cache":case"unstable-cache":return null;default:return e}}},90373,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"useUntrackedPathname",{enumerable:!0,get:function(){return u}});let n=e.r(71645),o=e.r(61994);function u(){return!function(){if("undefined"==typeof window){let{workUnitAsyncStorage:t}=e.r(62141),r=t.getStore();if(!r)return!1;switch(r.type){case"prerender":case"prerender-client":case"prerender-ppr":let n=r.fallbackRouteParams;return!!n&&n.size>0}}return!1}()?(0,n.useContext)(o.PathnameContext):null}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},51191,(e,t,r)=>{"use strict";function n(e,t=!0){return e.pathname+e.search+(t?e.hash:"")}Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"createHrefFromUrl",{enumerable:!0,get:function(){return n}}),("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},78377,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={handleHardNavError:function(){return a},useNavFailureHandler:function(){return i}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});e.r(71645);let u=e.r(51191);function a(e){return!!e&&"undefined"!=typeof window&&!!window.next.__pendingUrl&&(0,u.createHrefFromUrl)(new URL(window.location.href))!==(0,u.createHrefFromUrl)(window.next.__pendingUrl)&&(console.error("Error occurred during navigation, falling back to hard navigation",e),window.location.href=window.next.__pendingUrl.toString(),!0)}function i(){}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},26935,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"HTML_LIMITED_BOT_UA_RE",{enumerable:!0,get:function(){return n}});let n=/[\w-]+-Google|Google-[\w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight/i},82604,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={HTML_LIMITED_BOT_UA_RE:function(){return u.HTML_LIMITED_BOT_UA_RE},HTML_LIMITED_BOT_UA_RE_STRING:function(){return i},getBotType:function(){return c},isBot:function(){return l}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(26935),a=/Googlebot(?!-)|Googlebot$/i,i=u.HTML_LIMITED_BOT_UA_RE.source;function s(e){return u.HTML_LIMITED_BOT_UA_RE.test(e)}function l(e){return a.test(e)||s(e)}function c(e){return a.test(e)?"dom":s(e)?"html":void 0}},72383,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={ErrorBoundary:function(){return _},ErrorBoundaryHandler:function(){return p}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(55682),a=e.r(43476),i=u._(e.r(71645)),s=e.r(90373),l=e.r(65713);e.r(78377);let c=e.r(12354),f=e.r(82604),d="undefined"!=typeof window&&(0,f.isBot)(window.navigator.userAgent);class p extends i.default.Component{constructor(e){super(e),this.reset=()=>{this.setState({error:null})},this.state={error:null,previousPathname:this.props.pathname}}static getDerivedStateFromError(e){if((0,l.isNextRouterError)(e))throw e;return{error:e}}static getDerivedStateFromProps(e,t){let{error:r}=t;return e.pathname!==t.previousPathname&&t.error?{error:null,previousPathname:e.pathname}:{error:t.error,previousPathname:e.pathname}}render(){return this.state.error&&!d?(0,a.jsxs)(a.Fragment,{children:[(0,a.jsx)(c.HandleISRError,{error:this.state.error}),this.props.errorStyles,this.props.errorScripts,(0,a.jsx)(this.props.errorComponent,{error:this.state.error,reset:this.reset})]}):this.props.children}}function _({errorComponent:e,errorStyles:t,errorScripts:r,children:n}){let o=(0,s.useUntrackedPathname)();return e?(0,a.jsx)(p,{pathname:o,errorComponent:e,errorStyles:t,errorScripts:r,children:n}):(0,a.jsx)(a.Fragment,{children:n})}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},88540,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n,o={ACTION_HMR_REFRESH:function(){return c},ACTION_NAVIGATE:function(){return i},ACTION_REFRESH:function(){return a},ACTION_RESTORE:function(){return s},ACTION_SERVER_ACTION:function(){return f},ACTION_SERVER_PATCH:function(){return l},PrefetchKind:function(){return d}};for(var u in o)Object.defineProperty(r,u,{enumerable:!0,get:o[u]});let a="refresh",i="navigate",s="restore",l="server-patch",c="hmr-refresh",f="server-action";var d=((n={}).AUTO="auto",n.FULL="full",n.TEMPORARY="temporary",n);("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},90809,(e,t,r)=>{"use strict";function n(e){if("function"!=typeof WeakMap)return null;var t=new WeakMap,r=new WeakMap;return(n=function(e){return e?r:t})(e)}r._=function(e,t){if(!t&&e&&e.__esModule)return e;if(null===e||"object"!=typeof e&&"function"!=typeof e)return{default:e};var r=n(t);if(r&&r.has(e))return r.get(e);var o={__proto__:null},u=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var a in e)if("default"!==a&&Object.prototype.hasOwnProperty.call(e,a)){var i=u?Object.getOwnPropertyDescriptor(e,a):null;i&&(i.get||i.set)?Object.defineProperty(o,a,i):o[a]=e[a]}return o.default=e,r&&r.set(e,o),o}},64245,(e,t,r)=>{"use strict";function n(e){return null!==e&&"object"==typeof e&&"then"in e&&"function"==typeof e.then}Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"isThenable",{enumerable:!0,get:function(){return n}})},41538,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={dispatchAppRouterAction:function(){return s},useActionQueue:function(){return l}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(90809)._(e.r(71645)),a=e.r(64245),i=null;function s(e){if(null===i)throw Object.defineProperty(Error("Internal Next.js error: Router action dispatched before initialization."),"__NEXT_ERROR_CODE",{value:"E668",enumerable:!1,configurable:!0});i(e)}function l(e){let[t,r]=u.default.useState(e.state);i=t=>e.dispatch(t,r);let n=(0,u.useMemo)(()=>{if((0,a.isThenable)(t)){let e=[],r=Promise.resolve(t).then(t=>(null!==t.debugInfo&&e.push(...t.debugInfo),t));return r._debugInfo=e,r}return t},[t]);return(0,a.isThenable)(n)?(0,u.use)(n):n}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},32120,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"callServer",{enumerable:!0,get:function(){return a}});let n=e.r(71645),o=e.r(88540),u=e.r(41538);async function a(e,t){return new Promise((r,a)=>{(0,n.startTransition)(()=>{(0,u.dispatchAppRouterAction)({type:o.ACTION_SERVER_ACTION,actionId:e,actionArgs:t,resolve:r,reject:a})})})}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},92245,(e,t,r)=>{"use strict";let n;Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"findSourceMapURL",{enumerable:!0,get:function(){return n}});("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},3372,(e,t,r)=>{"use strict";function n(e){return e.startsWith("/")?e:`/${e}`}Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"ensureLeadingSlash",{enumerable:!0,get:function(){return n}})},13258,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={DEFAULT_SEGMENT_KEY:function(){return f},PAGE_SEGMENT_KEY:function(){return c},addSearchParamsIfPageSegment:function(){return s},computeSelectedLayoutSegment:function(){return l},getSegmentValue:function(){return u},getSelectedLayoutSegmentPath:function(){return function e(t,r,n=!0,o=[]){let a;if(n)a=t[1][r];else{let e=t[1];a=e.children??Object.values(e)[0]}if(!a)return o;let i=u(a[0]);return!i||i.startsWith(c)?o:(o.push(i),e(a,r,!1,o))}},isGroupSegment:function(){return a},isParallelRouteSegment:function(){return i}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});function u(e){return Array.isArray(e)?e[1]:e}function a(e){return"("===e[0]&&e.endsWith(")")}function i(e){return e.startsWith("@")&&"@children"!==e}function s(e,t){if(e.includes(c)){let e=JSON.stringify(t);return"{}"!==e?c+"?"+e:c}return e}function l(e,t){if(!e||0===e.length)return null;let r="children"===t?e[0]:e[e.length-1];return r===f?null:r}let c="__PAGE__",f="__DEFAULT__"},74180,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={normalizeAppPath:function(){return i},normalizeRscURL:function(){return s}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(3372),a=e.r(13258);function i(e){return(0,u.ensureLeadingSlash)(e.split("/").reduce((e,t,r,n)=>!t||(0,a.isGroupSegment)(t)||"@"===t[0]||("page"===t||"route"===t)&&r===n.length-1?e:`${e}/${t}`,""))}function s(e){return e.replace(/\.rsc($|\?)/,"$1")}},91463,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={INTERCEPTION_ROUTE_MARKERS:function(){return a},extractInterceptionRouteInformation:function(){return s},isInterceptionRouteAppPath:function(){return i}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(74180),a=["(..)(..)","(.)","(..)","(...)"];function i(e){return void 0!==e.split("/").find(e=>a.find(t=>e.startsWith(t)))}function s(e){let t,r,n;for(let o of e.split("/"))if(r=a.find(e=>o.startsWith(e))){[t,n]=e.split(r,2);break}if(!t||!r||!n)throw Object.defineProperty(Error(`Invalid interception route: ${e}. Must be in the format /<intercepting route>/(..|...|..)(..)/<intercepted route>`),"__NEXT_ERROR_CODE",{value:"E269",enumerable:!1,configurable:!0});switch(t=(0,u.normalizeAppPath)(t),r){case"(.)":n="/"===t?`/${n}`:t+"/"+n;break;case"(..)":if("/"===t)throw Object.defineProperty(Error(`Invalid interception route: ${e}. Cannot use (..) marker at the root level, use (.) instead.`),"__NEXT_ERROR_CODE",{value:"E207",enumerable:!1,configurable:!0});n=t.split("/").slice(0,-1).concat(n).join("/");break;case"(...)":n="/"+n;break;case"(..)(..)":let o=t.split("/");if(o.length<=2)throw Object.defineProperty(Error(`Invalid interception route: ${e}. Cannot use (..)(..) marker at the root level or one level up.`),"__NEXT_ERROR_CODE",{value:"E486",enumerable:!1,configurable:!0});n=o.slice(0,-2).concat(n).join("/");break;default:throw Object.defineProperty(Error("Invariant: unexpected marker"),"__NEXT_ERROR_CODE",{value:"E112",enumerable:!1,configurable:!0})}return{interceptingRoute:t,interceptedRoute:n}}},56019,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"matchSegment",{enumerable:!0,get:function(){return n}});let n=(e,t)=>"string"==typeof e?"string"==typeof t&&e===t:"string"!=typeof t&&e[0]===t[0]&&e[1]===t[1];("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},67764,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={ROOT_SEGMENT_CACHE_KEY:function(){return i},ROOT_SEGMENT_REQUEST_KEY:function(){return a},appendSegmentCacheKeyPart:function(){return f},appendSegmentRequestKeyPart:function(){return l},convertSegmentPathToStaticExportFilename:function(){return _},createSegmentCacheKeyPart:function(){return c},createSegmentRequestKeyPart:function(){return s}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(13258),a="",i="";function s(e){if("string"==typeof e)return e.startsWith(u.PAGE_SEGMENT_KEY)?u.PAGE_SEGMENT_KEY:"/_not-found"===e?"_not-found":p(e);let t=e[0];return"$"+e[2]+"$"+p(t)}function l(e,t,r){return e+"/"+("children"===t?r:`@${p(t)}/${r}`)}function c(e,t){return"string"==typeof t?e:e+"$"+p(t[1])}function f(e,t,r){return e+"/"+("children"===t?r:`@${p(t)}/${r}`)}let d=/^[a-zA-Z0-9\-_@]+$/;function p(e){return d.test(e)?e:"!"+btoa(e).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function _(e){return`__next${e.replace(/\//g,".")}.txt`}},33906,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={doesStaticSegmentAppearInURL:function(){return f},getCacheKeyForDynamicParam:function(){return d},getParamValueFromCacheKey:function(){return _},getRenderedPathname:function(){return l},getRenderedSearch:function(){return s},parseDynamicParamFromURLPart:function(){return c},urlSearchParamsToParsedUrlQuery:function(){return h},urlToUrlWithoutFlightMarker:function(){return p}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(13258),a=e.r(67764),i=e.r(21768);function s(e){let t=e.headers.get(i.NEXT_REWRITTEN_QUERY_HEADER);return null!==t?""===t?"":"?"+t:p(new URL(e.url)).search}function l(e){return e.headers.get(i.NEXT_REWRITTEN_PATH_HEADER)??p(new URL(e.url)).pathname}function c(e,t,r){switch(e){case"c":case"ci":return r<t.length?t.slice(r).map(e=>encodeURIComponent(e)):[];case"oc":return r<t.length?t.slice(r).map(e=>encodeURIComponent(e)):null;case"d":case"di":if(r>=t.length)return"";return encodeURIComponent(t[r]);default:return""}}function f(e){return!(e===a.ROOT_SEGMENT_REQUEST_KEY||e.startsWith(u.PAGE_SEGMENT_KEY)||"("===e[0]&&e.endsWith(")"))&&e!==u.DEFAULT_SEGMENT_KEY&&"/_not-found"!==e}function d(e,t){return"string"==typeof e?(0,u.addSearchParamsIfPageSegment)(e,Object.fromEntries(new URLSearchParams(t))):null===e?"":e.join("/")}function p(e){let t=new URL(e);if(t.searchParams.delete(i.NEXT_RSC_UNION_QUERY),t.pathname.endsWith(".txt")){let{pathname:e}=t,r=e.endsWith("/index.txt")?10:4;t.pathname=e.slice(0,-r)}return t}function _(e,t){return"c"===t||"oc"===t?e.split("/"):e}function h(e){let t={};for(let[r,n]of e.entries())void 0===t[r]?t[r]=n:Array.isArray(t[r])?t[r].push(n):t[r]=[t[r],n];return t}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},50590,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={createInitialRSCPayloadFromFallbackPrerender:function(){return l},getFlightDataPartsFromPath:function(){return s},getNextFlightSegmentPath:function(){return c},normalizeFlightData:function(){return f},prepareFlightRouterStateForRequest:function(){return d}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(13258),a=e.r(33906),i=e.r(51191);function s(e){let[t,r,n,o]=e.slice(-4),u=e.slice(0,-4);return{pathToSegment:u.slice(0,-1),segmentPath:u,segment:u[u.length-1]??"",tree:t,seedData:r,head:n,isHeadPartial:o,isRootRender:4===e.length}}function l(e,t){let r=(0,a.getRenderedPathname)(e),n=(0,a.getRenderedSearch)(e),o=(0,i.createHrefFromUrl)(new URL(location.href)),u=t.f[0],s=u[0];return{b:t.b,c:o.split("/"),q:n,i:t.i,f:[[function e(t,r,n,o){let u,i,s=t[0];if("string"==typeof s)u=s,i=(0,a.doesStaticSegmentAppearInURL)(s);else{let e=s[0],t=s[2],l=(0,a.parseDynamicParamFromURLPart)(t,n,o);u=[e,(0,a.getCacheKeyForDynamicParam)(l,r),t],i=!0}let l=i?o+1:o,c=t[1],f={};for(let t in c){let o=c[t];f[t]=e(o,r,n,l)}return[u,f,null,t[3],t[4]]}(s,n,r.split("/").filter(e=>""!==e),0),u[1],u[2],u[2]]],m:t.m,G:t.G,s:t.s,S:t.S}}function c(e){return e.slice(2)}function f(e){return"string"==typeof e?e:e.map(e=>s(e))}function d(e,t){return t?encodeURIComponent(JSON.stringify(e)):encodeURIComponent(JSON.stringify(function e(t){var r,n;let[o,a,i,s,l,c]=t,f="string"==typeof(r=o)&&r.startsWith(u.PAGE_SEGMENT_KEY+"?")?u.PAGE_SEGMENT_KEY:r,d={};for(let[t,r]of Object.entries(a))d[t]=e(r);let p=[f,d,null,(n=s)&&"refresh"!==n?s:null];return void 0!==l&&(p[4]=l),void 0!==c&&(p[5]=c),p}(e)))}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},14297,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={getAppBuildId:function(){return i},setAppBuildId:function(){return a}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u="";function a(e){u=e}function i(){return u}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},19921,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={djb2Hash:function(){return u},hexHash:function(){return a}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});function u(e){let t=5381;for(let r=0;r<e.length;r++)t=(t<<5)+t+e.charCodeAt(r)|0;return t>>>0}function a(e){return u(e).toString(36).slice(0,5)}},86051,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"computeCacheBustingSearchParam",{enumerable:!0,get:function(){return o}});let n=e.r(19921);function o(e,t,r,o){return(void 0===e||"0"===e)&&void 0===t&&void 0===r&&void 0===o?"":(0,n.hexHash)([e||"0",t||"0",r||"0",o||"0"].join(","))}},88093,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={setCacheBustingSearchParam:function(){return i},setCacheBustingSearchParamWithHash:function(){return s}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(86051),a=e.r(21768),i=(e,t)=>{s(e,(0,u.computeCacheBustingSearchParam)(t[a.NEXT_ROUTER_PREFETCH_HEADER],t[a.NEXT_ROUTER_SEGMENT_PREFETCH_HEADER],t[a.NEXT_ROUTER_STATE_TREE_HEADER],t[a.NEXT_URL]))},s=(e,t)=>{let r=e.search,n=(r.startsWith("?")?r.slice(1):r).split("&").filter(e=>e&&!e.startsWith(`${a.NEXT_RSC_UNION_QUERY}=`));t.length>0?n.push(`${a.NEXT_RSC_UNION_QUERY}=${t}`):n.push(`${a.NEXT_RSC_UNION_QUERY}`),e.search=n.length?`?${n.join("&")}`:""};("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},87288,(e,t,r)=>{"use strict";let n;Object.defineProperty(r,"__esModule",{value:!0});var o={createFetch:function(){return m},createFromNextReadableStream:function(){return E},fetchServerResponse:function(){return v}};for(var u in o)Object.defineProperty(r,u,{enumerable:!0,get:o[u]});let a=e.r(35326),i=e.r(21768),s=e.r(32120),l=e.r(92245),c=e.r(88540),f=e.r(50590),d=e.r(14297),p=e.r(88093),_=e.r(33906),h=a.createFromReadableStream,y=a.createFromFetch;function b(e){return(0,_.urlToUrlWithoutFlightMarker)(new URL(e,location.origin)).toString()}let g=new AbortController;async function v(e,t){let{flightRouterState:r,nextUrl:n,prefetchKind:o}=t,u={[i.RSC_HEADER]:"1",[i.NEXT_ROUTER_STATE_TREE_HEADER]:(0,f.prepareFlightRouterStateForRequest)(r,t.isHmrRefresh)};o===c.PrefetchKind.AUTO&&(u[i.NEXT_ROUTER_PREFETCH_HEADER]="1"),n&&(u[i.NEXT_URL]=n);let a=e;try{let t=o?o===c.PrefetchKind.TEMPORARY?"high":"low":"auto";(e=new URL(e)).pathname.endsWith("/")?e.pathname+="index.txt":e.pathname+=".txt";let r=await m(e,u,t,!0,g.signal),n=(0,_.urlToUrlWithoutFlightMarker)(new URL(r.url)),s=r.redirected?n:a,l=r.headers.get("content-type")||"",p=!!r.headers.get("vary")?.includes(i.NEXT_URL),h=!!r.headers.get(i.NEXT_DID_POSTPONE_HEADER),y=r.headers.get(i.NEXT_ROUTER_STALE_TIME_HEADER),v=null!==y?1e3*parseInt(y,10):-1,R=l.startsWith(i.RSC_CONTENT_TYPE_HEADER);if(R||(R=l.startsWith("text/plain")),!R||!r.ok||!r.body)return e.hash&&(n.hash=e.hash),b(n.toString());let O=r.flightResponse;if(null===O){let e,t=h?(e=r.body.getReader(),new ReadableStream({async pull(t){for(;;){let{done:r,value:n}=await e.read();if(!r){t.enqueue(n);continue}return}}})):r.body;O=E(t,u)}let S=await O;if((0,d.getAppBuildId)()!==S.b)return b(r.url);let P=(0,f.normalizeFlightData)(S.f);if("string"==typeof P)return b(P);return{flightData:P,canonicalUrl:s,renderedSearch:(0,_.getRenderedSearch)(r),couldBeIntercepted:p,prerendered:S.S,postponed:h,staleTime:v,debugInfo:O._debugInfo??null}}catch(e){return g.signal.aborted||console.error(`Failed to fetch RSC payload for ${a}. Falling back to browser navigation.`,e),a.toString()}}async function m(e,t,r,o,u){var a,c;let f=new URL(e);(0,p.setCacheBustingSearchParam)(f,t);let d=fetch(f,{credentials:"same-origin",headers:t,priority:r||void 0,signal:u}),_=o?(a=d,c=t,y(a,{callServer:s.callServer,findSourceMapURL:l.findSourceMapURL,debugChannel:n&&n(c)})):null,h=await d,b=h.redirected,g=new URL(h.url,f);return g.searchParams.delete(i.NEXT_RSC_UNION_QUERY),{url:g.href,redirected:b,ok:h.ok,headers:h.headers,body:h.body,status:h.status,flightResponse:_}}function E(e,t){return h(e,{callServer:s.callServer,findSourceMapURL:l.findSourceMapURL,debugChannel:n&&n(t)})}"undefined"!=typeof window&&(window.addEventListener("pagehide",()=>{g.abort()}),window.addEventListener("pageshow",()=>{g=new AbortController})),("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},39470,(e,t,r)=>{"use strict";function n(){let e,t,r=new Promise((r,n)=>{e=r,t=n});return{resolve:e,reject:t,promise:r}}Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"createPromiseWithResolvers",{enumerable:!0,get:function(){return n}})},70725,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"createRouterCacheKey",{enumerable:!0,get:function(){return o}});let n=e.r(13258);function o(e,t=!1){return Array.isArray(e)?`${e[0]}|${e[1]}|${e[2]}`:t&&e.startsWith(n.PAGE_SEGMENT_KEY)?n.PAGE_SEGMENT_KEY:e}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},8372,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={AppRouterContext:function(){return a},GlobalLayoutRouterContext:function(){return s},LayoutRouterContext:function(){return i},MissingSlotContext:function(){return c},TemplateContext:function(){return l}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(55682)._(e.r(71645)),a=u.default.createContext(null),i=u.default.createContext(null),s=u.default.createContext(null),l=u.default.createContext(null),c=u.default.createContext(new Set)},3680,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"ReadonlyURLSearchParams",{enumerable:!0,get:function(){return o}});class n extends Error{constructor(){super("Method unavailable on `ReadonlyURLSearchParams`. Read more: https://nextjs.org/docs/app/api-reference/functions/use-search-params#updating-searchparams")}}class o extends URLSearchParams{append(){throw new n}delete(){throw new n}set(){throw new n}sort(){throw new n}}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},13957,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={ServerInsertedHTMLContext:function(){return a},useServerInsertedHTML:function(){return i}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(90809)._(e.r(71645)),a=u.default.createContext(null);function i(e){let t=(0,u.useContext)(a);t&&t(e)}},92838,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={UnrecognizedActionError:function(){return u},unstable_isUnrecognizedActionError:function(){return a}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});class u extends Error{constructor(...e){super(...e),this.name="UnrecognizedActionError"}}function a(e){return!!(e&&"object"==typeof e&&e instanceof u)}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},34457,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"actionAsyncStorageInstance",{enumerable:!0,get:function(){return n}});let n=(0,e.r(90317).createAsyncLocalStorage)()},62266,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"actionAsyncStorage",{enumerable:!0,get:function(){return n.actionAsyncStorageInstance}});let n=e.r(34457)},24063,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={getRedirectError:function(){return s},getRedirectStatusCodeFromError:function(){return p},getRedirectTypeFromError:function(){return d},getURLFromRedirectError:function(){return f},permanentRedirect:function(){return c},redirect:function(){return l}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(76963),a=e.r(68391),i="undefined"==typeof window?e.r(62266).actionAsyncStorage:void 0;function s(e,t,r=u.RedirectStatusCode.TemporaryRedirect){let n=Object.defineProperty(Error(a.REDIRECT_ERROR_CODE),"__NEXT_ERROR_CODE",{value:"E394",enumerable:!1,configurable:!0});return n.digest=`${a.REDIRECT_ERROR_CODE};${t};${e};${r};`,n}function l(e,t){throw s(e,t??=i?.getStore()?.isAction?a.RedirectType.push:a.RedirectType.replace,u.RedirectStatusCode.TemporaryRedirect)}function c(e,t=a.RedirectType.replace){throw s(e,t,u.RedirectStatusCode.PermanentRedirect)}function f(e){return(0,a.isRedirectError)(e)?e.digest.split(";").slice(2,-2).join(";"):null}function d(e){if(!(0,a.isRedirectError)(e))throw Object.defineProperty(Error("Not a redirect error"),"__NEXT_ERROR_CODE",{value:"E260",enumerable:!1,configurable:!0});return e.digest.split(";",2)[1]}function p(e){if(!(0,a.isRedirectError)(e))throw Object.defineProperty(Error("Not a redirect error"),"__NEXT_ERROR_CODE",{value:"E260",enumerable:!1,configurable:!0});return Number(e.digest.split(";").at(-2))}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},22783,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"notFound",{enumerable:!0,get:function(){return u}});let n=e.r(54394),o=`${n.HTTP_ERROR_FALLBACK_ERROR_CODE};404`;function u(){let e=Object.defineProperty(Error(o),"__NEXT_ERROR_CODE",{value:"E394",enumerable:!1,configurable:!0});throw e.digest=o,e}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},79854,(e,t,r)=>{"use strict";function n(){throw Object.defineProperty(Error("`forbidden()` is experimental and only allowed to be enabled when `experimental.authInterrupts` is enabled."),"__NEXT_ERROR_CODE",{value:"E488",enumerable:!1,configurable:!0})}Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"forbidden",{enumerable:!0,get:function(){return n}}),e.r(54394).HTTP_ERROR_FALLBACK_ERROR_CODE,("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},22683,(e,t,r)=>{"use strict";function n(){throw Object.defineProperty(Error("`unauthorized()` is experimental and only allowed to be used when `experimental.authInterrupts` is enabled."),"__NEXT_ERROR_CODE",{value:"E411",enumerable:!1,configurable:!0})}Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"unauthorized",{enumerable:!0,get:function(){return n}}),e.r(54394).HTTP_ERROR_FALLBACK_ERROR_CODE,("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},15507,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"unstable_rethrow",{enumerable:!0,get:function(){return function e(t){if((0,o.isNextRouterError)(t)||(0,n.isBailoutToCSRError)(t))throw t;t instanceof Error&&"cause"in t&&e(t.cause)}}});let n=e.r(32061),o=e.r(65713);("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},63138,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={isHangingPromiseRejectionError:function(){return u},makeDevtoolsIOAwarePromise:function(){return f},makeHangingPromise:function(){return l}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});function u(e){return"object"==typeof e&&null!==e&&"digest"in e&&e.digest===a}let a="HANGING_PROMISE_REJECTION";class i extends Error{constructor(e,t){super(`During prerendering, ${t} rejects when the prerender is complete. Typically these errors are handled by React but if you move ${t} to a different context by using \`setTimeout\`, \`after\`, or similar functions you may observe this error and you should handle it in that context. This occurred at route "${e}".`),this.route=e,this.expression=t,this.digest=a}}let s=new WeakMap;function l(e,t,r){if(e.aborted)return Promise.reject(new i(t,r));{let n=new Promise((n,o)=>{let u=o.bind(null,new i(t,r)),a=s.get(e);if(a)a.push(u);else{let t=[u];s.set(e,t),e.addEventListener("abort",()=>{for(let e=0;e<t.length;e++)t[e]()},{once:!0})}});return n.catch(c),n}}function c(){}function f(e,t,r){return t.stagedRendering?t.stagedRendering.delayUntilStage(r,void 0,e):new Promise(t=>{setTimeout(()=>{t(e)},0)})}},67287,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"isPostpone",{enumerable:!0,get:function(){return o}});let n=Symbol.for("react.postpone");function o(e){return"object"==typeof e&&null!==e&&e.$$typeof===n}},76353,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={DynamicServerError:function(){return a},isDynamicServerError:function(){return i}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u="DYNAMIC_SERVER_USAGE";class a extends Error{constructor(e){super(`Dynamic server usage: ${e}`),this.description=e,this.digest=u}}function i(e){return"object"==typeof e&&null!==e&&"digest"in e&&"string"==typeof e.digest&&e.digest===u}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},43248,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={StaticGenBailoutError:function(){return a},isStaticGenBailoutError:function(){return i}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u="NEXT_STATIC_GEN_BAILOUT";class a extends Error{constructor(...e){super(...e),this.code=u}}function i(e){return"object"==typeof e&&null!==e&&"code"in e&&e.code===u}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},54839,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={METADATA_BOUNDARY_NAME:function(){return u},OUTLET_BOUNDARY_NAME:function(){return i},ROOT_LAYOUT_BOUNDARY_NAME:function(){return s},VIEWPORT_BOUNDARY_NAME:function(){return a}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u="__next_metadata_boundary__",a="__next_viewport_boundary__",i="__next_outlet_boundary__",s="__next_root_layout_boundary__"},29419,(e,t,r)=>{"use strict";var n=e.i(47167);Object.defineProperty(r,"__esModule",{value:!0});var o={atLeastOneTask:function(){return s},scheduleImmediate:function(){return i},scheduleOnNextTick:function(){return a},waitAtLeastOneReactRenderTask:function(){return l}};for(var u in o)Object.defineProperty(r,u,{enumerable:!0,get:o[u]});let a=e=>{Promise.resolve().then(()=>{n.default.nextTick(e)})},i=e=>{setImmediate(e)};function s(){return new Promise(e=>i(e))}function l(){return new Promise(e=>setImmediate(e))}},42852,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n,o={RenderStage:function(){return s},StagedRenderingController:function(){return l}};for(var u in o)Object.defineProperty(r,u,{enumerable:!0,get:o[u]});let a=e.r(12718),i=e.r(39470);var s=((n={})[n.Static=1]="Static",n[n.Runtime=2]="Runtime",n[n.Dynamic=3]="Dynamic",n);class l{constructor(e=null){this.abortSignal=e,this.currentStage=1,this.runtimeStagePromise=(0,i.createPromiseWithResolvers)(),this.dynamicStagePromise=(0,i.createPromiseWithResolvers)(),e&&e.addEventListener("abort",()=>{let{reason:t}=e;this.currentStage<2&&(this.runtimeStagePromise.promise.catch(c),this.runtimeStagePromise.reject(t)),this.currentStage<3&&(this.dynamicStagePromise.promise.catch(c),this.dynamicStagePromise.reject(t))},{once:!0})}advanceStage(e){!(this.currentStage>=e)&&(this.currentStage=e,e>=2&&this.runtimeStagePromise.resolve(),e>=3&&this.dynamicStagePromise.resolve())}getStagePromise(e){switch(e){case 2:return this.runtimeStagePromise.promise;case 3:return this.dynamicStagePromise.promise;default:throw Object.defineProperty(new a.InvariantError(`Invalid render stage: ${e}`),"__NEXT_ERROR_CODE",{value:"E881",enumerable:!1,configurable:!0})}}waitForStage(e){return this.getStagePromise(e)}delayUntilStage(e,t,r){var n,o,u;let a,i=(n=this.getStagePromise(e),o=t,u=r,a=new Promise((e,t)=>{n.then(e.bind(null,u),t)}),void 0!==o&&(a.displayName=o),a);return this.abortSignal&&i.catch(c),i}}function c(){}},67673,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n,o,u={Postpone:function(){return A},PreludeState:function(){return J},abortAndThrowOnSynchronousRequestDataAccess:function(){return w},abortOnSynchronousPlatformIOAccess:function(){return T},accessedDynamicData:function(){return I},annotateDynamicAccess:function(){return B},consumeDynamicAccess:function(){return L},createDynamicTrackingState:function(){return v},createDynamicValidationState:function(){return m},createHangingInputAbortSignal:function(){return H},createRenderInBrowserAbortSignal:function(){return F},delayUntilRuntimeStage:function(){return ee},formatDynamicAPIAccesses:function(){return $},getFirstDynamicReason:function(){return E},isDynamicPostpone:function(){return N},isPrerenderInterruptedError:function(){return U},logDisallowedDynamicError:function(){return Q},markCurrentScopeAsDynamic:function(){return R},postponeWithTracking:function(){return M},throwIfDisallowedDynamic:function(){return Z},throwToInterruptStaticGeneration:function(){return O},trackAllowedDynamicAccess:function(){return V},trackDynamicDataInDynamicRender:function(){return S},trackSynchronousPlatformIOAccessInDev:function(){return j},useDynamicRouteParams:function(){return X},useDynamicSearchParams:function(){return W}};for(var a in u)Object.defineProperty(r,a,{enumerable:!0,get:u[a]});let i=(n=e.r(71645))&&n.__esModule?n:{default:n},s=e.r(76353),l=e.r(43248),c=e.r(62141),f=e.r(63599),d=e.r(63138),p=e.r(54839),_=e.r(29419),h=e.r(32061),y=e.r(12718),b=e.r(42852),g="function"==typeof i.default.unstable_postpone;function v(e){return{isDebugDynamicAccesses:e,dynamicAccesses:[],syncDynamicErrorWithStack:null}}function m(){return{hasSuspenseAboveBody:!1,hasDynamicMetadata:!1,hasDynamicViewport:!1,hasAllowedDynamic:!1,dynamicErrors:[]}}function E(e){var t;return null==(t=e.dynamicAccesses[0])?void 0:t.expression}function R(e,t,r){if(t)switch(t.type){case"cache":case"unstable-cache":case"private-cache":return}if(!e.forceDynamic&&!e.forceStatic){if(e.dynamicShouldError)throw Object.defineProperty(new l.StaticGenBailoutError(`Route ${e.route} with \`dynamic = "error"\` couldn't be rendered statically because it used \`${r}\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering`),"__NEXT_ERROR_CODE",{value:"E553",enumerable:!1,configurable:!0});if(t)switch(t.type){case"prerender-ppr":return M(e.route,r,t.dynamicTracking);case"prerender-legacy":t.revalidate=0;let n=Object.defineProperty(new s.DynamicServerError(`Route ${e.route} couldn't be rendered statically because it used ${r}. See more info here: https://nextjs.org/docs/messages/dynamic-server-error`),"__NEXT_ERROR_CODE",{value:"E550",enumerable:!1,configurable:!0});throw e.dynamicUsageDescription=r,e.dynamicUsageStack=n.stack,n}}}function O(e,t,r){let n=Object.defineProperty(new s.DynamicServerError(`Route ${t.route} couldn't be rendered statically because it used \`${e}\`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error`),"__NEXT_ERROR_CODE",{value:"E558",enumerable:!1,configurable:!0});throw r.revalidate=0,t.dynamicUsageDescription=e,t.dynamicUsageStack=n.stack,n}function S(e){switch(e.type){case"cache":case"unstable-cache":case"private-cache":return}}function P(e,t,r){let n=k(`Route ${e} needs to bail out of prerendering at this point because it used ${t}.`);r.controller.abort(n);let o=r.dynamicTracking;o&&o.dynamicAccesses.push({stack:o.isDebugDynamicAccesses?Error().stack:void 0,expression:t})}function T(e,t,r,n){let o=n.dynamicTracking;P(e,t,n),o&&null===o.syncDynamicErrorWithStack&&(o.syncDynamicErrorWithStack=r)}function j(e){e.stagedRendering&&e.stagedRendering.advanceStage(b.RenderStage.Dynamic)}function w(e,t,r,n){if(!1===n.controller.signal.aborted){P(e,t,n);let o=n.dynamicTracking;o&&null===o.syncDynamicErrorWithStack&&(o.syncDynamicErrorWithStack=r)}throw k(`Route ${e} needs to bail out of prerendering at this point because it used ${t}.`)}function A({reason:e,route:t}){let r=c.workUnitAsyncStorage.getStore();M(t,e,r&&"prerender-ppr"===r.type?r.dynamicTracking:null)}function M(e,t,r){(function(){if(!g)throw Object.defineProperty(Error("Invariant: React.unstable_postpone is not defined. This suggests the wrong version of React was loaded. This is a bug in Next.js"),"__NEXT_ERROR_CODE",{value:"E224",enumerable:!1,configurable:!0})})(),r&&r.dynamicAccesses.push({stack:r.isDebugDynamicAccesses?Error().stack:void 0,expression:t}),i.default.unstable_postpone(D(e,t))}function D(e,t){return`Route ${e} needs to bail out of prerendering at this point because it used ${t}. React throws this special object to indicate where. It should not be caught by your own try/catch. Learn more: https://nextjs.org/docs/messages/ppr-caught-error`}function N(e){return"object"==typeof e&&null!==e&&"string"==typeof e.message&&C(e.message)}function C(e){return e.includes("needs to bail out of prerendering at this point because it used")&&e.includes("Learn more: https://nextjs.org/docs/messages/ppr-caught-error")}if(!1===C(D("%%%","^^^")))throw Object.defineProperty(Error("Invariant: isDynamicPostpone misidentified a postpone reason. This is a bug in Next.js"),"__NEXT_ERROR_CODE",{value:"E296",enumerable:!1,configurable:!0});let x="NEXT_PRERENDER_INTERRUPTED";function k(e){let t=Object.defineProperty(Error(e),"__NEXT_ERROR_CODE",{value:"E394",enumerable:!1,configurable:!0});return t.digest=x,t}function U(e){return"object"==typeof e&&null!==e&&e.digest===x&&"name"in e&&"message"in e&&e instanceof Error}function I(e){return e.length>0}function L(e,t){return e.dynamicAccesses.push(...t.dynamicAccesses),e.dynamicAccesses}function $(e){return e.filter(e=>"string"==typeof e.stack&&e.stack.length>0).map(({expression:e,stack:t})=>(t=t.split("\n").slice(4).filter(e=>!(e.includes("node_modules/next/")||e.includes(" (<anonymous>)")||e.includes(" (node:"))).join("\n"),`Dynamic API Usage Debug - ${e}:
|
| 2 |
+
${t}`))}function F(){let e=new AbortController;return e.abort(Object.defineProperty(new h.BailoutToCSRError("Render in Browser"),"__NEXT_ERROR_CODE",{value:"E721",enumerable:!1,configurable:!0})),e.signal}function H(e){switch(e.type){case"prerender":case"prerender-runtime":let t=new AbortController;if(e.cacheSignal)e.cacheSignal.inputReady().then(()=>{t.abort()});else{let r=(0,c.getRuntimeStagePromise)(e);r?r.then(()=>(0,_.scheduleOnNextTick)(()=>t.abort())):(0,_.scheduleOnNextTick)(()=>t.abort())}return t.signal;case"prerender-client":case"prerender-ppr":case"prerender-legacy":case"request":case"cache":case"private-cache":case"unstable-cache":return}}function B(e,t){let r=t.dynamicTracking;r&&r.dynamicAccesses.push({stack:r.isDebugDynamicAccesses?Error().stack:void 0,expression:e})}function X(e){let t=f.workAsyncStorage.getStore(),r=c.workUnitAsyncStorage.getStore();if(t&&r)switch(r.type){case"prerender-client":case"prerender":{let n=r.fallbackRouteParams;n&&n.size>0&&i.default.use((0,d.makeHangingPromise)(r.renderSignal,t.route,e));break}case"prerender-ppr":{let n=r.fallbackRouteParams;if(n&&n.size>0)return M(t.route,e,r.dynamicTracking);break}case"prerender-runtime":throw Object.defineProperty(new y.InvariantError(`\`${e}\` was called during a runtime prerender. Next.js should be preventing ${e} from being included in server components statically, but did not in this case.`),"__NEXT_ERROR_CODE",{value:"E771",enumerable:!1,configurable:!0});case"cache":case"private-cache":throw Object.defineProperty(new y.InvariantError(`\`${e}\` was called inside a cache scope. Next.js should be preventing ${e} from being included in server components statically, but did not in this case.`),"__NEXT_ERROR_CODE",{value:"E745",enumerable:!1,configurable:!0})}}function W(e){let t=f.workAsyncStorage.getStore(),r=c.workUnitAsyncStorage.getStore();if(t)switch(!r&&(0,c.throwForMissingRequestStore)(e),r.type){case"prerender-client":i.default.use((0,d.makeHangingPromise)(r.renderSignal,t.route,e));break;case"prerender-legacy":case"prerender-ppr":if(t.forceStatic)return;throw Object.defineProperty(new h.BailoutToCSRError(e),"__NEXT_ERROR_CODE",{value:"E394",enumerable:!1,configurable:!0});case"prerender":case"prerender-runtime":throw Object.defineProperty(new y.InvariantError(`\`${e}\` was called from a Server Component. Next.js should be preventing ${e} from being included in server components statically, but did not in this case.`),"__NEXT_ERROR_CODE",{value:"E795",enumerable:!1,configurable:!0});case"cache":case"unstable-cache":case"private-cache":throw Object.defineProperty(new y.InvariantError(`\`${e}\` was called inside a cache scope. Next.js should be preventing ${e} from being included in server components statically, but did not in this case.`),"__NEXT_ERROR_CODE",{value:"E745",enumerable:!1,configurable:!0});case"request":return}}let G=/\n\s+at Suspense \(<anonymous>\)/,Y=RegExp(`\\n\\s+at Suspense \\(<anonymous>\\)(?:(?!\\n\\s+at (?:body|div|main|section|article|aside|header|footer|nav|form|p|span|h1|h2|h3|h4|h5|h6) \\(<anonymous>\\))[\\s\\S])*?\\n\\s+at ${p.ROOT_LAYOUT_BOUNDARY_NAME} \\([^\\n]*\\)`),q=RegExp(`\\n\\s+at ${p.METADATA_BOUNDARY_NAME}[\\n\\s]`),K=RegExp(`\\n\\s+at ${p.VIEWPORT_BOUNDARY_NAME}[\\n\\s]`),z=RegExp(`\\n\\s+at ${p.OUTLET_BOUNDARY_NAME}[\\n\\s]`);function V(e,t,r,n){if(!z.test(t)){if(q.test(t)){r.hasDynamicMetadata=!0;return}if(K.test(t)){r.hasDynamicViewport=!0;return}if(Y.test(t)){r.hasAllowedDynamic=!0,r.hasSuspenseAboveBody=!0;return}else if(G.test(t)){r.hasAllowedDynamic=!0;return}else{var o,u;let a;if(n.syncDynamicErrorWithStack)return void r.dynamicErrors.push(n.syncDynamicErrorWithStack);let i=(o=`Route "${e.route}": Uncached data was accessed outside of <Suspense>. This delays the entire page from rendering, resulting in a slow user experience. Learn more: https://nextjs.org/docs/messages/blocking-route`,u=t,(a=Object.defineProperty(Error(o),"__NEXT_ERROR_CODE",{value:"E394",enumerable:!1,configurable:!0})).stack=a.name+": "+o+u,a);return void r.dynamicErrors.push(i)}}}var J=((o={})[o.Full=0]="Full",o[o.Empty=1]="Empty",o[o.Errored=2]="Errored",o);function Q(e,t){console.error(t),e.dev||(e.hasReadableErrorStacks?console.error(`To get a more detailed stack trace and pinpoint the issue, start the app in development mode by running \`next dev\`, then open "${e.route}" in your browser to investigate the error.`):console.error(`To get a more detailed stack trace and pinpoint the issue, try one of the following:
|
| 3 |
+
- Start the app in development mode by running \`next dev\`, then open "${e.route}" in your browser to investigate the error.
|
| 4 |
+
- Rerun the production build with \`next build --debug-prerender\` to generate better stack traces.`))}function Z(e,t,r,n){if(n.syncDynamicErrorWithStack)throw Q(e,n.syncDynamicErrorWithStack),new l.StaticGenBailoutError;if(0!==t){if(r.hasSuspenseAboveBody)return;let n=r.dynamicErrors;if(n.length>0){for(let t=0;t<n.length;t++)Q(e,n[t]);throw new l.StaticGenBailoutError}if(r.hasDynamicViewport)throw console.error(`Route "${e.route}" has a \`generateViewport\` that depends on Request data (\`cookies()\`, etc...) or uncached external data (\`fetch(...)\`, etc...) without explicitly allowing fully dynamic rendering. See more info here: https://nextjs.org/docs/messages/next-prerender-dynamic-viewport`),new l.StaticGenBailoutError;if(1===t)throw console.error(`Route "${e.route}" did not produce a static shell and Next.js was unable to determine a reason. This is a bug in Next.js.`),new l.StaticGenBailoutError}else if(!1===r.hasAllowedDynamic&&r.hasDynamicMetadata)throw console.error(`Route "${e.route}" has a \`generateMetadata\` that depends on Request data (\`cookies()\`, etc...) or uncached external data (\`fetch(...)\`, etc...) when the rest of the route does not. See more info here: https://nextjs.org/docs/messages/next-prerender-dynamic-metadata`),new l.StaticGenBailoutError}function ee(e,t){return e.runtimeStagePromise?e.runtimeStagePromise.then(()=>t):t}},91414,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"unstable_rethrow",{enumerable:!0,get:function(){return function e(t){if((0,a.isNextRouterError)(t)||(0,u.isBailoutToCSRError)(t)||(0,s.isDynamicServerError)(t)||(0,i.isDynamicPostpone)(t)||(0,o.isPostpone)(t)||(0,n.isHangingPromiseRejectionError)(t)||(0,i.isPrerenderInterruptedError)(t))throw t;t instanceof Error&&"cause"in t&&e(t.cause)}}});let n=e.r(63138),o=e.r(67287),u=e.r(32061),a=e.r(65713),i=e.r(67673),s=e.r(76353);("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},90508,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"unstable_rethrow",{enumerable:!0,get:function(){return n}});let n="undefined"==typeof window?e.r(91414).unstable_rethrow:e.r(15507).unstable_rethrow;("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},92805,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={ReadonlyURLSearchParams:function(){return u.ReadonlyURLSearchParams},RedirectType:function(){return i.RedirectType},forbidden:function(){return l.forbidden},notFound:function(){return s.notFound},permanentRedirect:function(){return a.permanentRedirect},redirect:function(){return a.redirect},unauthorized:function(){return c.unauthorized},unstable_isUnrecognizedActionError:function(){return d},unstable_rethrow:function(){return f.unstable_rethrow}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(3680),a=e.r(24063),i=e.r(68391),s=e.r(22783),l=e.r(79854),c=e.r(22683),f=e.r(90508);function d(){throw Object.defineProperty(Error("`unstable_isUnrecognizedActionError` can only be used on the client."),"__NEXT_ERROR_CODE",{value:"E776",enumerable:!1,configurable:!0})}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},76562,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={ReadonlyURLSearchParams:function(){return d.ReadonlyURLSearchParams},RedirectType:function(){return d.RedirectType},ServerInsertedHTMLContext:function(){return c.ServerInsertedHTMLContext},forbidden:function(){return d.forbidden},notFound:function(){return d.notFound},permanentRedirect:function(){return d.permanentRedirect},redirect:function(){return d.redirect},unauthorized:function(){return d.unauthorized},unstable_isUnrecognizedActionError:function(){return f.unstable_isUnrecognizedActionError},unstable_rethrow:function(){return d.unstable_rethrow},useParams:function(){return g},usePathname:function(){return y},useRouter:function(){return b},useSearchParams:function(){return h},useSelectedLayoutSegment:function(){return m},useSelectedLayoutSegments:function(){return v},useServerInsertedHTML:function(){return c.useServerInsertedHTML}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(90809)._(e.r(71645)),a=e.r(8372),i=e.r(61994),s=e.r(13258),l=e.r(3680),c=e.r(13957),f=e.r(92838),d=e.r(92805),p="undefined"==typeof window?e.r(67673).useDynamicRouteParams:void 0,_="undefined"==typeof window?e.r(67673).useDynamicSearchParams:void 0;function h(){_?.("useSearchParams()");let e=(0,u.useContext)(i.SearchParamsContext);return(0,u.useMemo)(()=>e?new l.ReadonlyURLSearchParams(e):null,[e])}function y(){return p?.("usePathname()"),(0,u.useContext)(i.PathnameContext)}function b(){let e=(0,u.useContext)(a.AppRouterContext);if(null===e)throw Object.defineProperty(Error("invariant expected app router to be mounted"),"__NEXT_ERROR_CODE",{value:"E238",enumerable:!1,configurable:!0});return e}function g(){return p?.("useParams()"),(0,u.useContext)(i.PathParamsContext)}function v(e="children"){p?.("useSelectedLayoutSegments()");let t=(0,u.useContext)(a.LayoutRouterContext);return t?(0,s.getSelectedLayoutSegmentPath)(t.parentTree,e):null}function m(e="children"){p?.("useSelectedLayoutSegment()"),(0,u.useContext)(i.NavigationPromisesContext);let t=v(e);return(0,s.computeSelectedLayoutSegment)(t,e)}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},58442,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={RedirectBoundary:function(){return p},RedirectErrorBoundary:function(){return d}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(90809),a=e.r(43476),i=u._(e.r(71645)),s=e.r(76562),l=e.r(24063),c=e.r(68391);function f({redirect:e,reset:t,redirectType:r}){let n=(0,s.useRouter)();return(0,i.useEffect)(()=>{i.default.startTransition(()=>{r===c.RedirectType.push?n.push(e,{}):n.replace(e,{}),t()})},[e,r,t,n]),null}class d extends i.default.Component{constructor(e){super(e),this.state={redirect:null,redirectType:null}}static getDerivedStateFromError(e){if((0,c.isRedirectError)(e))return{redirect:(0,l.getURLFromRedirectError)(e),redirectType:(0,l.getRedirectTypeFromError)(e)};throw e}render(){let{redirect:e,redirectType:t}=this.state;return null!==e&&null!==t?(0,a.jsx)(f,{redirect:e,redirectType:t,reset:()=>this.setState({redirect:null})}):this.props.children}}function p({children:e}){let t=(0,s.useRouter)();return(0,a.jsx)(d,{router:t,children:e})}("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},1244,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"unresolvedThenable",{enumerable:!0,get:function(){return n}});let n={then:()=>{}};("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)},97367,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0});var n={MetadataBoundary:function(){return i},OutletBoundary:function(){return l},RootLayoutBoundary:function(){return c},ViewportBoundary:function(){return s}};for(var o in n)Object.defineProperty(r,o,{enumerable:!0,get:n[o]});let u=e.r(54839),a={[u.METADATA_BOUNDARY_NAME]:function({children:e}){return e},[u.VIEWPORT_BOUNDARY_NAME]:function({children:e}){return e},[u.OUTLET_BOUNDARY_NAME]:function({children:e}){return e},[u.ROOT_LAYOUT_BOUNDARY_NAME]:function({children:e}){return e}},i=a[u.METADATA_BOUNDARY_NAME.slice(0)],s=a[u.VIEWPORT_BOUNDARY_NAME.slice(0)],l=a[u.OUTLET_BOUNDARY_NAME.slice(0)],c=a[u.ROOT_LAYOUT_BOUNDARY_NAME.slice(0)]},84356,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"hasInterceptionRouteInCurrentTree",{enumerable:!0,get:function(){return function e([t,r]){if(Array.isArray(t)&&("di"===t[2]||"ci"===t[2])||"string"==typeof t&&(0,n.isInterceptionRouteAppPath)(t))return!0;if(r){for(let t in r)if(e(r[t]))return!0}return!1}}});let n=e.r(91463);("function"==typeof r.default||"object"==typeof r.default&&null!==r.default)&&void 0===r.default.__esModule&&(Object.defineProperty(r.default,"__esModule",{value:!0}),Object.assign(r.default,r),t.exports=r.default)}]);
|