XiaoBai1221 commited on
Commit
feabc9f
·
1 Parent(s): 43ab4f0
static/frontend/index.html CHANGED
@@ -890,13 +890,17 @@
890
  color: #1A1A1A;
891
  text-align: left;
892
  line-height: 1.7;
893
- min-height: 60px;
894
  box-shadow: 0 6px 28px rgba(0, 0, 0, 0.1);
895
  font-family: 'Inter', -apple-system, sans-serif;
896
  display: none; /* 預設隱藏 */
897
  opacity: 0;
898
  transform: translateY(10px);
899
  transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
 
 
 
 
 
900
  }
901
 
902
  .voice-agent-output.active {
@@ -905,6 +909,18 @@
905
  transform: translateY(0);
906
  }
907
 
 
 
 
 
 
 
 
 
 
 
 
 
908
  /* 打字游標效果 */
909
  .voice-agent-output::after {
910
  content: '▋';
@@ -935,7 +951,7 @@
935
  color: #1A1A1A;
936
  text-align: center;
937
  line-height: 1.6;
938
- min-height: 80px;
939
  display: flex;
940
  align-items: center;
941
  justify-content: center;
@@ -946,6 +962,11 @@
946
  .voice-transcript.provisional {
947
  color: rgba(0, 0, 0, 0.4);
948
  font-style: italic;
 
 
 
 
 
949
  }
950
 
951
  .voice-transcript.final {
@@ -1306,6 +1327,190 @@
1306
  width: 20px;
1307
  height: 20px;
1308
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1309
  </style>
1310
  </head>
1311
  <body>
@@ -1428,10 +1633,27 @@
1428
  </div>
1429
  </div>
1430
 
1431
- <!-- 工具卡片容器 -->
1432
  <div id="tool-cards-container"></div>
1433
  </div>
1434
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1435
  <!-- JavaScript 模組化引入 -->
1436
  <script src="js/config.js"></script>
1437
  <script src="js/ui.js"></script>
 
890
  color: #1A1A1A;
891
  text-align: left;
892
  line-height: 1.7;
 
893
  box-shadow: 0 6px 28px rgba(0, 0, 0, 0.1);
894
  font-family: 'Inter', -apple-system, sans-serif;
895
  display: none; /* 預設隱藏 */
896
  opacity: 0;
897
  transform: translateY(10px);
898
  transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
899
+ /* 限制兩行高度 + 滾動 */
900
+ max-height: calc(1.7em * 2 + 40px); /* 兩行文字 + padding */
901
+ overflow-y: auto;
902
+ overflow-x: hidden;
903
+ -webkit-overflow-scrolling: touch;
904
  }
905
 
906
  .voice-agent-output.active {
 
909
  transform: translateY(0);
910
  }
911
 
912
+ /* 自訂滾動條樣式 */
913
+ .voice-agent-output::-webkit-scrollbar {
914
+ width: 4px;
915
+ }
916
+ .voice-agent-output::-webkit-scrollbar-track {
917
+ background: transparent;
918
+ }
919
+ .voice-agent-output::-webkit-scrollbar-thumb {
920
+ background: rgba(0, 0, 0, 0.15);
921
+ border-radius: 2px;
922
+ }
923
+
924
  /* 打字游標效果 */
925
  .voice-agent-output::after {
926
  content: '▋';
 
951
  color: #1A1A1A;
952
  text-align: center;
953
  line-height: 1.6;
954
+ min-height: 60px;
955
  display: flex;
956
  align-items: center;
957
  justify-content: center;
 
962
  .voice-transcript.provisional {
963
  color: rgba(0, 0, 0, 0.4);
964
  font-style: italic;
965
+ /* 打字狀態:限制一行 */
966
+ white-space: nowrap;
967
+ overflow: hidden;
968
+ text-overflow: ellipsis;
969
+ max-height: calc(1.6em + 48px); /* 一行文字 + padding */
970
  }
971
 
972
  .voice-transcript.final {
 
1327
  width: 20px;
1328
  height: 20px;
1329
  }
1330
+
1331
+ /* === 工具抽屜(手機端右側拉出面板)=== */
1332
+ .tool-drawer-toggle {
1333
+ position: fixed;
1334
+ right: 0;
1335
+ top: 50%;
1336
+ transform: translateY(-50%);
1337
+ z-index: 200;
1338
+ width: 24px;
1339
+ height: 60px;
1340
+ background: rgba(255, 255, 255, 0.6);
1341
+ border: 1px solid rgba(0, 0, 0, 0.08);
1342
+ border-right: none;
1343
+ border-radius: 12px 0 0 12px;
1344
+ display: none; /* 預設隱藏,有工具結果時才顯示 */
1345
+ align-items: center;
1346
+ justify-content: center;
1347
+ cursor: pointer;
1348
+ backdrop-filter: blur(10px);
1349
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.06);
1350
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1351
+ }
1352
+
1353
+ .tool-drawer-toggle.visible {
1354
+ display: flex;
1355
+ }
1356
+
1357
+ .tool-drawer-toggle:hover {
1358
+ background: rgba(255, 255, 255, 0.9);
1359
+ width: 28px;
1360
+ }
1361
+
1362
+ .tool-drawer-toggle::before {
1363
+ content: '‹';
1364
+ font-size: 18px;
1365
+ color: rgba(0, 0, 0, 0.5);
1366
+ transition: transform 0.3s;
1367
+ }
1368
+
1369
+ .tool-drawer-toggle.open::before {
1370
+ transform: rotate(180deg);
1371
+ }
1372
+
1373
+ /* 工具抽屜面板 */
1374
+ .tool-drawer {
1375
+ position: fixed;
1376
+ right: -320px;
1377
+ top: 0;
1378
+ width: 320px;
1379
+ height: 100%;
1380
+ height: 100dvh;
1381
+ background: rgba(255, 255, 255, 0.98);
1382
+ border-left: 1px solid rgba(0, 0, 0, 0.08);
1383
+ z-index: 199;
1384
+ backdrop-filter: blur(20px) saturate(180%);
1385
+ box-shadow: -4px 0 24px rgba(0, 0, 0, 0.1);
1386
+ transition: right 0.4s cubic-bezier(0.4, 0, 0.2, 1);
1387
+ display: flex;
1388
+ flex-direction: column;
1389
+ overflow: hidden;
1390
+ }
1391
+
1392
+ .tool-drawer.open {
1393
+ right: 0;
1394
+ }
1395
+
1396
+ .tool-drawer-header {
1397
+ padding: 20px;
1398
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
1399
+ display: flex;
1400
+ align-items: center;
1401
+ justify-content: space-between;
1402
+ flex-shrink: 0;
1403
+ }
1404
+
1405
+ .tool-drawer-header h3 {
1406
+ font-size: 15px;
1407
+ font-weight: 600;
1408
+ color: #1A1A1A;
1409
+ margin: 0;
1410
+ }
1411
+
1412
+ .tool-drawer-close {
1413
+ width: 32px;
1414
+ height: 32px;
1415
+ border: none;
1416
+ background: rgba(0, 0, 0, 0.05);
1417
+ border-radius: 8px;
1418
+ cursor: pointer;
1419
+ display: flex;
1420
+ align-items: center;
1421
+ justify-content: center;
1422
+ font-size: 16px;
1423
+ color: rgba(0, 0, 0, 0.5);
1424
+ transition: all 0.2s;
1425
+ }
1426
+
1427
+ .tool-drawer-close:hover {
1428
+ background: rgba(0, 0, 0, 0.1);
1429
+ color: rgba(0, 0, 0, 0.8);
1430
+ }
1431
+
1432
+ .tool-drawer-content {
1433
+ flex: 1;
1434
+ overflow-y: auto;
1435
+ overflow-x: hidden;
1436
+ padding: 16px;
1437
+ -webkit-overflow-scrolling: touch;
1438
+ }
1439
+
1440
+ /* 抽屜內的工具卡片樣式覆寫 */
1441
+ .tool-drawer-content .voice-tool-card {
1442
+ position: relative;
1443
+ top: auto;
1444
+ left: auto;
1445
+ right: auto;
1446
+ bottom: auto;
1447
+ width: 100%;
1448
+ max-width: none;
1449
+ min-width: auto;
1450
+ margin-bottom: 16px;
1451
+ animation: none;
1452
+ }
1453
+
1454
+ /* 抽屜內滾動條樣式 */
1455
+ .tool-drawer-content::-webkit-scrollbar {
1456
+ width: 4px;
1457
+ }
1458
+ .tool-drawer-content::-webkit-scrollbar-track {
1459
+ background: transparent;
1460
+ }
1461
+ .tool-drawer-content::-webkit-scrollbar-thumb {
1462
+ background: rgba(0, 0, 0, 0.15);
1463
+ border-radius: 2px;
1464
+ }
1465
+
1466
+ /* 抽屜遮罩層 */
1467
+ .tool-drawer-overlay {
1468
+ position: fixed;
1469
+ top: 0;
1470
+ left: 0;
1471
+ right: 0;
1472
+ bottom: 0;
1473
+ background: rgba(0, 0, 0, 0.3);
1474
+ z-index: 198;
1475
+ opacity: 0;
1476
+ visibility: hidden;
1477
+ transition: all 0.3s;
1478
+ }
1479
+
1480
+ .tool-drawer-overlay.visible {
1481
+ opacity: 1;
1482
+ visibility: visible;
1483
+ }
1484
+
1485
+ /* === 響應式設計:根據裝置類型切換工具卡片顯示方式 === */
1486
+ /* 手機/平板模式(寬度 <= 1024px):只顯示抽屜,隱藏桌面端卡片 */
1487
+ @media (max-width: 1024px) {
1488
+ #tool-cards-container {
1489
+ display: none !important;
1490
+ }
1491
+ .tool-drawer-toggle.visible {
1492
+ display: flex;
1493
+ }
1494
+ .tool-drawer {
1495
+ display: flex;
1496
+ }
1497
+ }
1498
+
1499
+ /* 電腦模式(寬度 > 1024px):只顯示桌面端卡片,隱藏抽屜 */
1500
+ @media (min-width: 1025px) {
1501
+ #tool-cards-container {
1502
+ display: block;
1503
+ }
1504
+ .tool-drawer-toggle {
1505
+ display: none !important;
1506
+ }
1507
+ .tool-drawer {
1508
+ display: none !important;
1509
+ }
1510
+ .tool-drawer-overlay {
1511
+ display: none !important;
1512
+ }
1513
+ }
1514
  </style>
