XiaoBai1221 commited on
Commit
921a78a
·
1 Parent(s): d96697e
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.production.example +0 -63
  2. app.py +21 -5
  3. bloom-ware-login/app/icon.svg +54 -0
  4. bloom-ware-login/app/layout.tsx +0 -2
  5. bloom-ware-login/components/login-form.tsx +110 -5
  6. bloom-ware-login/next.config.mjs +4 -0
  7. bloom-ware-login/package-lock.json +0 -0
  8. bloom-ware-login/tsconfig.json +19 -5
  9. core/database/cache.py +8 -0
  10. features/mcp/agent_bridge.py +163 -11
  11. features/mcp/auto_registry.py +33 -18
  12. features/mcp/coordinator.py +16 -3
  13. features/mcp/tools/base_tool.py +7 -0
  14. features/mcp/tools/geocode_tool.py +139 -114
  15. features/mcp/tools/tdx_base.py +137 -24
  16. features/mcp/tools/tdx_bus_arrival.py +501 -175
  17. features/mcp/tools/tdx_metro.py +117 -20
  18. features/mcp/tools/tdx_parking.py +121 -20
  19. features/mcp/tools/tdx_thsr.py +85 -15
  20. features/mcp/tools/tdx_train.py +209 -55
  21. features/mcp/tools/tdx_youbike.py +149 -23
  22. features/mcp_config.json +52 -31
  23. services/ai_service.py +30 -18
  24. static/frontend/index.html +198 -534
  25. static/frontend/js/agent.js +4 -82
  26. static/frontend/js/app.js +12 -16
  27. static/frontend/js/canvas.js +6 -67
  28. static/frontend/js/location.js +220 -0
  29. static/frontend/js/login.js +0 -730
  30. static/frontend/js/tools.js +208 -258
  31. static/frontend/js/tts.js +1 -88
  32. static/frontend/js/ui.js +89 -138
  33. static/frontend/js/websocket.js +49 -375
  34. static/frontend/login.html +0 -547
  35. static/frontend/login/404.html +1 -0
  36. static/frontend/login/404/index.html +1 -0
  37. static/frontend/login/__next.__PAGE__.txt +17 -0
  38. static/frontend/login/__next._full.txt +27 -0
  39. static/frontend/login/__next._index.txt +6 -0
  40. static/frontend/login/__next._tree.txt +13 -0
  41. static/frontend/login/_next/static/JHSefKGBAlaH-P1I6_EFb/_buildManifest.js +11 -0
  42. static/frontend/login/_next/static/JHSefKGBAlaH-P1I6_EFb/_clientMiddlewareManifest.json +1 -0
  43. static/frontend/login/_next/static/JHSefKGBAlaH-P1I6_EFb/_ssgManifest.js +1 -0
  44. static/frontend/login/_next/static/chunks/1f600c83a5ecf168.css +3 -0
  45. static/frontend/login/_next/static/chunks/42879de7b8087bc9.js +1 -0
  46. static/frontend/login/_next/static/chunks/66925481c7f9f43e.js +0 -0
  47. static/frontend/login/_next/static/chunks/752a4bd6387d6ef7.js +1 -0
  48. static/frontend/login/_next/static/chunks/9b0c5fad00d39a77.js +1 -0
  49. static/frontend/login/_next/static/chunks/a6dad97d9634a72d.js +0 -0
  50. 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="/static/login.html", status_code=307)
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"/static/login.html?{error_params}", status_code=302)
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"/static/login.html?error={error}&state={state or ''}",
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"/static/login.html?code={code}&state={state or ''}&scope={scope or ''}"
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="/static/login.html?error=callback_error", status_code=302)
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
- const handleGoogleLogin = () => {
9
- // Google login logic here
10
- console.log("Google login clicked")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  }
12
 
13
  const handleVoiceLogin = () => {
14
- // Voice login logic here
15
- console.log("Voice login clicked")
 
 
 
 
 
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": ["dom", "dom.iterable", "esnext"],
 
 
 
 
4
  "allowJs": true,
5
  "target": "ES6",
6
  "skipLibCheck": true,
@@ -11,7 +15,7 @@
11
  "moduleResolution": "bundler",
12
  "resolveJsonModule": true,
13
  "isolatedModules": true,
14
- "jsx": "preserve",
15
  "incremental": true,
16
  "plugins": [
17
  {
@@ -19,9 +23,19 @@
19
  }
20
  ],
21
  "paths": {
22
- "@/*": ["./*"]
 
 
23
  }
24
  },
25
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26
- "exclude": ["node_modules"]
 
 
 
 
 
 
 
 
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
- intent_data = json.loads(response.strip())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if value.isdigit():
562
- normalized_value = int(value)
563
- else:
 
 
 
 
 
 
 
 
 
 
564
  lower_value = value.lower()
565
  if lower_value in ("true", "false"):
566
  normalized_value = lower_value == "true"