1515
  </head>
1516
  <body>
 
1633
  </div>
1634
  </div>
1635
 
1636
+ <!-- 工具卡片容器(保留用於桌面端) -->
1637
  <div id="tool-cards-container"></div>
1638
  </div>
1639
 
1640
+ <!-- 工具抽屜遮罩層 -->
1641
+ <div class="tool-drawer-overlay" id="toolDrawerOverlay"></div>
1642
+
1643
+ <!-- 工具抽屜切換按鈕(右側透明箭頭) -->
1644
+ <div class="tool-drawer-toggle" id="toolDrawerToggle"></div>
1645
+
1646
+ <!-- 工具抽屜面板 -->
1647
+ <div class="tool-drawer" id="toolDrawer">
1648
+ <div class="tool-drawer-header">
1649
+ <h3>📊 工具結果</h3>
1650
+ <button class="tool-drawer-close" id="toolDrawerClose">✕</button>
1651
+ </div>
1652
+ <div class="tool-drawer-content" id="toolDrawerContent">
1653
+ <!-- 工具卡片將渲染到這裡 -->
1654
+ </div>
1655
+ </div>
1656
+
1657
  <!-- JavaScript 模組化引入 -->
1658
  <script src="js/config.js"></script>
1659
  <script src="js/ui.js"></script>
static/frontend/js/app.js CHANGED
@@ -87,6 +87,7 @@ function initializeApp(token) {
87
  initTranscriptControls();
88
  initToolCardControls();
89
  initAgentControls();
 
90
 
91
  // 同步 MCP 工具 metadata
92
  syncToolMetadata();
 
87
  initTranscriptControls();
88
  initToolCardControls();
89
  initAgentControls();
90
+ initToolDrawer(); // 初始化工具抽屜
91
 
92
  // 同步 MCP 工具 metadata
93
  syncToolMetadata();
static/frontend/js/tools.js CHANGED
@@ -1,9 +1,122 @@
1
- // ========== 工具卡片管理(改良版:支援位置滿了的情況)==========
2
 
3
  const positions = ['pos-top-right', 'pos-top-left', 'pos-bottom-right', 'pos-bottom-left'];
4
  let usedPositions = [];
5
  const MAX_CARDS = 4;
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  function getNextPosition() {
8
  // 如果卡片數量已達上限,不允許新增
9
  if (usedPositions.length >= MAX_CARDS) {
@@ -209,6 +322,7 @@ function getIconForTool(toolName, category) {
209
 
210
  /**
211
  * 動態顯示工具卡片(通用版本,支援所有 MCP 工具)
 
212
  */
213
  function displayToolCard(toolName, toolData) {
214
  // 清除舊卡片
@@ -219,27 +333,38 @@ function displayToolCard(toolName, toolData) {
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
- // 渲染卡片內容
231
- const contentHTML = renderCardContent(toolName, toolData);
232
-
233
  card.innerHTML = `
234
  <div class="card-header">
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
 
245
  /**
@@ -253,16 +378,18 @@ function renderCardContent(toolName, toolData) {
253
  return '<p class="data-row">無數據</p>';
254
  }
255
 
256
- // 模式 1:health_data 陣列
257
- if (toolData.health_data && Array.isArray(toolData.health_data)) {
 
258
  console.log('✅ 匹配到模式 1: health_data');
259
- return renderHealthMetrics(toolData.health_data);
260
  }
261
 
262
- // 模式 2:articles 陣列
263
- if (toolData.articles && Array.isArray(toolData.articles)) {
 
264
  console.log('✅ 匹配到模式 2: articles');
265
- return renderNewsList(toolData.articles);
266
  }
267
 
268
  // 模式 3:天氣數據(直接檢查,無論是否包在 raw_data 中)
@@ -284,10 +411,11 @@ function renderCardContent(toolName, toolData) {
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:火車列車資訊
@@ -315,9 +443,34 @@ function renderCardContent(toolName, toolData) {
315
  return renderReverseGeocode(toolData);
316
  }
317
 
318
- // 模式 10:通用 raw_data 物件
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  if (toolData.raw_data && typeof toolData.raw_data === 'object') {
320
- console.log('✅ 匹配到模式 10: 通用 raw_data');
321
  return renderKeyValuePairs(toolData.raw_data);
322
  }
323
 
@@ -383,26 +536,86 @@ function renderWeatherData(data) {
383
  * 渲染健康指標
384
  */
385
  function renderHealthMetrics(healthData) {
 
 
 
 
386
  const metricNames = {
387
- heart_rate: '心率',
388
- step_count: '步數',
389
- oxygen_level: '血氧',
390
- respiratory_rate: '呼吸',
391
- sleep_analysis: '睡眠'
392
  };
393
 
394
- let html = '';
395
- healthData.slice(0, 3).forEach(item => {
396
- const label = metricNames[item.metric] || item.metric;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
  html += `
398
- <div class="data-row">
399
- <span class="data-label">${label}</span>
400
- <span class="data-value">${item.value} ${item.unit || ''}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  </div>
402
  `;
403
  });
404
 
405
- return html || '<p>無健康數據</p>';
 
406
  }
407
 
408
  /**
@@ -799,6 +1012,222 @@ function renderNearbyStops(stops) {
799
  return html;
800
  }
801
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
802
  /**
803
  * Fallback:顯示 JSON
804
  */
 
1
+ // ========== 工具卡片管理(改良版:支援抽屜面板)==========
2
 
3
  const positions = ['pos-top-right', 'pos-top-left', 'pos-bottom-right', 'pos-bottom-left'];
4
  let usedPositions = [];
5
  const MAX_CARDS = 4;
6
 
7
+ // 抽屜相關元素
8
+ let toolDrawer = null;
9
+ let toolDrawerToggle = null;
10
+ let toolDrawerContent = null;
11
+ let toolDrawerOverlay = null;
12
+ let toolDrawerClose = null;
13
+ let isDrawerOpen = false;
14
+
15
+ /**
16
+ * 初始化工具抽屜
17
+ */
18
+ function initToolDrawer() {
19
+ toolDrawer = document.getElementById('toolDrawer');
20
+ toolDrawerToggle = document.getElementById('toolDrawerToggle');
21
+ toolDrawerContent = document.getElementById('toolDrawerContent');
22
+ toolDrawerOverlay = document.getElementById('toolDrawerOverlay');
23
+ toolDrawerClose = document.getElementById('toolDrawerClose');
24
+
25
+ if (!toolDrawer || !toolDrawerToggle) {
26
+ console.warn('⚠️ 工具抽屜元素未找到');
27
+ return;
28
+ }
29
+
30
+ // 綁定切換按鈕事件
31
+ toolDrawerToggle.addEventListener('click', toggleToolDrawer);
32
+
33
+ // 綁定關閉按鈕事件
34
+ if (toolDrawerClose) {
35
+ toolDrawerClose.addEventListener('click', hideToolDrawer);
36
+ }
37
+
38
+ // 綁定遮罩層點擊關閉
39
+ if (toolDrawerOverlay) {
40
+ toolDrawerOverlay.addEventListener('click', hideToolDrawer);
41
+ }
42
+
43
+ console.log('✅ 工具抽屜已初始化');
44
+ }
45
+
46
+ /**
47
+ * 顯示工具抽屜切換按鈕(有工具結果時調用)
48
+ */
49
+ function showToolDrawerToggle() {
50
+ if (toolDrawerToggle) {
51
+ toolDrawerToggle.classList.add('visible');
52
+ console.log('📊 工具抽屜按鈕已顯示');
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 隱藏工具抽屜切換按鈕
58
+ */
59
+ function hideToolDrawerToggle() {
60
+ if (toolDrawerToggle) {
61
+ toolDrawerToggle.classList.remove('visible');
62
+ toolDrawerToggle.classList.remove('open');
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 切換工具抽屜開關
68
+ */
69
+ function toggleToolDrawer() {
70
+ if (isDrawerOpen) {
71
+ hideToolDrawer();
72
+ } else {
73
+ showToolDrawer();
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 打開工具抽屜
79
+ */
80
+ function showToolDrawer() {
81
+ if (toolDrawer) {
82
+ toolDrawer.classList.add('open');
83
+ toolDrawerToggle?.classList.add('open');
84
+ toolDrawerOverlay?.classList.add('visible');
85
+ isDrawerOpen = true;
86
+ console.log('📂 工具抽屜已打開');
87
+ }
88
+ }
89
+
90
+ /**
91
+ * 關閉工具抽屜
92
+ */
93
+ function hideToolDrawer() {
94
+ if (toolDrawer) {
95
+ toolDrawer.classList.remove('open');
96
+ toolDrawerToggle?.classList.remove('open');
97
+ toolDrawerOverlay?.classList.remove('visible');
98
+ isDrawerOpen = false;
99
+ console.log('📁 工具抽屜已關閉');
100
+ }
101
+ }
102
+
103
+ /**
104
+ * 隱藏工具卡片(下一個請求或關懷模式時調用)
105
+ */
106
+ function hideToolCards() {
107
+ // 隱藏抽屜
108
+ hideToolDrawer();
109
+ // 隱藏切換按鈕
110
+ hideToolDrawerToggle();
111
+ // 清空抽屜內容
112
+ if (toolDrawerContent) {
113
+ toolDrawerContent.innerHTML = '';
114
+ }
115
+ // 清空桌面端卡片容器
116
+ clearAllCards();
117
+ console.log('🗑️ 工具卡片已隱藏');
118
+ }
119
+
120
  function getNextPosition() {
121
  // 如果卡片數量已達上限,不允許新增
122
  if (usedPositions.length >= MAX_CARDS) {
 
322
 
323
  /**
324
  * 動態顯示工具卡片(通用版本,支援所有 MCP 工具)
325
+ * 優先渲染到抽屜面板(手機端),同時保留桌面端卡片
326
  */
327
  function displayToolCard(toolName, toolData) {
328
  // 清除舊卡片
 
333
  const category = toolMeta.category || '未知';
334
  const icon = getIconForTool(toolName, category);
335
 
336
+ // 渲染卡片內容(處理後的結果,非 raw data)
337
+ const contentHTML = renderCardContent(toolName, toolData);
 
338
 
339
+ // 創建卡片元素
340
  const card = document.createElement('div');
341
+ card.className = 'voice-tool-card';
342
  card.dataset.type = toolName;
343
 
 
 
 
344
  card.innerHTML = `
345
  <div class="card-header">
346
  <div class="card-icon">${icon}</div>
347
  <h3>${category}</h3>
348
  </div>
349
+ <div class="card-content" style="max-height: 300px; overflow-y: auto; overflow-x: hidden; padding-right: 8px;">${contentHTML}</div>
350
  `;
351
 
352
+ // 渲染到抽屜面板
353
+ if (toolDrawerContent) {
354
+ toolDrawerContent.innerHTML = '';
355
+ toolDrawerContent.appendChild(card.cloneNode(true));
356
+ // 顯示抽屜切換按鈕
357
+ showToolDrawerToggle();
358
+ console.log(`📊 工具卡片已渲染到抽屜: ${toolName} (${category})`);
359
+ }
360
+
361
+ // 同時渲染到桌面端卡片容器(保留原有邏輯)
362
+ const position = getNextPosition();
363
+ if (position && cardsContainer) {
364
+ card.classList.add(position);
365
+ cardsContainer.appendChild(card);
366
+ console.log(`🃏 工具卡片已渲染到桌面: ${toolName} (${category})`);
367
+ }
368
  }
369
 
370
  /**
 
378
  return '<p class="data-row">無數據</p>';
379
  }
380
 
381
+ // 模式 1:health_data 陣列(直接或在 raw_data 中)
382
+ const healthData = toolData.health_data || toolData.raw_data?.health_data;
383
+ if (healthData && Array.isArray(healthData)) {
384
  console.log('✅ 匹配到模式 1: health_data');
385
+ return renderHealthMetrics(healthData);
386
  }
387
 
388
+ // 模式 2:articles 陣列(直接或在 raw_data 中)
389
+ const articlesData = toolData.articles || toolData.raw_data?.articles;
390
+ if (articlesData && Array.isArray(articlesData)) {
391
  console.log('✅ 匹配到模式 2: articles');
392
+ return renderNewsList(articlesData);
393
  }
394
 
395
  // 模式 3:天氣數據(直接檢查,無論是否包在 raw_data 中)
 
411
  return renderNearbyStops(toolData.stops);
412
  }
413
 
414
+ // 模式 6:匯率數據(直接或在 raw_data 中)
415
+ const exchangeData = toolData.raw_data || toolData;
416
+ if (exchangeData.rate !== undefined && exchangeData.from_currency !== undefined) {
417
  console.log('✅ 匹配到模式 6: 匯率數據');
418
+ return renderExchangeRate(exchangeData);
419
  }
420
 
421
  // 模式 7:火車列車資訊
 
443
  return renderReverseGeocode(toolData);
444
  }
445
 
446
+ // 模式 10:導航路線(directions)
447
+ if ((toolData.distance_m !== undefined || toolData.duration_s !== undefined) &&
448
+ (toolName === 'directions' || toolData.polyline !== undefined)) {
449
+ console.log('✅ 匹配到模式 10: 導航路線');
450
+ return renderDirections(toolData);
451
+ }
452
+
453
+ // 模式 11:捷運到站資訊(tdx_metro arrivals)
454
+ if (toolData.arrivals && Array.isArray(toolData.arrivals) && toolName === 'tdx_metro') {
455
+ console.log('✅ 匹配到模式 11: 捷運到站資訊');
456
+ return renderMetroArrivals(toolData.arrivals);
457
+ }
458
+
459
+ // 模式 12:捷運站點資訊(tdx_metro stations)
460
+ if (toolData.stations && Array.isArray(toolData.stations) && toolName === 'tdx_metro') {
461
+ console.log('✅ 匹配到模式 12: 捷運站點資訊');
462
+ return renderMetroStations(toolData.stations);
463
+ }
464
+
465
+ // 模式 13:正向地理編碼(forward_geocode)
466
+ if (toolData.lat && toolData.lon && toolData.display_name && toolName === 'forward_geocode') {
467
+ console.log('✅ 匹配到模式 13: 正向地理編碼');
468
+ return renderForwardGeocode(toolData);
469
+ }
470
+
471
+ // 模式 14:通用 raw_data 物件
472
  if (toolData.raw_data && typeof toolData.raw_data === 'object') {
473
+ console.log('✅ 匹配到模式 14: 通用 raw_data');
474
  return renderKeyValuePairs(toolData.raw_data);
475
  }
476
 
 
536
  * 渲染健康指標
537
  */
538
  function renderHealthMetrics(healthData) {
539
+ if (!healthData || healthData.length === 0) {
540
+ return '<p class="data-row">無健康數據</p>';
541
+ }
542
+
543
  const metricNames = {
544
+ heart_rate: '❤️ 心率',
545
+ step_count: '👟 步數',
546
+ oxygen_level: '🫁 血氧',
547
+ respiratory_rate: '💨 呼吸',
548
+ sleep_analysis: '😴 睡眠'
549
  };
550
 
551
+ const metricIcons = {
552
+ heart_rate: '❤️',
553
+ step_count: '👟',
554
+ oxygen_level: '🫁',
555
+ respiratory_rate: '💨',
556
+ sleep_analysis: '😴'
557
+ };
558
+
559
+ // 按指標類型分組
560
+ const grouped = {};
561
+ healthData.forEach(item => {
562
+ const metric = item.metric || item.type;
563
+ if (!grouped[metric]) {
564
+ grouped[metric] = [];
565
+ }
566
+ grouped[metric].push(item);
567
+ });
568
+
569
+ let html = '<div class="health-metrics">';
570
+
571
+ // 渲染每種指標
572
+ Object.entries(grouped).forEach(([metric, items], index) => {
573
+ const icon = metricIcons[metric] || '📊';
574
+ const label = metricNames[metric]?.replace(/^.+\s/, '') || metric;
575
+ const latestItem = items[0]; // 最新的數據
576
+ const value = latestItem.value;
577
+ const unit = latestItem.unit || '';
578
+
579
+ // 格式化時間
580
+ let timeStr = '';
581
+ if (latestItem.timestamp) {
582
+ try {
583
+ const date = new Date(latestItem.timestamp);
584
+ timeStr = date.toLocaleString('zh-TW', {
585
+ month: 'numeric',
586
+ day: 'numeric',
587
+ hour: '2-digit',
588
+ minute: '2-digit'
589
+ });
590
+ } catch (e) {
591
+ timeStr = '';
592
+ }
593
+ }
594
+
595
  html += `
596
+ <div class="health-metric-item" style="border-bottom: 1px solid #eee; padding: 10px 0; ${index === Object.keys(grouped).length - 1 ? 'border-bottom: none;' : ''}">
597
+ <div class="data-row">
598
+ <span class="data-label">${icon} ${label}</span>
599
+ <span class="data-value" style="font-weight: bold;">${value} ${unit}</span>
600
+ </div>
601
+ ${timeStr ? `
602
+ <div class="data-row" style="opacity: 0.7;">
603
+ <span class="data-label" style="font-size: 0.85em;">記錄時間</span>
604
+ <span class="data-value" style="font-size: 0.85em;">${timeStr}</span>
605
+ </div>
606
+ ` : ''}
607
+ ${items.length > 1 ? `
608
+ <div class="data-row" style="opacity: 0.6;">
609
+ <span class="data-label" style="font-size: 0.8em;">平均值</span>
610
+ <span class="data-value" style="font-size: 0.8em;">${(items.reduce((sum, i) => sum + i.value, 0) / items.length).toFixed(1)} ${unit}</span>
611
+ </div>
612
+ ` : ''}
613
  </div>
614
  `;
615
  });
616
 
617
+ html += '</div>';
618
+ return html;
619
  }
620
 
621
  /**
 
1012
  return html;
1013
  }
1014
 
1015
+ /**
1016
+ * 渲染導航路線(directions)
1017
+ */
1018
+ function renderDirections(data) {
1019
+ const originLabel = data.origin_label || '起點';
1020
+ const destLabel = data.dest_label || '目的地';
1021
+ const distanceM = data.distance_m;
1022
+ const durationS = data.duration_s;
1023
+
1024
+ // 格式化距離
1025
+ let distanceStr = '--';
1026
+ if (distanceM !== undefined) {
1027
+ distanceStr = distanceM >= 1000
1028
+ ? `${(distanceM / 1000).toFixed(1)} 公里`
1029
+ : `${Math.round(distanceM)} 公尺`;
1030
+ }
1031
+
1032
+ // 格式化時間
1033
+ let durationStr = '--';
1034
+ if (durationS !== undefined) {
1035
+ const minutes = Math.round(durationS / 60);
1036
+ if (minutes >= 60) {
1037
+ const hours = Math.floor(minutes / 60);
1038
+ const mins = minutes % 60;
1039
+ durationStr = mins > 0 ? `${hours} 小時 ${mins} 分鐘` : `${hours} 小時`;
1040
+ } else {
1041
+ durationStr = `${minutes} 分鐘`;
1042
+ }
1043
+ }
1044
+
1045
+ // 生成 Google Maps 連結(如果有座標)
1046
+ let mapsLink = '';
1047
+ if (data.origin_lat && data.origin_lon && data.dest_lat && data.dest_lon) {
1048
+ const mapsUrl = `https://www.google.com/maps/dir/${data.origin_lat},${data.origin_lon}/${data.dest_lat},${data.dest_lon}`;
1049
+ mapsLink = `
1050
+ <div class="data-row" style="margin-top: 8px;">
1051
+ <a href="${mapsUrl}" target="_blank" style="color: #0066cc; text-decoration: none; font-size: 0.9em;">
1052
+ 🗺️ 在 Google Maps 中查看 →
1053
+ </a>
1054
+ </div>
1055
+ `;
1056
+ }
1057
+
1058
+ return `
1059
+ <div class="data-row">
1060
+ <span class="data-label">📍 起點</span>
1061
+ <span class="data-value">${originLabel}</span>
1062
+ </div>
1063
+ <div class="data-row">
1064
+ <span class="data-label">🎯 目的地</span>
1065
+ <span class="data-value">${destLabel}</span>
1066
+ </div>
1067
+ <div class="data-row">
1068
+ <span class="data-label">📏 距離</span>
1069
+ <span class="data-value">${distanceStr}</span>
1070
+ </div>
1071
+ <div class="data-row">
1072
+ <span class="data-label">⏱️ 預估時間</span>
1073
+ <span class="data-value">${durationStr}</span>
1074
+ </div>
1075
+ ${mapsLink}
1076
+ `;
1077
+ }
1078
+
1079
+ /**
1080
+ * 渲染捷運到站資訊(tdx_metro arrivals)
1081
+ */
1082
+ function renderMetroArrivals(arrivals) {
1083
+ if (!arrivals || arrivals.length === 0) {
1084
+ return '<p class="data-row">目前無捷運到站資訊</p>';
1085
+ }
1086
+
1087
+ let html = '<div class="metro-arrivals">';
1088
+
1089
+ // 按路線分組
1090
+ const lineGroups = {};
1091
+ arrivals.forEach(arr => {
1092
+ const lineName = arr.line_name || '未知路線';
1093
+ if (!lineGroups[lineName]) {
1094
+ lineGroups[lineName] = [];
1095
+ }
1096
+ lineGroups[lineName].push(arr);
1097
+ });
1098
+
1099
+ // 渲染每條路線
1100
+ Object.entries(lineGroups).forEach(([lineName, lineArrivals], index) => {
1101
+ html += `
1102
+ <div class="metro-line" style="border-bottom: 1px solid #eee; padding: 12px 0; ${index === Object.keys(lineGroups).length - 1 ? 'border-bottom: none;' : ''}">
1103
+ <div class="data-row" style="margin-bottom: 8px;">
1104
+ <span class="data-label" style="font-weight: bold; color: #0066cc;">🚇 ${lineName}</span>
1105
+ </div>
1106
+ `;
1107
+
1108
+ lineArrivals.slice(0, 3).forEach(arr => {
1109
+ const dest = arr.destination || '未知';
1110
+ const timeSec = arr.arrival_time_sec;
1111
+ const status = arr.train_status || '未知';
1112
+
1113
+ let timeStr = status;
1114
+ if (timeSec > 0) {
1115
+ const min = Math.floor(timeSec / 60);
1116
+ const sec = timeSec % 60;
1117
+ timeStr = min > 0 ? `${min} 分 ${sec} 秒` : `${sec} 秒`;
1118
+ }
1119
+
1120
+ html += `
1121
+ <div class="data-row">
1122
+ <span class="data-label">→ ${dest}</span>
1123
+ <span class="data-value">${timeStr}</span>
1124
+ </div>
1125
+ `;
1126
+ });
1127
+
1128
+ html += '</div>';
1129
+ });
1130
+
1131
+ html += '</div>';
1132
+ return html;
1133
+ }
1134
+
1135
+ /**
1136
+ * 渲染捷運站點資訊(tdx_metro stations)
1137
+ */
1138
+ function renderMetroStations(stations) {
1139
+ if (!stations || stations.length === 0) {
1140
+ return '<p class="data-row">附近無捷運站</p>';
1141
+ }
1142
+
1143
+ let html = '<div class="metro-stations">';
1144
+
1145
+ stations.forEach((station, index) => {
1146
+ const stationName = station.station_name || '未知車站';
1147
+ const distance = station.distance_m ? `${Math.round(station.distance_m)} 公尺` : '';
1148
+ const walkTime = station.walking_time_min ? `步行約 ${station.walking_time_min} 分鐘` : '';
1149
+ const address = station.address || '';
1150
+
1151
+ html += `
1152
+ <div class="metro-station-item" style="border-bottom: 1px solid #eee; padding: 12px 0; ${index === stations.length - 1 ? 'border-bottom: none;' : ''}">
1153
+ <div class="data-row" style="margin-bottom: 4px;">
1154
+ <span class="data-label" style="font-weight: bold; color: #0066cc;">🚇 ${stationName}</span>
1155
+ </div>
1156
+ ${distance ? `
1157
+ <div class="data-row">
1158
+ <span class="data-label">📏 距離</span>
1159
+ <span class="data-value">${distance}</span>
1160
+ </div>
1161
+ ` : ''}
1162
+ ${walkTime ? `
1163
+ <div class="data-row">
1164
+ <span class="data-label">🚶 步行時間</span>
1165
+ <span class="data-value">${walkTime}</span>
1166
+ </div>
1167
+ ` : ''}
1168
+ ${address ? `
1169
+ <div class="data-row">
1170
+ <span class="data-label">📍 地址</span>
1171
+ <span class="data-value" style="font-size: 0.85em;">${address}</span>
1172
+ </div>
1173
+ ` : ''}
1174
+ </div>
1175
+ `;
1176
+ });
1177
+
1178
+ html += '</div>';
1179
+ return html;
1180
+ }
1181
+
1182
+ /**
1183
+ * 渲染正向地理編碼(forward_geocode)
1184
+ */
1185
+ function renderForwardGeocode(data) {
1186
+ const displayName = data.display_name || '未知地點';
1187
+ const lat = data.lat?.toFixed(6) || '';
1188
+ const lon = data.lon?.toFixed(6) || '';
1189
+ const city = data.city || '';
1190
+ const road = data.road || '';
1191
+ const suburb = data.suburb || '';
1192
+
1193
+ // 生成 Google Maps 連結
1194
+ const mapsUrl = `https://www.google.com/maps?q=${lat},${lon}`;
1195
+
1196
+ return `
1197
+ <div class="data-row">
1198
+ <span class="data-label">📍 地點</span>
1199
+ <span class="data-value" style="font-weight: bold;">${displayName}</span>
1200
+ </div>
1201
+ ${city ? `
1202
+ <div class="data-row">
1203
+ <span class="data-label">🏙️ 城市</span>
1204
+ <span class="data-value">${city}</span>
1205
+ </div>
1206
+ ` : ''}
1207
+ ${road ? `
1208
+ <div class="data-row">
1209
+ <span class="data-label">🛣️ 道路</span>
1210
+ <span class="data-value">${road}</span>
1211
+ </div>
1212
+ ` : ''}
1213
+ ${suburb ? `
1214
+ <div class="data-row">
1215
+ <span class="data-label">🏘️ 區域</span>
1216
+ <span class="data-value">${suburb}</span>
1217
+ </div>
1218
+ ` : ''}
1219
+ <div class="data-row">
1220
+ <span class="data-label">🌐 座標</span>
1221
+ <span class="data-value" style="font-size: 0.85em;">${lat}, ${lon}</span>
1222
+ </div>
1223
+ <div class="data-row" style="margin-top: 8px;">
1224
+ <a href="${mapsUrl}" target="_blank" style="color: #0066cc; text-decoration: none; font-size: 0.9em;">
1225
+ 🗺️ 在 Google Maps 中查看 →
1226
+ </a>
1227
+ </div>
1228
+ `;
1229
+ }
1230
+
1231
  /**
1232
  * Fallback:顯示 JSON
1233
  */
static/frontend/js/websocket.js CHANGED
@@ -496,9 +496,13 @@ function initializeWebSocket(token) {
496
  break;
497
 
498
  case 'typing':
499
- // 思考中提示
500
  if (data.message === 'thinking') {
501
  setState('thinking');
 
 
 
 
502
  }
503
  break;
504
 
@@ -613,9 +617,12 @@ function initializeWebSocket(token) {
613
  console.warn('⚠️ applyEmotion 函數未定義或情緒值無效');
614
  }
615
 
616
- // 如果啟用關懷模式,可以在這裡添加額外的 UI 提示
617
  if (data.care_mode) {
618
- console.log('💙 關懷模式已啟動');
 
 
 
619
  }
620
  break;
621
 
 
496
  break;
497
 
498
  case 'typing':
499
+ // 思考中提示(新請求開始,隱藏工具卡片)
500
  if (data.message === 'thinking') {
501
  setState('thinking');
502
+ // 隱藏上一次的工具卡片
503
+ if (typeof hideToolCards === 'function') {
504
+ hideToolCards();
505
+ }
506
  }
507
  break;
508
 
 
617
  console.warn('⚠️ applyEmotion 函數未定義或情緒值無效');
618
  }
619
 
620
+ // 如果啟用關懷模式,隱藏工具卡片
621
  if (data.care_mode) {
622
+ console.log('💙 關懷模式已啟動,隱藏工具卡片');
623
+ if (typeof hideToolCards === 'function') {
624
+ hideToolCards();
625
+ }
626
  }
627
  break;
628