567
- else:
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
- always_format_for_conversation = ['exchange_query', 'weather_query', 'healthkit_query', 'news_query']
 
 
 
 
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
- for py_file in tools_path.glob("*_tool.py"):
 
 
 
 
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
- # 使用類別的execute方法(需要實例化)
162
- async def wrapper(arguments):
163
- instance = tool_class()
164
- return await instance.execute(arguments)
165
- handler = wrapper
 
 
 
 
 
 
 
 
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
- logger.warning(f"工具名稱重複,跳過: {tool.name}")
 
 
 
 
 
 
 
 
 
 
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.warning(f"外部工具名稱重複,跳過: {tool_name}")
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
- if field in env_ctx:
77
- merged[field] = env_ctx[field]
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, # 提高 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
- async with aiohttp.ClientSession(headers=headers) as session:
118
- async with session.get(url, params=params, timeout=10) as resp:
119
- if resp.status != 200:
120
- raise ExecutionError(f"Nominatim 失敗: {resp.status}")
121
- data = await resp.json()
122
-
123
- addr = data.get("address", {})
124
- extratags = data.get("extratags", {})
125
-
126
- # 基本地址組件
127
- road = addr.get("road") or addr.get("pedestrian") or addr.get("footway") or addr.get("cycleway") or ""
128
- house_number = addr.get("house_number") or ""
129
- suburb = addr.get("suburb") or addr.get("neighbourhood") or addr.get("quarter") or ""
130
- city_district = addr.get("city_district") or ""
131
- city = addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county") or ""
132
- admin = addr.get("state") or addr.get("county") or ""
133
- country_code = (addr.get("country_code") or "").upper()
134
- postcode = addr.get("postcode") or ""
135
-
136
- # POI 資訊(商店、建築物、設施等)
137
- amenity = addr.get("amenity") or extratags.get("amenity") or ""
138
- shop = addr.get("shop") or extratags.get("shop") or ""
139
- building = addr.get("building") or extratags.get("building") or ""
140
- office = addr.get("office") or extratags.get("office") or ""
141
- leisure = addr.get("leisure") or extratags.get("leisure") or ""
142
- tourism = addr.get("tourism") or extratags.get("tourism") or ""
143
-
144
- # 地點名稱(優先使用繁中)
145
- name = data.get("name") or ""
146
- namedetails = data.get("namedetails", {})
147
- name_zh = namedetails.get("name:zh") or namedetails.get("name:zh-TW") or name
148
-
149
- display_name = data.get("display_name") or ""
150
-
151
- # 組裝精確標籤(優先顯示最精確的資訊)
152
- label_parts = []
153
-
154
- # 1. POI 名稱(如「7-11 明倫門市」「台北101」)
155
- if name_zh and name_zh != road:
156
- label_parts.append(name_zh)
157
-
158
- # 2. 門牌號碼 + 路名(如「中正路123號」)
159
- if road and house_number:
160
- label_parts.append(f"{road}{house_number}號")
161
- elif road:
162
- # 如果沒有門牌,但有路口資訊
163
- if "路口" in road or "交叉口" in road or "intersection" in road.lower():
164
- label_parts.append(road)
165
- else:
166
- # 嘗試從附近找路口
167
- label_parts.append(road)
168
-
169
- # 3. 郵遞區號(如「100」)
170
- if postcode and len(label_parts) > 0:
171
- label_parts[0] = f"〒{postcode} {label_parts[0]}"
172
-
173
- # 4. 區域(如「大安區」)
174
- if city_district and city_district not in label_parts:
175
- label_parts.append(city_district)
176
- elif suburb and suburb not in label_parts:
177
- label_parts.append(suburb)
178
-
179
- # 5. 城市(如「台北市」)
180
- if city and city not in label_parts:
181
- label_parts.append(city)
182
-
183
- # 6. 省份/州(如「台灣」)
184
- if admin and admin not in city and admin not in label_parts:
185
- label_parts.append(admin)
186
-
187
- label = ", ".join(filter(None, label_parts))
188
-
189
- # 組裝詳細地址(用於 AI 顯示)
190
- detailed_address_parts = []
191
- if name_zh:
192
- detailed_address_parts.append(f"地點: {name_zh}")
193
- if road and house_number:
194
- detailed_address_parts.append(f"地址: {road}{house_number}號")
195
- elif road:
196
- detailed_address_parts.append(f"路段: {road}")
197
- if suburb:
198
- detailed_address_parts.append(f"區域: {suburb}")
199
- if city:
200
- detailed_address_parts.append(f"城市: {city}")
201
- if postcode:
202
- detailed_address_parts.append(f"郵遞區號: {postcode}")
203
-
204
- detailed_address = " | ".join(detailed_address_parts) if detailed_address_parts else label
205
-
206
- payload = {
207
- "lat": lat, # ← 新增:補上座標
208
- "lon": lon, # 新增:補上座標
209
- "city": city or "",
210
- "admin": admin or "",
211
- "country_code": country_code,
212
- "display_name": display_name,
213
- "label": label or display_name,
214
- "detailed_address": detailed_address,
215
- "road": road,
216
- "house_number": house_number,
217
- "suburb": suburb,
218
- "city_district": city_district,
219
- "postcode": postcode,
220
- "amenity": amenity,
221
- "shop": shop,
222
- "building": building,
223
- "office": office,
224
- "leisure": leisure,
225
- "tourism": tourism,
226
- "name": name_zh or name,
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
- TDX_BASE_URL = "https://tdx.transportdata.tw/api/basic/v2"
 
 
 
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(快取 1 小時)"""
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
- if not TDX_CLIENT_ID or not TDX_CLIENT_SECRET:
 
 
 
 
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": TDX_CLIENT_ID,
45
- "client_secret": TDX_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(cls, endpoint: str, params: Optional[Dict[str, Any]] = None,
76
- cache_ttl: int = 60) -> Any:
77
- """呼叫 TDX API 並處理快取"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  access_token = await cls.get_access_token()
79
 
80
- url = f"{TDX_BASE_URL}/{endpoint}"
 
81
  headers = {
82
  "Authorization": f"Bearer {access_token}",
83
  "Accept": "application/json"
84
  }
85
 
 
 
 
 
 
 
86
  # 生成快取鍵
87
- cache_key = f"tdx:{endpoint}:{json.dumps(params or {}, sort_keys=True)}"
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(headers=headers) as session:
99
- async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=20)) as resp:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  if resp.status == 304:
101
  logger.info("TDX 資料未變更 (304)")
102
  return cached if cached else []
103
 
 
 
 
 
 
 
 
 
 
 
 
104
  if resp.status != 200:
105
- error_text = await resp.text()
106
- raise ExecutionError(f"TDX API 錯誤 {endpoint}: HTTP {resp.status} - {error_text[:200]}")
 
107
 
108
- data = await resp.json()
 
 
 
 
 
 
 
 
 
 
 
 
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
- raise ExecutionError(f"TDX API 逾時: {endpoint}")
 
 
119
  except aiohttp.ClientError as e:
120
- raise ExecutionError(f"TDX API 網路錯誤: {e}")
 
 
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
- dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
145
- return dt.strftime("%H:%M")
 
 
 
 
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": "string"},
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], user_id: str = None) -> Dict[str, Any]:
75
- route_name = arguments.get("route_name", "").strip()
76
- city = arguments.get("city")
 
 
77
  limit = min(int(arguments.get("limit", 5)), 20)
78
 
79
- # 1. 取得用戶位置
80
- env_ctx = await get_user_env_current(user_id) if user_id else None
81
- if not env_ctx or not env_ctx.get("success"):
82
- if not route_name:
83
- raise ExecutionError("無法取得您的位置,請提供路線名稱或開啟定位權限")
84
- user_lat, user_lon, user_city = None, None, None
85
- else:
86
- ctx = env_ctx.get("context", {})
87
- user_lat = ctx.get("lat")
88
- user_lon = ctx.get("lon")
89
- user_city = ctx.get("city", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
- # 2. 自動判斷城市
92
- if not city:
93
- city = cls._map_city_name(user_city) if user_city else "Taipei"
 
 
 
 
 
94
 
95
- # 3. 查詢邏輯分支
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(cls, route_name: str, city: str,
109
- user_lat: Optional[float], user_lon: Optional[float],
110
- limit: int) -> Dict[str, Any]:
111
- """查詢特定路線的即時到站"""
112
- # 1. 查詢路線基本資訊
113
- route_endpoint = f"Bus/Route/City/{city}"
114
- route_params = {
115
- "$filter": f"contains(RouteName/Zh_tw, '{route_name}')",
116
- "$format": "JSON",
117
- "$top": 1
118
- }
119
 
120
- routes = await TDXBaseAPI.call_api(route_endpoint, route_params, cache_ttl=3600)
 
 
 
 
121
 
122
- if not routes or len(routes) == 0:
123
- raise ExecutionError(f"找不到路線「{route_name}」,請確認路線名稱是否正確")
 
124
 
125
- route = routes[0]
126
- route_uid = route.get("RouteUID")
127
- full_route_name = route.get("RouteName", {}).get("Zh_tw", route_name)
 
 
 
 
 
 
 
 
 
128
 
129
- # 2. 查詢該路線所有站點
130
- stop_endpoint = f"Bus/StopOfRoute/City/{city}"
131
- stop_params = {
132
- "$filter": f"RouteUID eq '{route_uid}'",
133
- "$format": "JSON"
134
- }
135
 
136
- stops = await TDXBaseAPI.call_api(stop_endpoint, stop_params, cache_ttl=1800)
 
 
137
 
138
- if not stops:
139
- raise ExecutionError(f"路線「{full_route_name}」暫無站點資訊")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
- # 3. 如果有用戶位置,找最近的站點
 
 
 
 
 
 
 
142
  if user_lat and user_lon:
143
- for stop_seq in stops:
144
- for stop in stop_seq.get("Stops", []):
145
- pos = stop.get("StopPosition", {})
146
- if pos.get("PositionLat") and pos.get("PositionLon"):
147
- stop["distance_m"] = TDXBaseAPI.haversine_distance(
148
- user_lat, user_lon,
149
- pos["PositionLat"], pos["PositionLon"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  )
151
-
152
- # 取前 3 個最近的站點
153
- all_stops = []
154
- for stop_seq in stops:
155
- all_stops.extend(stop_seq.get("Stops", []))
156
-
157
- all_stops = [s for s in all_stops if "distance_m" in s]
158
- all_stops.sort(key=lambda x: x["distance_m"])
159
- target_stops = all_stops[:3]
160
- else:
161
- # 沒有位置,取前幾個站點
162
- target_stops = []
163
- for stop_seq in stops[:1]:
164
- target_stops.extend(stop_seq.get("Stops", [])[:limit])
 
 
165
 
166
- # 4. 查詢這些站點的即時到站
167
  arrivals = []
168
- for stop in target_stops:
169
- stop_uid = stop.get("StopUID")
170
- stop_name = stop.get("StopName", {}).get("Zh_tw", "未知")
 
171
 
172
- arrival_endpoint = f"Bus/EstimatedTimeOfArrival/City/{city}"
173
- arrival_params = {
174
- "$filter": f"RouteUID eq '{route_uid}' and StopUID eq '{stop_uid}'",
175
- "$format": "JSON"
176
- }
177
 
178
- arrival_data = await TDXBaseAPI.call_api(arrival_endpoint, arrival_params, cache_ttl=30)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
 
180
- for arr in arrival_data[:2]: # 每個站點最多 2 筆(雙向)
181
- estimate_time = arr.get("EstimateTime")
182
- stop_status = arr.get("StopStatus", 0)
183
-
184
- if stop_status == 0: # 正常
185
- status_text = f"{estimate_time // 60} 分鐘" if estimate_time else "即將進站"
186
- elif stop_status == 1: # 尚��發車
187
- status_text = "尚未發車"
188
- elif stop_status == 2: # 交管不停靠
189
- status_text = "交管不停靠"
190
- elif stop_status == 3: # 末班車已過
191
- status_text = "末班車已過"
192
- elif stop_status == 4: # 今日未營運
193
- status_text = "今日未營運"
194
- else:
195
- status_text = "未知"
196
-
197
- arrivals.append({
198
- "route_name": full_route_name,
199
- "stop_name": stop_name,
200
- "direction": arr.get("Direction", 0),
201
- "estimate_time": estimate_time,
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(cls, lat: float, lon: float, city: str, limit: int) -> Dict[str, Any]:
216
- """查詢附近公車站"""
217
- # TDX 附近站點查詢
 
 
 
 
 
 
 
 
 
218
  endpoint = f"Bus/Stop/City/{city}"
219
  params = {
220
- "$spatialFilter": f"nearby({lat}, {lon}, 300)", # 300m 範圍
221
- "$format": "JSON",
222
- "$top": limit * 3 # 多取一些,後續過濾
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="附近 300 公尺內沒有公車站,請擴大範圍或移動位置",
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" in s]
243
  stops.sort(key=lambda x: x["distance_m"])
244
- stops = stops[:limit]
245
 
246
- # 格式化結果
247
  results = []
 
 
248
  for stop in stops:
249
- stop_name = stop.get("StopName", {}).get("Zh_tw", "未知")
250
- distance = stop["distance_m"]
251
- walking_time = int(distance / 80) # 80m/min
 
252
 
 
253
  results.append({
254
- "stop_name": stop_name,
255
  "distance_m": int(distance),
256
- "walking_time_min": walking_time,
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
- @staticmethod
268
- def _map_city_name(chinese_city: str) -> str:
269
- """中文城市名稱轉 TDX 代碼"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  city_map = {
271
- "台北": "Taipei", "臺北": "Taipei",
272
  "新北": "NewTaipei", "新北市": "NewTaipei",
273
- "桃園": "Taoyuan",
274
- "台中": "Taichung", "臺中": "Taichung",
275
- "台南": "Tainan", "臺南": "Tainan",
276
- "高���": "Kaohsiung",
277
- "基隆": "Keelung",
278
- "新竹": "Hsinchu",
279
- "嘉義": "Chiayi"
 
 
 
 
 
 
 
 
 
 
 
 
280
  }
281
 
282
- for key, value in city_map.items():
283
- if key in chinese_city:
 
 
 
 
 
284
  return value
285
 
286
- return "Taipei" # 預設台北
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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} 即時到站資訊:\n"]
295
-
296
- # 按站點分組
297
- stops_dict = {}
298
  for arr in arrivals:
299
- stop = arr["stop_name"]
300
- if stop not in stops_dict:
301
- stops_dict[stop] = []
302
- stops_dict[stop].append(arr)
303
-
304
- for i, (stop_name, stop_arrivals) in enumerate(stops_dict.items(), 1):
 
 
 
 
305
  dist_info = ""
306
- if has_location and stop_arrivals[0].get("distance_m"):
307
- dist = stop_arrivals[0]["distance_m"]
308
- walk_time = int(dist / 80)
309
- dist_info = f" - 步行 {walk_time} 分鐘 ({int(dist)}m)"
310
-
311
- lines.append(f"{i}. 🚏 {stop_name}{dist_info}")
312
-
313
- for arr in stop_arrivals:
314
- direction = "往 ↑" if arr["direction"] == 0 else "返 ↓"
315
- lines.append(f" {direction} {arr['status']}")
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)\n"
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], user_id: str = None) -> 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
- env_ctx = await get_user_env_current(user_id) if user_id else None
86
- if not env_ctx or not env_ctx.get("success"):
87
- if not station_name:
88
- raise ExecutionError("無法取得您的位置,請提供車站名稱")
89
- user_lat, user_lon, user_city = None, None, None
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
- # 2. 自動判斷捷運系統
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  if not metro_system:
98
- metro_system = cls._detect_metro_system(user_city)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- station_endpoint = f"Metro/Station/{metro_system}"
 
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
- arrival_endpoint = f"Metro/LiveBoard/{metro_system}"
 
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
- station_endpoint = f"Metro/Station/{metro_system}"
 
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], user_id: str = None) -> 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
- env_ctx = await get_user_env_current(user_id) if user_id else None
95
- if not env_ctx or not env_ctx.get("success"):
96
- if not parking_name:
97
- raise ExecutionError("無法取得您的位置,請提供停車場名稱或開啟定位權限")
98
- user_lat, user_lon, user_city = None, None, None
99
- else:
100
- ctx = env_ctx.get("context", {})
101
- user_lat = ctx.get("lat")
102
- user_lon = ctx.get("lon")
103
- user_city = ctx.get("city", "")
104
-
105
- # 2. 自動判斷城市
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  if not city:
107
- city = cls._map_city_name(user_city) if user_city else "Taipei"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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], user_id: str = None) -> 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
- env_ctx = await get_user_env_current(user_id) if user_id else None
102
- user_lat, user_lon = None, None
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
- # 2. 查詢分支
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- endpoint = f"Rail/THSR/DailyTimetable/TrainDate/{date_str}"
 
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
- endpoint = f"Rail/THSR/DailyTimetable/TrainDate/{date_str}"
 
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], user_id: str = None) -> Dict[str, Any]:
85
- origin = arguments.get("origin_station", "").strip()
86
- destination = arguments.get("destination_station", "").strip()
87
- train_no = arguments.get("train_no", "").strip()
88
- departure_time = arguments.get("departure_time", "").strip()
 
 
 
 
 
 
 
 
 
 
 
89
  train_type = arguments.get("train_type")
90
  limit = min(int(arguments.get("limit", 5)), 20)
 
 
 
 
 
 
 
 
 
91
 
92
- # 1. 取得用戶位置(用於最近車站查詢)
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
- # 2. 查詢分支
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- raise ExecutionError("查詢最近車站需要定位權限,或請提供起迄站名稱")
 
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
- endpoint = "Rail/TRA/DailyTrainInfo/Today"
 
123
  params = {
124
- "$filter": f"TrainNo eq '{train_no}'",
125
  "$format": "JSON"
126
  }
127
 
128
- trains = await TDXBaseAPI.call_api(endpoint, params, cache_ttl=1800)
 
 
 
129
 
130
  if not trains:
131
  raise ExecutionError(f"找不到車次 {train_no},請確認車次號碼")
132
 
133
- train = trains[0]
134
- train_type = train.get("TrainTypeName", {}).get("Zh_tw", "未知")
 
 
 
 
135
 
136
  # 取得停靠站資訊
137
- stops = train.get("StopTimes", [])
138
 
139
  if not stops:
140
  raise ExecutionError(f"車次 {train_no} 無停靠站資訊")
141
 
142
  # 格式化時刻表
143
- schedule_lines = [f"🚂 {train_type} {train_no} 次\n"]
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": train, "stops": stops}
164
  )
165
 
 
 
 
 
 
 
166
  @classmethod
167
- async def _query_od_trains(cls, origin: str, destination: str,
 
 
 
 
 
 
 
 
168
  departure_time: Optional[str], train_type: Optional[str],
169
  limit: int) -> Dict[str, Any]:
170
  """查詢起迄站列車"""
171
- # 1. 先取得今日所有列車
172
- endpoint = "Rail/TRA/DailyTrainInfo/Today"
 
173
  params = {
174
  "$format": "JSON"
175
  }
176
-
177
- all_trains = await TDXBaseAPI.call_api(endpoint, params, cache_ttl=1800)
178
-
 
 
 
179
  if not all_trains:
180
  raise ExecutionError("無法取得台鐵列車資訊")
181
-
182
  # 2. 過濾符合起迄站的列車
183
  matching_trains = []
184
-
185
- for train in all_trains:
186
- stops = train.get("StopTimes", [])
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
- if origin in station:
 
193
  origin_idx = i
194
- if destination in station:
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": train.get("TrainNo"),
204
- "train_type": train.get("TrainTypeName", {}).get("Zh_tw", "未知"),
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
- raise ExecutionError(f"找不到 {origin} 到 {destination} 的直達列車")
 
 
 
 
 
 
 
 
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
- stations = await TDXBaseAPI.call_api(endpoint, params, cache_ttl=86400)
264
-
265
- if not stations:
 
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}\n"]
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']}次\n"
326
- f" {train['departure_time'][:5]} → {train['arrival_time'][:5]}"
327
- f" ({duration_str})\n"
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], user_id: str = None) -> Dict[str, Any]:
90
- station_name = arguments.get("station_name", "").strip()
 
 
 
 
 
 
 
 
 
 
 
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
- env_ctx = await get_user_env_current(user_id) if user_id else None
97
- if not env_ctx or not env_ctx.get("success"):
98
- if not station_name:
99
- raise ExecutionError("無法取得您的位置,請提供站點名稱或開啟定位權限")
100
- user_lat, user_lon, user_city = None, None, None
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
- # 2. 自動判斷城市
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  if not city:
109
- city = cls._map_city_name(user_city) if user_city else "Taipei"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- raise ExecutionError("查詢附近 YouBike 需要定位權限")
 
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": "YouBike 2.0" if "2.0" in target_station.get("BikesCapacity", "") else "YouBike 1.0"
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": "YouBike 2.0" if "2.0" in station.get("BikesCapacity", "") else "YouBike 1.0"
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") or "").strip()
161
- label = (ctx.get("label") or "").strip()
162
- address_display = (ctx.get("address_display") or "").strip()
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") or "").strip()
173
- house_number = (ctx.get("house_number") or "").strip()
174
- postcode = (ctx.get("postcode") or "").strip()
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") or "").strip()
184
- suburb = (ctx.get("suburb") or "").strip()
185
- city = (ctx.get("city") or "").strip()
186
- admin = (ctx.get("admin") or "").strip()
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") or "").strip()
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") or "").strip()
219
- shop = (ctx.get("shop") or "").strip()
220
- building = (ctx.get("building") or "").strip()
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") or "").strip()
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") or "").strip()
249
  if locale:
250
  parts.append(f"語系: {locale}")
251
 
252
- device = (ctx.get("device") or "").strip()
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: #FFF8E7;
19
  color: #1A1A1A;
20
  overflow: hidden;
21
  -webkit-font-smoothing: antialiased;
22
- /* 頁面載入淡入動畫 */
23
- animation: pageEnter 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
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
- inset: 0;
 
 
 
 
 
 
 
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: absolute;
150
- inset: 0;
 
 
 
 
 
151
  opacity: 0;
152
  transition: opacity 1.2s cubic-bezier(0.4, 0, 0.2, 1);
153
- z-index: 0;
 
154
  }
155
 
156
  .voice-immersive-background::before {
157
  content: '';
158
  position: absolute;
159
- inset: 0;
 
 
 
 
 
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%, #FFF8E7 100%);
170
  --petal-color: #FFFFFF;
171
  }
172
 
173
  .emotion-happy {
174
- --emotion-bg: linear-gradient(180deg, #FEF3C7 0%, #FFF8E7 100%);
175
  --petal-color: #FFFFFF;
176
  }
177
 
178
  .emotion-sad {
179
- --emotion-bg: linear-gradient(180deg, #E0E7FF 0%, #FFF8E7 100%);
180
  --petal-color: #FFFFFF;
181
  }
182
 
183
  .emotion-angry {
184
- --emotion-bg: linear-gradient(180deg, #FEE2E2 0%, #FFF8E7 100%);
185
  --petal-color: #FFFFFF;
186
  }
187
 
188
  .emotion-fear {
189
- --emotion-bg: linear-gradient(180deg, #EDE9FE 0%, #FFF8E7 100%);
190
  --petal-color: #FFFFFF;
191
  }
192
 
193
  .emotion-surprise {
194
- --emotion-bg: linear-gradient(180deg, #FFEDD5 0%, #FFF8E7 100%);
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: clamp(36px, 6vh, 72px);
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.94) 0%,
418
- rgba(254, 254, 254, 0.92) 40%,
419
- rgba(248, 249, 250, 0.88) 70%,
420
- rgba(241, 243, 245, 0.84) 100%
421
  ),
422
- linear-gradient(180deg,
423
- rgba(255, 255, 255, 0.93) 0%,
424
- rgba(253, 253, 253, 0.91) 30%,
425
- rgba(247, 248, 249, 0.87) 70%,
426
- rgba(240, 242, 244, 0.83) 100%
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
- transition: all 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
456
- /* 微弱但明顯的邊緣描邊:比原本稍暗,但仍保持細微、不突兀 */
457
- border: 1px solid rgba(0, 0, 0, 0.12);
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(12deg) translateY(-30px) scale(1.08);
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(12px);
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
- width: 100%; /* 填滿 wrapper(受 max-width 限制)*/
1041
- flex-shrink: 0;
1042
- padding: 18px 24px; /* 優化:進一步減少內距(20px 28px → 18px 24px)*/
1043
- background: rgba(255, 255, 255, 0.96);
1044
- border: 1.5px solid rgba(0, 0, 0, 0.06); /* 更細的邊框 */
1045
- border-radius: 12px; /* 優化:減少圓角(14px → 12px)*/
1046
- backdrop-filter: blur(24px) saturate(180%);
1047
- font-size: 17px; /* 優化:稍微縮小字體(18px → 17px)*/
1048
  font-weight: 400;
1049
  color: #1A1A1A;
1050
  text-align: center;
1051
- line-height: 1.5; /* 稍微緊湊(原 1.6)*/
1052
- min-height: 68px; /* 優化:減少最小高度(72px → 68px)*/
1053
- max-height: 130px; /* 優化:減少最大高度(140px → 130px)*/
1054
  display: flex;
1055
  align-items: center;
1056
  justify-content: center;
1057
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1058
- box-shadow: 0 3px 16px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04); /* 更柔和的陰影 */
1059
- outline: none;
1060
- resize: none;
1061
- overflow-y: auto;
1062
  }
1063
 
1064
  .voice-transcript.provisional {
1065
- color: rgba(0, 0, 0, 0.35); /* 稍微降低對比(原 0.4)*/
1066
  font-style: italic;
1067
  }
1068
 
1069
  .voice-transcript.final {
1070
  color: #1A1A1A;
1071
- border-color: rgba(0, 0, 0, 0.10); /* 稍微淡化(原 0.12)*/
1072
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.04);
1073
  }
1074
 
1075
  /* 文字輸入模式 */
1076
  .voice-transcript.text-input-mode {
1077
- cursor: text;
1078
- text-align: left;
1079
- border-color: rgba(59, 130, 246, 0.4); /* 稍微淡化聚焦顏色 */
1080
- box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.08), 0 4px 20px rgba(0, 0, 0, 0.08); /* 減少光暈範圍 */
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:focus {
1088
- border-color: rgba(59, 130, 246, 0.7); /* 稍微降低強度(原 0.8)*/
1089
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12), 0 6px 28px rgba(0, 0, 0, 0.12); /* 調整光暈 */
 
 
 
 
 
 
 
 
 
 
 
1090
  }
1091
 
1092
- .voice-transcript.text-input-mode:empty:before {
1093
- content: attr(data-placeholder);
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
- bottom: 40px;
1272
- left: 40px;
 
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: 24px;
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: clamp(24px, 5vh, 36px);
1311
- right: 24px;
1312
  z-index: 50;
1313
- padding: 12px 22px;
1314
  background: rgba(255, 255, 255, 0.95);
1315
  border: 1px solid rgba(0, 0, 0, 0.08);
1316
- border-radius: 12px;
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(12px);
1324
- box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12);
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
- 📱 RWD 響應式設計(2025 最佳實踐)
1386
- ======================================== */
1387
-
1388
- /* 工具卡片側邊欄(手機版) */
1389
- .tool-sidebar {
1390
  position: fixed;
1391
- right: -100%;
1392
  top: 0;
1393
- width: 90%;
1394
- max-width: 400px;
1395
  height: 100vh;
1396
- background: linear-gradient(160deg, rgba(255, 255, 255, 0.98), rgba(243, 244, 246, 0.95));
1397
- backdrop-filter: blur(22px);
1398
- border-left: 1px solid rgba(15, 23, 42, 0.08);
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
- cursor: pointer;
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
- .tool-sidebar-subtitle {
1454
- font-size: 13px;
1455
- color: rgba(15, 23, 42, 0.55);
1456
- line-height: 1.6;
1457
  }
1458
 
1459
- #tool-sidebar-cards {
1460
- display: flex;
1461
- flex-direction: column;
1462
- gap: 20px;
1463
- padding-bottom: 60px;
 
 
 
1464
  }
1465
 
1466
- .tool-sidebar .voice-tool-card {
 
 
 
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
- .tool-sidebar-toggle.active {
1493
- right: 90%;
1494
- max-width: 400px;
 
 
1495
  }
1496
 
1497
- .tool-sidebar-toggle.active::before {
1498
- content: '›';
 
1499
  }
1500
 
1501
- .tool-sidebar-toggle:not(.active)::before {
1502
- content: '‹';
 
 
 
 
1503
  }
1504
 
1505
- @keyframes pulse {
1506
- 0%, 100% { transform: scale(1); }
1507
- 50% { transform: scale(1.05); }
 
 
1508
  }
1509
 
1510
- .tool-sidebar-close {
1511
- position: absolute;
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
- font-size: 20px;
1524
- transition: all 0.2s;
1525
  }
1526
 
1527
- .tool-sidebar-close:hover {
1528
- background: rgba(0, 0, 0, 0.1);
 
 
 
1529
  }
1530
 
1531
- /* 平板與手機適配 */
1532
- @media (max-width: 1024px) {
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
- @media (max-width: 640px) {
1593
- /* 極小手機(<640px) */
1594
- .tool-sidebar {
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">ESC 退出</button>
1706
 
1707
- <!-- ChatWindow 折疊圖標(切換語音/文字模式)-->
1708
- <div class="chat-icon" id="modeToggleBtn" title="切換為文字輸入模式">💬</div>
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-wrapper">
1750
- <div
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
- const mappedEmotion = emotionAlias[normalized] || normalized;
154
- const finalEmotion = validEmotions.includes(mappedEmotion) ? mappedEmotion : 'neutral';
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
- try {
169
- const storedEmotion = localStorage.getItem('lastEmotion');
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 = '/static/login.html';
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 = '/static/login.html';
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 = '/static/login.html';
79
  return false;
80
  }
81
 
@@ -90,14 +79,21 @@ async function checkLoginStatus() {
90
  function initializeApp(token) {
91
  console.log('🚀 初始化應用...');
92
 
 
 
 
 
 
 
 
93
  // 初始化各個模組的事件監聽器
94
  initLoginButton();
95
- initExitButton(); // 初始化登出按鈕
 
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
- // 優先使用 TTS 音訊數據(如果正在播放 TTS)
144
- if (useTTSAudio && ttsAnalyserRef && ttsDataArrayRef) {
145
- ttsAnalyserRef.getByteFrequencyData(ttsDataArrayRef);
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
- // 優先處理 TTS 音訊
167
- if (useTTSAudio && ttsDataArrayRef && ttsBufferLengthRef > 0) {
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
- const desktopCards = cardsContainer.querySelectorAll('.voice-tool-card');
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
- 'exchange_query': '💱',
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 = 'voice-tool-card';
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
- if (isMobileMode()) {
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('✅ 匹配到模式 4: 匯率數據');
308
  return renderExchangeRate(toolData);
309
  }
310
 
311
- // 模式 5:地理定位數據(forward_geocode / reverse_geocode)
312
- // forward_geocode: best_match.lat/lon
313
- // reverse_geocode: 直接有 lat/lon + display_name
314
- if ((toolData.best_match && toolData.best_match.lat && toolData.best_match.lon) ||
315
- (toolData.lat !== undefined && toolData.lon !== undefined && toolData.display_name)) {
316
- console.log('✅ 匹配到模式 5: 地理定位數據');
317
- return renderLocationData(toolData);
 
 
 
318
  }
319
 
320
- // 模式 6:通用 raw_data 物件
321
  if (toolData.raw_data && typeof toolData.raw_data === 'object') {
322
- console.log('✅ 匹配到模式 6: 通用 raw_data');
323
  return renderKeyValuePairs(toolData.raw_data);
324
  }
325
 
@@ -408,26 +393,20 @@ function renderHealthMetrics(healthData) {
408
  }
409
 
410
  /**
411
- * 渲染新聞列表(顯示 AI 生成的簡短摘要)
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: 8px;">
422
- <span style="font-size: 14px; line-height: 1.5;">• ${displayText}</span>
 
423
  </div>
424
  `;
425
  });
426
 
427
- // 使用可滾動容器,固定高度為 3 條新聞的大小
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
- * 渲染地理定位數據(forward_geocode / reverse_geocode)
526
  */
527
- function renderLocationData(data) {
528
- // reverse_geocode: 扁平結構(欄位在第一層)
529
- // forward_geocode: 巢狀結構(best_match + results)
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
- // 地點名稱(POI、建築物等)
547
- if (bestMatch.name && bestMatch.name !== bestMatch.road) {
 
 
 
 
 
 
 
 
 
548
  html += `
549
- <div class="data-row">
550
- <span class="data-label">� 地點</span>
551
- <span class="data-value">${bestMatch.name}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
  </div>
553
  `;
 
 
 
 
 
 
 
 
 
 
 
 
554
  }
555
 
556
- // 地址(路名 + 門牌號)
557
- if (bestMatch.road) {
558
- const address = bestMatch.house_number
559
- ? `${bestMatch.road}${bestMatch.house_number}號`
560
- : bestMatch.road;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
561
  html += `
562
- <div class="data-row">
563
- <span class="data-label">� 地址</span>
564
- <span class="data-value">${address}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
  </div>
566
  `;
567
- }
 
 
 
 
568
 
569
- // 區域 + 城市
570
- const locationParts = [];
571
- if (bestMatch.suburb) locationParts.push(bestMatch.suburb);
572
- if (bestMatch.city_district && bestMatch.city_district !== bestMatch.suburb) {
573
- locationParts.push(bestMatch.city_district);
 
574
  }
575
- if (bestMatch.city) locationParts.push(bestMatch.city);
 
576
 
577
- if (locationParts.length > 0) {
578
- html += `
579
- <div class="data-row">
580
- <span class="data-label">📍 位置</span>
581
- <span class="data-value">${locationParts.join(', ')}</span>
582
- </div>
583
- `;
584
- }
 
585
 
586
- // 郵遞區號
587
- if (bestMatch.postcode) {
 
 
 
588
  html += `
589
- <div class="data-row">
590
- <span class="data-label">📮 郵遞區號</span>
591
- <span class="data-value">${bestMatch.postcode}</span>
592
- </div>
 
593
  `;
594
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
595
 
596
- // 如果有多個結果,顯示數量
597
- if (results.length > 1) {
598
- html += `
599
- <div class="data-row" style="margin-top: 8px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 8px;">
600
- <span class="data-label">📊 其他結果</span>
601
- <span class="data-value">共 ${results.length} 個地點</span>
602
- </div>
603
- `;
 
604
  }
605
 
606
- // Google Maps 連結(可選)
607
- if (bestMatch.lat && bestMatch.lon) {
608
- const mapsUrl = `https://www.google.com/maps?q=${bestMatch.lat},${bestMatch.lon}`;
 
 
 
609
  html += `
610
- <div class="data-row" style="margin-top: 8px;">
611
- <a href="${mapsUrl}" target="_blank" rel="noopener noreferrer"
612
- style="color: #4fc3f7; text-decoration: none; font-size: 0.9em;">
613
- 🗺️ Google Maps 中查看
614
- </a>
615
  </div>
616
  `;
617
- }
618
 
619
- return html || '<p>無地理數據</p>';
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 initExitButton() {
85
- const exitButton = document.querySelector('.exit-button');
86
- if (exitButton) {
87
- exitButton.addEventListener('click', handleLogout);
 
88
  }
89
  }
90
 
91
- async function handleLogout() {
92
- console.log('🚪 開始登出流程...');
93
 
94
- try {
95
- // 可選:呼叫後端登出 API(主要由前端清除 token)
96
- await fetch('/auth/logout', {
97
- method: 'POST',
98
- headers: {
99
- 'Content-Type': 'application/json'
100
- }
101
- });
102
- } catch (error) {
103
- console.warn('⚠️ 後端登出失敗(忽略):', error);
104
  }
105
 
106
- // 清除本地儲存的 token
107
- localStorage.removeItem('jwt_token');
108
- sessionStorage.clear();
 
109
 
110
- console.log('✅ 登出成功,跳轉至登入頁面...');
111
 
112
- // 跳轉到登入頁面
113
- window.location.href = '/static/login.html';
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
- const micContainer = document.getElementById('mic-container');
129
-
 
 
 
 
130
  if (isTextInputMode) {
131
- // === 切換到文字輸入模式 ===
132
- console.log('🔤 切換到文字輸入模式');
133
-
134
- // 停止語音錄音(如果正在錄音)
135
- if (currentState === 'recording') {
136
- console.log('⏹️ 停止語音錄音');
137
- if (typeof stopRealAudioAnalysis === 'function') {
138
- stopRealAudioAnalysis();
139
- }
140
- if (wsManager && typeof wsManager.stopRecording === 'function') {
141
- wsManager.stopRecording();
142
- }
143
- setState('idle');
144
- }
145
-
146
- // 禁用麥克風點擊,並讓麥克風容器變淡(但不影響波形)
147
- micContainer.style.pointerEvents = 'none';
148
- // 不要設置整體透明度,讓波形保持可見
149
- // micContainer.style.opacity = '0.3';
150
-
151
- // 添加文字輸入模式標記到body
152
- document.body.classList.add('text-input-active');
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
- document.body.classList.remove('text-input-active');
176
-
177
- // 停用文字輸入
178
- transcript.contentEditable = 'false';
179
- transcript.classList.remove('text-input-mode');
180
- transcript.classList.add('provisional');
 
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 transcript = document.getElementById('transcript');
206
- const message = transcript.textContent.trim();
207
-
208
- if (!message) {
209
- console.warn('⚠️ 訊息內容為空');
210
  return;
211
  }
212
 
213
- console.log('📤 發送文字訊息:', message);
214
 
215
- // 顯示為 final 狀態(讓用戶看到已發送)
216
- transcript.classList.remove('provisional');
217
- transcript.classList.add('final');
 
 
218
 
219
- // 發送訊息到 WebSocket
220
- if (wsManager && wsManager.isConnected()) {
221
- // 獲取當前 chat_id(從全域變數)
222
- const chatId = window.currentChatId;
223
 
224
- if (!chatId) {
225
- console.warn('⚠️ 當前沒有 chat_id,後端將自動創建新對話');
 
226
  }
227
 
228
- wsManager.sendUserMessage(message, chatId);
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
- try {
90
- const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
91
- const locale = navigator.language || 'zh-TW';
92
- const device = `${navigator.platform || ''}`;
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 = '/static/login.html';
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 = '/static/login.html';
206
  }
207
  } catch (e) {
208
  console.error('❌ Token 解析失敗,跳轉到登入頁面');
209
  localStorage.removeItem('jwt_token');
210
- window.location.href = '/static/login.html';
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 = '/static/login.html';
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 = '/static/login.html';
245
  return;
246
  }
247
  }
@@ -270,20 +221,21 @@ class WebSocketManager {
270
 
271
  // 發送用戶輸入
272
  sendUserMessage(text, chatId) {
273
- if (!text || !text.trim()) {
274
- console.warn('⚠️ 訊息內容為空');
275
  return false;
276
  }
277
 
278
- // chatId 可以為空,後端會自動創建或使用最新對話
279
  if (!chatId) {
280
- console.log('📝 發送訊息(無 chat_id,後端將自動處理)');
 
281
  }
282
 
283
  const payload = {
284
  type: 'user_message',
285
  message: text,
286
- chat_id: chatId || null
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
- // 如果系統訊息包含 chat_id,儲存它
668
  if (data.chat_id) {
669
- currentChatId = data.chat_id;
670
- window.currentChatId = currentChatId;
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
- const shouldShowToolCard = !inCareMode && data.tool_name && data.tool_data;
713
- if (shouldShowToolCard) {
714
  console.log('📊 準備顯示工具卡片:', data.tool_name);
715
  displayToolCard(data.tool_name, data.tool_data);
716
  } else {
717
- console.log('⚠️ 不顯示工具卡片:', inCareMode ? '關懷模式' : '缺少工具資料');
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
- const emotionValue = typeof data.emotion === 'string' ? data.emotion : data.emotion.label;
744
- console.log('😊 應用情緒主題:', emotionValue);
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 'new_chat_created':
763
- // 新對話建立
764
- currentChatId = data.chat_id;
765
- window.currentChatId = currentChatId; // 確保全域變數也更新
766
- console.log('✅ 新對話建立:', currentChatId, '標題:', data.title);
767
  break;
768
 
769
  case 'error':
770
  // 錯誤訊息
771
  console.error('❌ 後端錯誤:', data.message);
772
-
773
- const messageText = data && typeof data.message === 'string' ? data.message : '';
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 'voice_binding_ready':
795
- // 語音綁定準備就緒 - 自動開始錄音 5 秒
796
- console.log('🎙️ 收到 voice_binding_ready,準備錄音 5 秒...');
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
- case 'env_ack':
811
- if (data.success) {
812
- console.log('🧭 環境快照已同步,Geohash:', data.geohash_7, 'Heading:', data.heading);
 
813
  } else {
814
- console.warn('⚠️ 環境快照同步失敗:', data.error);
 
 
 
 
 
815
  }
816
  break;
817
 
@@ -844,11 +606,8 @@ function handleVoiceLoginResult(data) {
844
  currentUserId = data.user.id;
845
 
846
  // 套用情緒主題
847
- if (data.emotion) {
848
- const emotionValue = typeof data.emotion === 'string' ? data.emotion : data.emotion.label;
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,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;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,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;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)}]);