Marco310 commited on
Commit
aba9311
·
1 Parent(s): 4f491c9

Ref: Refactor architecture to decouple UI from logic, implement server-side session persistence for agents, and restore full map/metrics visualizations.

Browse files
app.py CHANGED
@@ -1,911 +1,217 @@
1
  """
2
- LifeFlow AI - Multi-User Safe with Real Streaming
3
- 支持多用戶並發 + 真正的串流輸出 + 安全的設定隔離
4
  """
5
 
6
- import sys
7
- from pathlib import Path
8
  import gradio as gr
9
- from datetime import datetime
10
- import time as time_module
11
- import json
12
- import uuid
13
- from typing import Dict, Any, Optional
14
 
15
- # ===== 導入配置 =====
16
- from config import DEFAULT_SETTINGS, APP_TITLE
 
17
 
18
- # ===== 導入 UI 組件 =====
 
19
  from ui.theme import get_enhanced_css
 
 
 
 
 
 
 
 
 
20
  from ui.components.header import create_header, create_top_controls
21
  from ui.components.input_form import create_input_form, toggle_location_inputs
22
- from ui.components.confirmation import create_confirmation_area, create_exit_button
23
  from ui.components.results import create_team_area, create_result_area, create_tabs
24
  from ui.components.modals import create_settings_modal, create_doc_modal
25
 
26
- # ===== 導入核心工具 =====
27
- from core.utils import (
28
- create_agent_stream_output, create_agent_card_enhanced,
29
- create_task_card, create_summary_card, create_animated_map,
30
- get_reasoning_html_reversed, create_celebration_animation,
31
- create_result_visualization
32
- )
33
-
34
- # ===== 導入 Agno Agent 組件 =====
35
- from agno.models.google import Gemini
36
- from agno.agent import RunEvent
37
- from agno.run.team import TeamRunEvent
38
-
39
- # ===== 導入 Agent 系統 =====
40
- from src.agent.base import UserState, Location, get_context
41
- from src.agent.planner import create_planner_agent
42
- from src.agent.core_team import create_core_team
43
- from src.infra.context import set_session_id, get_session_id
44
- from src.infra.poi_repository import poi_repo
45
- from src.tools import (
46
- ScoutToolkit, OptimizationToolkit,
47
- NavigationToolkit, WeatherToolkit, ReaderToolkit
48
- )
49
- from src.infra.config import get_settings
50
- from src.infra.logger import get_logger
51
-
52
- logger = get_logger(__name__)
53
-
54
-
55
- class UserSession:
56
- """
57
- 單個用戶的會話數據
58
- 每個用戶有獨立的實例,確保資料隔離
59
- """
60
-
61
- def __init__(self):
62
- self.session_id: Optional[str] = None
63
- self.planner_agent = None
64
- self.core_team = None
65
- self.user_state: Optional[UserState] = None
66
- self.task_list: list = []
67
- self.reasoning_messages: list = []
68
- self.chat_history: list = []
69
- self.planning_completed: bool = False
70
-
71
- # 存儲位置信息(用於重新初始化 Agent)
72
- self.lat: Optional[float] = None
73
- self.lng: Optional[float] = None
74
-
75
- # [Security Fix] 用戶個人的自定義設定 (API Keys, Model Choice)
76
- self.custom_settings: Dict[str, Any] = {}
77
-
78
- # 系統預設設定 (從環境變數讀取)
79
- self.agno_settings = get_settings()
80
-
81
- def to_dict(self) -> Dict[str, Any]:
82
- """序列化為字典(用於 Gradio State)"""
83
- return {
84
- 'session_id': self.session_id,
85
- 'task_list': self.task_list,
86
- 'reasoning_messages': self.reasoning_messages,
87
- 'chat_history': self.chat_history,
88
- 'planning_completed': self.planning_completed,
89
- 'lat': self.lat,
90
- 'lng': self.lng,
91
- # [Security Fix] 保存用戶自定義設定
92
- 'custom_settings': self.custom_settings
93
- }
94
-
95
- @classmethod
96
- def from_dict(cls, data: Dict[str, Any]) -> 'UserSession':
97
- """從字典恢復(用於 Gradio State)"""
98
- session = cls()
99
- session.session_id = data.get('session_id')
100
- session.task_list = data.get('task_list', [])
101
- session.reasoning_messages = data.get('reasoning_messages', [])
102
- session.chat_history = data.get('chat_history', [])
103
- session.planning_completed = data.get('planning_completed', False)
104
- session.lat = data.get('lat')
105
- session.lng = data.get('lng')
106
- # [Security Fix] 恢復用戶自定義設定
107
- session.custom_settings = data.get('custom_settings', {})
108
- return session
109
-
110
 
111
  class LifeFlowAI:
112
- """LifeFlow AI - Multi-User Safe Version"""
113
-
114
  def __init__(self):
115
- # [Security Fix] 移除全域 settings,避免用戶間資料汙染
116
- pass
117
-
118
- def _initialize_agents(self, session: UserSession, lat: float, lng: float):
119
- """初始化 Agents(每個用戶獨立,並應用用戶設定)"""
120
- session.lat = lat
121
- session.lng = lng
122
-
123
- if session.planner_agent is not None:
124
- logger.debug(f"Agents already initialized for session {session.session_id}")
125
- return
126
-
127
- # 生成 Session ID
128
- if session.session_id is None:
129
- session.session_id = str(uuid.uuid4())
130
- token = set_session_id(session.session_id)
131
- logger.info(f"🆔 New Session: {session.session_id}")
132
- else:
133
- set_session_id(session.session_id)
134
- logger.info(f"🔄 Restoring Session: {session.session_id}")
135
-
136
- # 設定用戶狀態
137
- session.user_state = UserState(location=Location(lat=lat, lng=lng))
138
-
139
- # [Security Fix] 讀取用戶選擇的模型,如果沒有則使用預設
140
- selected_model_id = 'gemini-2.5-flash' #session.custom_settings.get('model', 'gemini-2.5-flash')
141
-
142
- # [Security Fix] 優先使用用戶提供的 API Key (這裡以 Gemini 為例,若支援其他模型需擴充邏輯)
143
- gemini_api_key = session.custom_settings.get("gemini_api_key", "")
144
- google_maps_api_key = session.custom_settings.get("google_maps_api_key", "")
145
- openweather_api_key = session.custom_settings.get("openweather_api_key", "")
146
-
147
- # 初始化模型 (應用設定)
148
- planner_model = Gemini(
149
- id=selected_model_id,
150
- thinking_budget=2048,
151
- api_key=gemini_api_key
152
- )
153
-
154
- main_model = Gemini(
155
- id=selected_model_id,
156
- thinking_budget=1024,
157
- api_key=gemini_api_key
158
- )
159
-
160
- lite_model = Gemini(
161
- id="gemini-2.5-flash-lite", # 輕量級模型通常固定或由次要選項決定
162
- api_key=gemini_api_key
163
- )
164
-
165
- # 配置模型和工具
166
- models_dict = {
167
- "team": main_model,
168
- "scout": main_model,
169
- "optimizer": lite_model,
170
- "navigator": lite_model,
171
- "weatherman": lite_model,
172
- "presenter": main_model,
173
- }
174
-
175
- # [Note] 如果 Toolkit 支援傳入 API Key,應在此處從 session.custom_settings 傳入
176
- tools_dict = {
177
- "scout": [ScoutToolkit(google_maps_api_key)],
178
- "optimizer": [OptimizationToolkit(google_maps_api_key)],
179
- "navigator": [NavigationToolkit(google_maps_api_key)],
180
- "weatherman": [WeatherToolkit(openweather_api_key)],
181
- "presenter": [ReaderToolkit()],
182
- }
183
-
184
- planner_kwargs = {
185
- "additional_context": get_context(session.user_state),
186
- "timezone_identifier": session.user_state.utc_offset,
187
- "debug_mode": False,
188
- }
189
-
190
- team_kwargs = {
191
- "timezone_identifier": session.user_state.utc_offset,
192
- }
193
-
194
- # 創建 Agents
195
- session.planner_agent = create_planner_agent(
196
- planner_model,
197
- planner_kwargs,
198
- session_id=session.session_id
199
- )
200
-
201
- session.core_team = create_core_team(
202
- models_dict,
203
- team_kwargs,
204
- tools_dict,
205
- session_id=session.session_id
206
- )
207
-
208
- logger.info(f"✅ Agents initialized for session {session.session_id} using model {selected_model_id}")
209
-
210
- def step1_analyze_tasks(self, user_input: str, auto_location: bool,
211
- lat: float, lon: float, session: UserSession):
212
- """Step 1: 真正的串流分析"""
213
- if not user_input.strip():
214
- yield from self._empty_step1_outputs(session)
215
- return
216
-
217
- if auto_location:
218
- lat, lon = 25.033, 121.565
219
-
220
- try:
221
- self._initialize_agents(session, lat, lon)
222
-
223
- self._add_reasoning(session, "planner", "🚀 Starting analysis...")
224
- yield self._create_step1_outputs(
225
- stream_text="🤔 Analyzing your request with AI...",
226
- session=session,
227
- agent_status=("planner", "working", "Initializing...")
228
- )
229
 
230
- time_module.sleep(0.3)
231
-
232
- self._add_reasoning(session, "planner", f"Processing: {user_input[:50]}...")
233
- yield self._create_step1_outputs(
234
- stream_text="🤔 Analyzing your request with AI...\n📋 AI is extracting tasks...",
235
- session=session,
236
- agent_status=("planner", "working", "Extracting tasks...")
237
- )
238
-
239
- planner_stream = session.planner_agent.run(
240
- f"help user to update the task_list, user's message: {user_input}",
241
- stream=True,
242
- stream_events=True
243
- )
244
-
245
- accumulated_response = ""
246
- displayed_text = "🤔 Analyzing your request with AI...\n📋 AI is extracting tasks...\n\n"
247
- show_content = True
248
-
249
- for chunk in planner_stream:
250
- if chunk.event == RunEvent.run_content:
251
- content = chunk.content
252
- accumulated_response += content
253
-
254
- if show_content:
255
- if "@@@" in accumulated_response:
256
- show_content = False
257
- remaining = content.split("@@@")[0]
258
- if remaining:
259
- displayed_text += remaining
260
- else:
261
- displayed_text += content
262
-
263
- yield self._create_step1_outputs(
264
- stream_text=displayed_text,
265
- session=session,
266
- agent_status=("planner", "working", "Processing...")
267
- )
268
-
269
- json_data = "{" + accumulated_response.split("{", maxsplit=1)[-1]
270
- json_data = json_data.replace("`", "").replace("@", "").replace("\\", " ").replace("\n", " ")
271
-
272
- session.planner_agent.update_session_state(
273
- session_id=session.session_id,
274
- session_state_updates={"task_list": json_data}
275
- )
276
-
277
- try:
278
- task_list_data = json.loads(json_data)
279
- session.task_list = self._convert_task_list_to_ui_format(task_list_data)
280
- except json.JSONDecodeError as e:
281
- logger.error(f"Failed to parse task_list: {e}")
282
- session.task_list = []
283
-
284
- self._add_reasoning(session, "planner", f"✅ Extracted {len(session.task_list)} tasks")
285
-
286
- high_priority = sum(1 for t in session.task_list if t.get("priority") == "HIGH")
287
- total_time = sum(
288
- int(t.get("duration", "0").split()[0])
289
- for t in session.task_list
290
- if t.get("duration")
291
- )
292
-
293
- final_text = displayed_text + f"\n✅ Analysis complete! Found {len(session.task_list)} tasks."
294
-
295
- yield self._create_step1_complete_outputs(
296
- stream_text=final_text,
297
- session=session,
298
- high_priority=high_priority,
299
- total_time=total_time
300
- )
301
-
302
- except Exception as e:
303
- logger.error(f"Error in step1: {e}", exc_info=True)
304
- yield self._create_error_outputs(str(e), session)
305
-
306
- def modify_task_chat(self, user_message: str, session: UserSession):
307
- """修改任務(帶真正的串流)"""
308
- if not user_message.strip():
309
- chat_html = self._generate_chat_history_html(session)
310
- task_html = self._generate_task_list_html(session)
311
- yield chat_html, task_html, session.to_dict()
312
- return
313
-
314
- session.chat_history.append({
315
- "role": "user",
316
- "message": user_message,
317
- "time": datetime.now().strftime("%H:%M:%S")
318
- })
319
-
320
- yield (
321
- self._generate_chat_history_html(session),
322
- self._generate_task_list_html(session),
323
- session.to_dict()
324
- )
325
-
326
- try:
327
- if session.planner_agent is None:
328
- if session.lat is not None and session.lng is not None:
329
- session.chat_history.append({
330
- "role": "assistant",
331
- "message": "🔄 Restoring AI system...",
332
- "time": datetime.now().strftime("%H:%M:%S")
333
- })
334
- yield (
335
- self._generate_chat_history_html(session),
336
- self._generate_task_list_html(session),
337
- session.to_dict()
338
- )
339
- self._initialize_agents(session, session.lat, session.lng)
340
- session.chat_history.pop()
341
- else:
342
- session.chat_history.append({
343
- "role": "assistant",
344
- "message": "❌ Error: Please restart the planning process.",
345
- "time": datetime.now().strftime("%H:%M:%S")
346
- })
347
- yield (
348
- self._generate_chat_history_html(session),
349
- self._generate_task_list_html(session),
350
- session.to_dict()
351
- )
352
- return
353
-
354
- session.chat_history.append({
355
- "role": "assistant",
356
- "message": "🤔 AI is thinking...",
357
- "time": datetime.now().strftime("%H:%M:%S")
358
- })
359
- yield (
360
- self._generate_chat_history_html(session),
361
- self._generate_task_list_html(session),
362
- session.to_dict()
363
- )
364
-
365
- planner_stream = session.planner_agent.run(
366
- f"help user to update the task_list, user's message: {user_message}",
367
- stream=True,
368
- stream_events=True
369
- )
370
-
371
- accumulated_response = ""
372
- displayed_thinking = "🤔 AI is thinking...\n\n"
373
- show_content = True
374
-
375
- for chunk in planner_stream:
376
- if chunk.event == RunEvent.run_content:
377
- content = chunk.content
378
- accumulated_response += content
379
-
380
- if show_content:
381
- if "@@@" in accumulated_response:
382
- show_content = False
383
- content = content.split("@@@")[0]
384
-
385
- if content:
386
- displayed_thinking += content
387
- session.chat_history[-1] = {
388
- "role": "assistant",
389
- "message": displayed_thinking,
390
- "time": datetime.now().strftime("%H:%M:%S")
391
- }
392
- yield (
393
- self._generate_chat_history_html(session),
394
- self._generate_task_list_html(session),
395
- session.to_dict()
396
- )
397
-
398
- json_data = "{" + accumulated_response.split("{", maxsplit=1)[-1]
399
- json_data = json_data.replace("`", "").replace("@", "").replace("\\", " ").replace("\n", " ")
400
-
401
- session.planner_agent.update_session_state(
402
- session_id=session.session_id,
403
- session_state_updates={"task_list": json_data}
404
- )
405
-
406
- task_list_data = json.loads(json_data)
407
- session.task_list = self._convert_task_list_to_ui_format(task_list_data)
408
 
409
- session.chat_history[-1] = {
410
- "role": "assistant",
411
- "message": "✅ Tasks updated based on your request",
412
- "time": datetime.now().strftime("%H:%M:%S")
413
- }
414
 
415
- self._add_reasoning(session, "planner", f"Updated: {user_message[:30]}...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
 
417
- yield (
418
- self._generate_chat_history_html(session),
419
- self._generate_task_list_html(session),
420
- session.to_dict()
421
- )
422
 
423
- except Exception as e:
424
- logger.error(f"Error in modify_task_chat: {e}", exc_info=True)
425
- if session.chat_history and "thinking" in session.chat_history[-1].get("message", "").lower():
426
- session.chat_history.pop()
427
- session.chat_history.append({
428
- "role": "assistant",
429
- "message": f"❌ Error: {str(e)}",
430
- "time": datetime.now().strftime("%H:%M:%S")
431
- })
432
  yield (
433
- self._generate_chat_history_html(session),
434
- self._generate_task_list_html(session),
435
- session.to_dict()
436
  )
437
 
438
- def step2_search_pois(self, session: UserSession):
439
- """Step 2: Scout 開始工作"""
440
- self._add_reasoning(session, "team", "🚀 Core Team activated")
441
- self._add_reasoning(session, "scout", "Searching for POIs...")
 
442
 
443
- agent_updates = [
444
- create_agent_card_enhanced("planner", "complete", "Tasks ready"),
445
- create_agent_card_enhanced("scout", "working", "Searching..."),
446
- *[create_agent_card_enhanced(k, "idle", "On standby")
447
- for k in ["optimizer", "validator", "weather", "traffic"]]
448
- ]
449
 
450
  return (
451
- get_reasoning_html_reversed(session.reasoning_messages),
452
  "🗺️ Scout is searching...",
453
- *agent_updates,
454
- session.to_dict()
455
  )
456
 
457
- def step3_run_core_team(self, session: UserSession):
458
- """Step 3: 運行 Core Team(帶串流)"""
459
- try:
460
- if session.core_team is None or session.planner_agent is None:
461
- if session.lat is not None and session.lng is not None:
462
- self._initialize_agents(session, session.lat, session.lng)
463
- else:
464
- raise ValueError("Cannot restore agents: location not available")
465
-
466
- set_session_id(session.session_id)
467
- task_list_input = session.planner_agent.get_session_state().get("task_list")
468
-
469
- if not task_list_input:
470
- raise ValueError("No task list available")
471
-
472
- if isinstance(task_list_input, str):
473
- task_list_str = task_list_input
474
- else:
475
- task_list_str = json.dumps(task_list_input, indent=2, ensure_ascii=False)
476
-
477
- self._add_reasoning(session, "team", "🎯 Multi-agent collaboration started")
478
-
479
- team_stream = session.core_team.run(
480
- f"Plan this trip: {task_list_str}",
481
- stream=True,
482
- stream_events=True,
483
- session_id=session.session_id
484
- )
485
-
486
- report_content = ""
487
-
488
- for event in team_stream:
489
- if event.event in [TeamRunEvent.run_content]:
490
- report_content += event.content
491
- elif event.event == "tool_call":
492
- tool_name = event.tool_call.get('function', {}).get('name', 'unknown')
493
- self._add_reasoning(session, "team", f"🔧 Tool: {tool_name}")
494
- elif event.event == TeamRunEvent.run_completed:
495
- self._add_reasoning(session, "team", "🎉 Completed")
496
-
497
- report_html = f"## 🎯 Planning Complete\n\n{report_content}..."
498
- return report_html, session.to_dict()
499
-
500
- except Exception as e:
501
- logger.error(f"Error in step3: {e}")
502
- return f"## ❌ Error\n\n{str(e)}", session.to_dict()
503
-
504
- def step4_finalize(self, session: UserSession):
505
- """Step 4: 完成"""
506
- try:
507
- final_ref_id = poi_repo.get_last_id_by_session(session.session_id)
508
- if not final_ref_id:
509
- raise ValueError(f"No final result found for session {session.session_id}")
510
-
511
- structured_data = poi_repo.load(final_ref_id)
512
- timeline = structured_data.get("timeline", [])
513
- metrics = structured_data.get("metrics", {})
514
- traffic_summary = structured_data.get("traffic_summary", {})
515
-
516
- timeline_html = self._generate_timeline_html(timeline)
517
- metrics_html = self._generate_metrics_html(metrics, traffic_summary)
518
-
519
- safe_task_list = session.task_list if session.task_list else []
520
- result_viz = create_result_visualization(safe_task_list, structured_data)
521
- map_fig = self._generate_map_from_data(structured_data)
522
-
523
- agent_updates = [
524
- create_agent_card_enhanced(k, "complete", "✓ Complete")
525
- for k in ["planner", "scout", "optimizer", "validator", "weather", "traffic"]
526
- ]
527
-
528
- self._add_reasoning(session, "team", "🎉 All completed")
529
- session.planning_completed = True
530
 
 
 
531
  return (
532
- timeline_html, metrics_html, result_viz, map_fig,
533
- gr.update(visible=True), gr.update(visible=False),
 
 
 
 
534
  "🎉 Planning completed!",
535
- *agent_updates,
536
- session.to_dict()
537
  )
538
-
539
- except Exception as e:
540
- logger.error(f"Error in step4: {e}", exc_info=True)
541
- error_html = f"<div style='color:red'>Error: {str(e)}</div>"
542
  default_map = create_animated_map()
543
- agent_updates = [
544
- create_agent_card_enhanced(k, "idle", "Waiting...")
545
- for k in ["planner", "scout", "optimizer", "validator", "weather", "traffic"]
546
- ]
547
  return (
548
- error_html, error_html, error_html, default_map,
549
- gr.update(visible=True), gr.update(visible=False),
550
- f"Error: {str(e)}",
551
- *agent_updates,
552
- session.to_dict()
553
  )
554
 
555
- # ===== 輔助方法 =====
556
-
557
- # [Security Fix] 新增:保存設定到 User Session
558
  def save_settings(self, google_key, weather_key, gemini_key, model, session_data):
559
- """保存設定到用戶 Session"""
560
  session = UserSession.from_dict(session_data)
561
-
562
  session.custom_settings['google_maps_api_key'] = google_key
563
  session.custom_settings['openweather_api_key'] = weather_key
564
  session.custom_settings['gemini_api_key'] = gemini_key
565
  session.custom_settings['model'] = model
566
  return "✅ Settings saved locally!", session.to_dict()
567
 
568
- def _add_reasoning(self, session: UserSession, agent: str, message: str):
569
- session.reasoning_messages.append({
570
- "agent": agent,
571
- "message": message,
572
- "time": datetime.now().strftime("%H:%M:%S")
573
- })
574
-
575
- def _create_step1_outputs(self, stream_text: str, session: UserSession, agent_status: tuple):
576
- agent_key, status, message = agent_status
577
- return (
578
- self._create_stream_html(stream_text), "", "",
579
- get_reasoning_html_reversed(session.reasoning_messages),
580
- gr.update(visible=False), gr.update(visible=False), "", message,
581
- create_agent_card_enhanced(agent_key, status, message),
582
- create_agent_card_enhanced("scout", "idle", "On standby"),
583
- create_agent_card_enhanced("optimizer", "idle", "On standby"),
584
- create_agent_card_enhanced("validator", "idle", "On standby"),
585
- create_agent_card_enhanced("weather", "idle", "On standby"),
586
- create_agent_card_enhanced("traffic", "idle", "On standby"),
587
- session.to_dict()
588
- )
589
-
590
- def _create_step1_complete_outputs(self, stream_text: str, session: UserSession,
591
- high_priority: int, total_time: int):
592
- summary_html = create_summary_card(len(session.task_list), high_priority, total_time)
593
- task_html = self._generate_task_list_html(session)
594
- return (
595
- self._create_stream_html(stream_text), summary_html, task_html,
596
- get_reasoning_html_reversed(session.reasoning_messages),
597
- gr.update(visible=True), gr.update(visible=False),
598
- self._generate_chat_welcome_html(), "✓ Tasks extracted",
599
- create_agent_card_enhanced("planner", "complete", "Tasks ready"),
600
- create_agent_card_enhanced("scout", "idle", "On standby"),
601
- create_agent_card_enhanced("optimizer", "idle", "On standby"),
602
- create_agent_card_enhanced("validator", "idle", "On standby"),
603
- create_agent_card_enhanced("weather", "idle", "On standby"),
604
- create_agent_card_enhanced("traffic", "idle", "On standby"),
605
- session.to_dict()
606
- )
607
-
608
- def _empty_step1_outputs(self, session: UserSession):
609
- yield (
610
- create_agent_stream_output(), "", "",
611
- get_reasoning_html_reversed(),
612
- gr.update(visible=False), gr.update(visible=False), "",
613
- "Waiting for input...",
614
- create_agent_card_enhanced("planner", "idle", "On standby"),
615
- create_agent_card_enhanced("scout", "idle", "On standby"),
616
- create_agent_card_enhanced("optimizer", "idle", "On standby"),
617
- create_agent_card_enhanced("validator", "idle", "On standby"),
618
- create_agent_card_enhanced("weather", "idle", "On standby"),
619
- create_agent_card_enhanced("traffic", "idle", "On standby"),
620
- session.to_dict()
621
- )
622
-
623
- def _create_error_outputs(self, error: str, session: UserSession):
624
- return (
625
- self._create_stream_html(f"❌ Error: {error}"), "", "",
626
- get_reasoning_html_reversed(session.reasoning_messages),
627
- gr.update(visible=False), gr.update(visible=False), "",
628
- f"Error: {error}",
629
- create_agent_card_enhanced("planner", "idle", "Error occurred"),
630
- create_agent_card_enhanced("scout", "idle", "On standby"),
631
- create_agent_card_enhanced("optimizer", "idle", "On standby"),
632
- create_agent_card_enhanced("validator", "idle", "On standby"),
633
- create_agent_card_enhanced("weather", "idle", "On standby"),
634
- create_agent_card_enhanced("traffic", "idle", "On standby"),
635
- session.to_dict()
636
- )
637
-
638
- def _create_stream_html(self, text: str) -> str:
639
- return f"""<div class="stream-container"><div class="stream-text">{text}<span class="stream-cursor"></span></div></div>"""
640
-
641
- def _convert_task_list_to_ui_format(self, task_list_data):
642
- ui_tasks = []
643
- if isinstance(task_list_data, dict):
644
- tasks = task_list_data.get("tasks", [])
645
- elif isinstance(task_list_data, list):
646
- tasks = task_list_data
647
- else:
648
- return []
649
-
650
- for i, task in enumerate(tasks, 1):
651
- ui_task = {
652
- "id": i,
653
- "title": task.get("description", "Task"),
654
- "priority": task.get("priority", "MEDIUM"),
655
- "time": task.get("time_window", "Anytime"),
656
- "duration": f"{task.get('duration_minutes', 30)} minutes",
657
- "location": task.get("location_hint", "To be determined"),
658
- "icon": self._get_task_icon(task.get("category", "other"))
659
- }
660
- ui_tasks.append(ui_task)
661
- return ui_tasks
662
-
663
- def _get_task_icon(self, category: str) -> str:
664
- icons = {
665
- "medical": "🏥", "shopping": "🛒", "postal": "📮",
666
- "food": "🍽️", "entertainment": "🎭", "transportation": "🚗",
667
- "other": "📋"
668
- }
669
- return icons.get(category.lower(), "📋")
670
-
671
- def _generate_task_list_html(self, session: UserSession) -> str:
672
- if not session.task_list:
673
- return "<p>No tasks available</p>"
674
- html = ""
675
- for task in session.task_list:
676
- html += create_task_card(
677
- task["id"], task["title"], task["priority"],
678
- task["time"], task["duration"], task["location"],
679
- task.get("icon", "📋")
680
- )
681
- return html
682
-
683
- def _generate_chat_history_html(self, session: UserSession) -> str:
684
- if not session.chat_history:
685
- return self._generate_chat_welcome_html()
686
- html = '<div class="chat-history">'
687
- for msg in session.chat_history:
688
- role_class = "user" if msg["role"] == "user" else "assistant"
689
- icon = "👤" if msg["role"] == "user" else "🤖"
690
- html += f"""
691
- <div class="chat-message {role_class}">
692
- <span class="chat-icon">{icon}</span>
693
- <div class="chat-content">
694
- <div class="chat-text">{msg["message"]}</div>
695
- <div class="chat-time">{msg["time"]}</div>
696
- </div>
697
- </div>
698
- """
699
- html += '</div>'
700
- return html
701
-
702
- def _generate_chat_welcome_html(self) -> str:
703
- return """
704
- <div class="chat-welcome">
705
- <h3>💬 Chat with LifeFlow AI</h3>
706
- <p>Modify your tasks by chatting here.</p>
707
- </div>
708
- """
709
-
710
- def _generate_timeline_html(self, timeline):
711
- """
712
- 生成右側 Timeline Tab 的 HTML
713
- 使用 structured_data['timeline'] 中的豐富數據
714
- """
715
- if not timeline:
716
- return "<div>No timeline data</div>"
717
-
718
- html = '<div class="timeline-container">'
719
- for i, stop in enumerate(timeline):
720
- time = stop.get("time", "N/A")
721
- location = stop.get("location", "Unknown Location")
722
- weather = stop.get("weather", "")
723
- aqi = stop.get("aqi", {}).get("label", "")
724
- travel_time = stop.get("travel_time_from_prev", "")
725
-
726
- # 決定標記樣式
727
- marker_class = "start" if i == 0 else "stop"
728
-
729
- html += f"""
730
- <div class="timeline-item">
731
- <div class="timeline-marker {marker_class}">{i}</div>
732
- <div class="timeline-content">
733
- <div class="timeline-header">
734
- <span class="time-badge">{time}</span>
735
- <h4>{location}</h4>
736
- </div>
737
- <div class="timeline-details">
738
- {f'<span class="weather-tag">🌤️ {weather}</span>' if weather else ''}
739
- {f'<span class="aqi-tag">{aqi}</span>' if aqi else ''}
740
- </div>
741
- {f'<p class="travel-info">🚗 Drive: {travel_time}</p>' if i > 0 else ''}
742
- </div>
743
- </div>
744
- """
745
- html += '</div>'
746
-
747
- # 補充少許 CSS 以確保美觀
748
- html += """
749
- <style>
750
- .timeline-header { display: flex; align-items: center; gap: 10px; margin-bottom: 5px; }
751
- .time-badge { background: #e3f2fd; color: #1976d2; padding: 2px 8px; border-radius: 4px; font-weight: bold; font-size: 12px; }
752
- .timeline-details { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 5px; }
753
- .weather-tag, .aqi-tag { font-size: 11px; padding: 2px 6px; background: #f5f5f5; border-radius: 4px; border: 1px solid #e0e0e0; }
754
- .travel-info { font-size: 12px; color: #666; margin: 0; display: flex; align-items: center; gap: 5px; }
755
- </style>
756
- """
757
- return html
758
-
759
- def _generate_metrics_html(self, metrics, traffic_summary):
760
- """生成左側的簡單 Metrics"""
761
- if traffic_summary is None: traffic_summary = {}
762
- if metrics is None: metrics = {}
763
-
764
- total_distance = traffic_summary.get("total_distance_km", 0)
765
- total_duration = traffic_summary.get("total_duration_min", 0)
766
- efficiency = metrics.get("route_efficiency_pct", 90)
767
-
768
- # 根據效率改變顏色
769
- eff_color = "#50C878" if efficiency >= 80 else "#F5A623"
770
-
771
- return f"""
772
- <div class="metrics-container">
773
- <div class="metric-card">
774
- <h3>📏 Distance</h3>
775
- <p class="metric-value">{total_distance:.1f} km</p>
776
- </div>
777
- <div class="metric-card">
778
- <h3>⏱️ Duration</h3>
779
- <p class="metric-value">{total_duration:.0f} min</p>
780
- </div>
781
- <div class="metric-card" style="border-bottom: 3px solid {eff_color}">
782
- <h3>⚡ Efficiency</h3>
783
- <p class="metric-value" style="color: {eff_color}">{efficiency:.0f}%</p>
784
- </div>
785
- </div>
786
- """
787
-
788
- def _generate_map_from_data(self, structured_data):
789
- """
790
- 生成 Plotly 地圖,包含真實道路軌跡 (Polyline)
791
- """
792
- import plotly.graph_objects as go
793
- from core.utils import decode_polyline # 確保導入了解碼函數
794
-
795
- try:
796
- timeline = structured_data.get("timeline", [])
797
- precise_result = structured_data.get("precise_traffic_result", {})
798
- legs = precise_result.get("legs", [])
799
-
800
- if not timeline:
801
- return create_animated_map()
802
-
803
- fig = go.Figure()
804
-
805
- # 1. 繪製路線 (Polyline) - 藍色路徑
806
- # 從 legs 中提取 polyline 並解碼
807
- route_lats = []
808
- route_lons = []
809
-
810
- for leg in legs:
811
- poly_str = leg.get("polyline")
812
- if poly_str:
813
- decoded = decode_polyline(poly_str)
814
- # 解碼後是 list of (lat, lng)
815
- leg_lats = [coord[0] for coord in decoded]
816
- leg_lons = [coord[1] for coord in decoded]
817
-
818
- # 為了讓線段連續,我們可以將其串接 (plotly 支援 nan 分隔,或直接單一 trace)
819
- # 這裡簡單起見,我們將每個 leg 分開加,或者合併。合併效能較好。
820
- route_lats.extend(leg_lats)
821
- route_lons.extend(leg_lons)
822
-
823
- if route_lats:
824
- fig.add_trace(go.Scattermapbox(
825
- lat=route_lats,
826
- lon=route_lons,
827
- mode='lines',
828
- line=dict(width=4, color='#4A90E2'),
829
- name='Route',
830
- hoverinfo='none' # 路徑本身不需 hover
831
- ))
832
-
833
- # 2. 繪製站點 (Markers)
834
- lats, lons, hover_texts, colors, sizes = [], [], [], [], []
835
-
836
- for i, stop in enumerate(timeline):
837
- coords = stop.get("coordinates", {})
838
- lat = coords.get("lat")
839
- lng = coords.get("lng")
840
-
841
- if lat and lng:
842
- lats.append(lat)
843
- lons.append(lng)
844
-
845
- # 構建 Hover Text
846
- name = stop.get("location", "Stop")
847
- time = stop.get("time", "")
848
- weather = stop.get("weather", "")
849
- text = f"<b>{i}. {name}</b><br>🕒 {time}<br>🌤️ {weather}"
850
- hover_texts.append(text)
851
-
852
- # 樣式:起點綠色,終點紅色,中間藍色
853
- if i == 0:
854
- colors.append('#50C878') # Start
855
- sizes.append(15)
856
- elif i == len(timeline) - 1:
857
- colors.append('#FF6B6B') # End
858
- sizes.append(15)
859
- else:
860
- colors.append('#F5A623') # Middle
861
- sizes.append(12)
862
-
863
- fig.add_trace(go.Scattermapbox(
864
- lat=lats, lon=lons,
865
- mode='markers+text',
866
- marker=dict(size=sizes, color=colors, allowoverlap=True),
867
- text=[str(i) for i in range(len(lats))], # 顯示序號
868
- textposition="top center",
869
- textfont=dict(size=14, color='black', family="Arial Black"),
870
- hovertext=hover_texts,
871
- hoverinfo='text',
872
- name='Stops'
873
- ))
874
-
875
- # 設定地圖中心與縮放
876
- if lats:
877
- center_lat = sum(lats) / len(lats)
878
- center_lon = sum(lons) / len(lons)
879
- else:
880
- center_lat, center_lon = 25.033, 121.565
881
-
882
- fig.update_layout(
883
- mapbox=dict(
884
- style='open-street-map', # 或 'carto-positron' 看起來更乾淨
885
- center=dict(lat=center_lat, lon=center_lon),
886
- zoom=12
887
- ),
888
- margin=dict(l=0, r=0, t=0, b=0),
889
- height=500,
890
- showlegend=False
891
- )
892
-
893
- return fig
894
- except Exception as e:
895
- logger.error(f"Error generating map: {e}", exc_info=True)
896
- return create_animated_map()
897
-
898
  def build_interface(self):
899
  with gr.Blocks(title=APP_TITLE) as demo:
900
  gr.HTML(get_enhanced_css())
901
  create_header()
902
-
903
  theme_btn, settings_btn, doc_btn = create_top_controls()
904
 
905
- # 🔥 每個 Browser Tab 都有獨立的 UserSession
906
  session_state = gr.State(value=UserSession().to_dict())
907
 
908
  with gr.Row():
 
909
  with gr.Column(scale=2, min_width=400):
910
  (input_area, agent_stream_output, user_input, auto_location,
911
  location_inputs, lat_input, lon_input, analyze_btn) = create_input_form(
@@ -915,10 +221,14 @@ class LifeFlowAI:
915
  (task_confirm_area, task_summary_display,
916
  task_list_display, exit_btn_inline, ready_plan_btn) = create_confirmation_area()
917
 
 
918
  team_area, agent_displays = create_team_area(create_agent_card_enhanced)
 
 
919
  (result_area, result_display, timeline_display, metrics_display) = create_result_area(
920
  create_animated_map)
921
 
 
922
  with gr.Column(scale=3, min_width=500):
923
  status_bar = gr.Textbox(label="📊 Status", value="Waiting for input...", interactive=False,
924
  max_lines=1)
@@ -927,22 +237,19 @@ class LifeFlowAI:
927
  create_animated_map, get_reasoning_html_reversed()
928
  )
929
 
 
930
  (settings_modal, google_maps_key, openweather_key, gemini_api_key,
931
  model_choice, close_settings_btn, save_settings_btn,
932
  settings_status) = create_settings_modal()
933
-
934
  doc_modal, close_doc_btn = create_doc_modal()
935
 
936
- # ===== Event Handlers =====
937
 
938
  auto_location.change(fn=toggle_location_inputs, inputs=[auto_location], outputs=[location_inputs])
939
 
940
- def analyze_wrapper(ui, al, lat, lon, s):
941
- session = UserSession.from_dict(s)
942
- yield from self.step1_analyze_tasks(ui, al, lat, lon, session)
943
-
944
  analyze_btn.click(
945
- fn=analyze_wrapper,
946
  inputs=[user_input, auto_location, lat_input, lon_input, session_state],
947
  outputs=[
948
  agent_stream_output, task_summary_display, task_list_display,
@@ -954,16 +261,14 @@ class LifeFlowAI:
954
  outputs=[input_area, task_confirm_area, chat_input_area]
955
  )
956
 
957
- def chat_wrapper(msg, s):
958
- session = UserSession.from_dict(s)
959
- yield from self.modify_task_chat(msg, session)
960
-
961
  chat_send.click(
962
- fn=chat_wrapper,
963
  inputs=[chat_input, session_state],
964
  outputs=[chat_history_output, task_list_display, session_state]
965
  ).then(fn=lambda: "", outputs=[chat_input])
966
 
 
967
  exit_btn_inline.click(
968
  fn=lambda: (
969
  gr.update(visible=True), gr.update(visible=False),
@@ -971,34 +276,34 @@ class LifeFlowAI:
971
  gr.update(visible=False), gr.update(visible=False),
972
  gr.update(visible=False), "",
973
  create_agent_stream_output(),
974
- self._generate_chat_welcome_html(),
975
  "Ready...",
976
  UserSession().to_dict()
977
  ),
978
  outputs=[
979
  input_area, task_confirm_area, chat_input_area, result_area,
980
  team_area, report_tab, map_tab, user_input,
981
- agent_stream_output, chat_history_output, status_bar, session_state
982
  ]
983
  )
984
 
 
985
  ready_plan_btn.click(
986
  fn=lambda: (gr.update(visible=False), gr.update(visible=False),
987
  gr.update(visible=True), gr.update(selected="ai_conversation_tab")),
988
  outputs=[task_confirm_area, chat_input_area, team_area, tabs]
989
  ).then(
990
- fn=lambda s: self.step2_search_pois(UserSession.from_dict(s)),
991
  inputs=[session_state],
992
  outputs=[reasoning_output, status_bar, *agent_displays, session_state]
993
  ).then(
994
- fn=lambda s: self.step3_run_core_team(UserSession.from_dict(s)),
995
  inputs=[session_state],
996
  outputs=[report_output, session_state]
997
  ).then(
998
  fn=lambda: (gr.update(visible=True), gr.update(visible=True), gr.update(selected="report_tab")),
999
  outputs=[report_tab, map_tab, tabs]
1000
  ).then(
1001
- fn=lambda s: self.step4_finalize(UserSession.from_dict(s)),
1002
  inputs=[session_state],
1003
  outputs=[
1004
  timeline_display, metrics_display, result_display,
@@ -1006,18 +311,15 @@ class LifeFlowAI:
1006
  ]
1007
  ).then(fn=lambda: gr.update(visible=True), outputs=[result_area])
1008
 
1009
- # Settings
1010
  settings_btn.click(fn=lambda: gr.update(visible=True), outputs=[settings_modal])
1011
  close_settings_btn.click(fn=lambda: gr.update(visible=False), outputs=[settings_modal])
1012
-
1013
- # [Security Fix] 儲存設定到 session_state
1014
  save_settings_btn.click(
1015
  fn=self.save_settings,
1016
  inputs=[google_maps_key, openweather_key, gemini_api_key, model_choice, session_state],
1017
  outputs=[settings_status, session_state]
1018
  )
1019
 
1020
- # Theme
1021
  theme_btn.click(fn=None, js="""
1022
  () => {
1023
  const container = document.querySelector('.gradio-container');
 
1
  """
2
+ LifeFlow AI - Main Application (Refactored)
3
+ Controller Layer: 負責 UI 佈局與 Service 連接
4
  """
5
 
 
 
6
  import gradio as gr
7
+ from typing import List
 
 
 
 
8
 
9
+ # ===== Core & Data Models =====
10
+ from core.session import UserSession
11
+ from services.planner_service import PlannerService
12
 
13
+ # ===== UI & Config =====
14
+ from config import APP_TITLE, DEFAULT_SETTINGS
15
  from ui.theme import get_enhanced_css
16
+ from ui.renderers import (
17
+ create_agent_stream_output,
18
+ create_agent_card_enhanced,
19
+ get_reasoning_html_reversed,
20
+ generate_chat_history_html_bubble
21
+ )
22
+ from core.visualizers import create_animated_map
23
+
24
+ # ===== UI Components =====
25
  from ui.components.header import create_header, create_top_controls
26
  from ui.components.input_form import create_input_form, toggle_location_inputs
27
+ from ui.components.confirmation import create_confirmation_area
28
  from ui.components.results import create_team_area, create_result_area, create_tabs
29
  from ui.components.modals import create_settings_modal, create_doc_modal
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  class LifeFlowAI:
 
 
33
  def __init__(self):
34
+ self.service = PlannerService()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
+ def _get_agent_outputs(self, active_agent: str = None, status: str = "idle", message: str = "Waiting") -> List[str]:
37
+ """
38
+ 輔助函數:生成 6 Agent 卡片的 HTML 列表
39
+ 用於在 Gradio 中更新 agent_displays
40
+ """
41
+ agents = ['planner', 'scout', 'optimizer', 'validator', 'weather', 'traffic']
42
+ outputs = []
43
+ for agent in agents:
44
+ if agent == active_agent:
45
+ outputs.append(create_agent_card_enhanced(agent, status, message))
46
+ else:
47
+ # 簡單處理:非活動 Agent 顯示 Idle 或保持原狀 (這裡簡化為 Idle,可根據需求優化)
48
+ outputs.append(create_agent_card_enhanced(agent, "idle", "On standby"))
49
+ return outputs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
+ def analyze_wrapper(self, user_input, auto_loc, lat, lon, session_data):
52
+ """Step 1: 呼叫 Service 並將結果轉換為 Gradio Updates"""
53
+ session = UserSession.from_dict(session_data)
 
 
54
 
55
+ # 呼叫 Service Generator
56
+ iterator = self.service.run_step1_analysis(user_input, auto_loc, lat, lon, session)
57
+
58
+ for event in iterator:
59
+ evt_type = event.get("type")
60
+
61
+ # 準備 Agent 狀態
62
+ agent_status = event.get("agent_status", ("planner", "idle", "Waiting"))
63
+ agent_outputs = self._get_agent_outputs(*agent_status)
64
+
65
+ # 獲取推理過程 HTML
66
+ # 注意:event 中可能已更新 session,從 session 獲取最新 reasoning
67
+ current_session = event.get("session", session)
68
+ reasoning_html = get_reasoning_html_reversed(current_session.reasoning_messages)
69
+
70
+ if evt_type == "stream":
71
+ yield (
72
+ create_agent_stream_output().replace("Ready to analyze...", event.get("stream_text", "")),
73
+ gr.HTML(), # Task Summary (Empty)
74
+ gr.HTML(), # Task List (Empty)
75
+ reasoning_html,
76
+ gr.update(visible=False), # Confirm Area
77
+ gr.update(visible=False), # Chat Input
78
+ gr.HTML(), # Chat History
79
+ f"Processing: {agent_status[2]}", # Status Bar
80
+ *agent_outputs,
81
+ current_session.to_dict()
82
+ )
83
+
84
+ elif evt_type == "complete":
85
+ # 完成時生成 Task List HTML
86
+ task_html = self.service.generate_task_list_html(current_session)
87
+ # 生成 Summary HTML (這裡簡化調用,實際可從 service 獲取)
88
+ summary_html = f"<div class='summary-card'>Found {len(current_session.task_list)} tasks</div>"
89
+
90
+ # 更新所有 Agent 為完成
91
+ final_agents = self._get_agent_outputs("planner", "complete", "Tasks ready")
92
+
93
+ yield (
94
+ create_agent_stream_output().replace("Ready...", event.get("stream_text", "")),
95
+ gr.HTML(value=summary_html),
96
+ gr.HTML(value=task_html),
97
+ reasoning_html,
98
+ gr.update(visible=True), # Confirm Area Show
99
+ gr.update(visible=False),
100
+ generate_chat_history_html_bubble(current_session),
101
+ "✓ Tasks extracted",
102
+ *final_agents,
103
+ current_session.to_dict()
104
+ )
105
+
106
+ elif evt_type == "error":
107
+ err_msg = event.get("message", "Unknown error")
108
+ error_agents = self._get_agent_outputs("planner", "idle", "Error")
109
+ yield (
110
+ f"<div style='color:red'>Error: {err_msg}</div>",
111
+ gr.HTML(), gr.HTML(), reasoning_html,
112
+ gr.update(visible=False), gr.update(visible=False), gr.HTML(),
113
+ f"Error: {err_msg}",
114
+ *error_agents,
115
+ current_session.to_dict()
116
+ )
117
+
118
+ def chat_wrapper(self, msg, session_data):
119
+ """Chat Logic Wrapper"""
120
+ session = UserSession.from_dict(session_data)
121
+ iterator = self.service.modify_task_chat(msg, session)
122
 
123
+ for event in iterator:
124
+ current_session = event.get("session", session)
125
+ chat_html = generate_chat_history_html_bubble(current_session)
126
+ task_html = self.service.generate_task_list_html(current_session)
 
127
 
 
 
 
 
 
 
 
 
 
128
  yield (
129
+ chat_html,
130
+ task_html,
131
+ current_session.to_dict()
132
  )
133
 
134
+ def step2_wrapper(self, session_data):
135
+ """Step 2 Wrapper"""
136
+ session = UserSession.from_dict(session_data)
137
+ result = self.service.run_step2_search(session)
138
+ current_session = result.get("session", session)
139
 
140
+ reasoning_html = get_reasoning_html_reversed(current_session.reasoning_messages)
141
+ agent_outputs = self._get_agent_outputs("scout", "working", "Searching POIs...")
 
 
 
 
142
 
143
  return (
144
+ reasoning_html,
145
  "🗺️ Scout is searching...",
146
+ *agent_outputs,
147
+ current_session.to_dict()
148
  )
149
 
150
+ def step3_wrapper(self, session_data):
151
+ """Step 3 Wrapper"""
152
+ session = UserSession.from_dict(session_data)
153
+ iterator = self.service.run_step3_team(session)
154
+
155
+ for event in iterator:
156
+ current_session = event.get("session", session)
157
+ if event["type"] == "complete":
158
+ yield (event.get("report_html", ""), current_session.to_dict())
159
+ elif event["type"] == "error":
160
+ yield (f"Error: {event.get('message')}", current_session.to_dict())
161
+ # 可以根據需要處理中間狀態更新
162
+
163
+ def step4_wrapper(self, session_data):
164
+ """Step 4 Wrapper"""
165
+ session = UserSession.from_dict(session_data)
166
+ result = self.service.run_step4_finalize(session)
167
+ current_session = result.get("session", session)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
 
169
+ if result["type"] == "success":
170
+ agent_outputs = self._get_agent_outputs("team", "complete", "Done")
171
  return (
172
+ result["timeline_html"],
173
+ result["metrics_html"],
174
+ result["result_html"],
175
+ result["map_fig"],
176
+ gr.update(visible=True), # Show Map Tab
177
+ gr.update(visible=False), # Hide Team Area
178
  "🎉 Planning completed!",
179
+ *agent_outputs,
180
+ current_session.to_dict()
181
  )
182
+ else:
183
+ # Error handling
 
 
184
  default_map = create_animated_map()
185
+ agent_outputs = self._get_agent_outputs("team", "idle", "Error")
186
+ err = result.get("message", "Error")
 
 
187
  return (
188
+ f"Error: {err}", "", "", default_map,
189
+ gr.update(), gr.update(),
190
+ f"Error: {err}",
191
+ *agent_outputs,
192
+ current_session.to_dict()
193
  )
194
 
 
 
 
195
  def save_settings(self, google_key, weather_key, gemini_key, model, session_data):
196
+ """Settings Save Wrapper"""
197
  session = UserSession.from_dict(session_data)
 
198
  session.custom_settings['google_maps_api_key'] = google_key
199
  session.custom_settings['openweather_api_key'] = weather_key
200
  session.custom_settings['gemini_api_key'] = gemini_key
201
  session.custom_settings['model'] = model
202
  return "✅ Settings saved locally!", session.to_dict()
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  def build_interface(self):
205
  with gr.Blocks(title=APP_TITLE) as demo:
206
  gr.HTML(get_enhanced_css())
207
  create_header()
 
208
  theme_btn, settings_btn, doc_btn = create_top_controls()
209
 
210
+ # State
211
  session_state = gr.State(value=UserSession().to_dict())
212
 
213
  with gr.Row():
214
+ # Left Column
215
  with gr.Column(scale=2, min_width=400):
216
  (input_area, agent_stream_output, user_input, auto_location,
217
  location_inputs, lat_input, lon_input, analyze_btn) = create_input_form(
 
221
  (task_confirm_area, task_summary_display,
222
  task_list_display, exit_btn_inline, ready_plan_btn) = create_confirmation_area()
223
 
224
+ # Team Area
225
  team_area, agent_displays = create_team_area(create_agent_card_enhanced)
226
+
227
+ # Result Area
228
  (result_area, result_display, timeline_display, metrics_display) = create_result_area(
229
  create_animated_map)
230
 
231
+ # Right Column
232
  with gr.Column(scale=3, min_width=500):
233
  status_bar = gr.Textbox(label="📊 Status", value="Waiting for input...", interactive=False,
234
  max_lines=1)
 
237
  create_animated_map, get_reasoning_html_reversed()
238
  )
239
 
240
+ # Modals
241
  (settings_modal, google_maps_key, openweather_key, gemini_api_key,
242
  model_choice, close_settings_btn, save_settings_btn,
243
  settings_status) = create_settings_modal()
 
244
  doc_modal, close_doc_btn = create_doc_modal()
245
 
246
+ # ===== Event Binding =====
247
 
248
  auto_location.change(fn=toggle_location_inputs, inputs=[auto_location], outputs=[location_inputs])
249
 
250
+ # Step 1: Analyze
 
 
 
251
  analyze_btn.click(
252
+ fn=self.analyze_wrapper,
253
  inputs=[user_input, auto_location, lat_input, lon_input, session_state],
254
  outputs=[
255
  agent_stream_output, task_summary_display, task_list_display,
 
261
  outputs=[input_area, task_confirm_area, chat_input_area]
262
  )
263
 
264
+ # Chat
 
 
 
265
  chat_send.click(
266
+ fn=self.chat_wrapper,
267
  inputs=[chat_input, session_state],
268
  outputs=[chat_history_output, task_list_display, session_state]
269
  ).then(fn=lambda: "", outputs=[chat_input])
270
 
271
+ # Exit
272
  exit_btn_inline.click(
273
  fn=lambda: (
274
  gr.update(visible=True), gr.update(visible=False),
 
276
  gr.update(visible=False), gr.update(visible=False),
277
  gr.update(visible=False), "",
278
  create_agent_stream_output(),
 
279
  "Ready...",
280
  UserSession().to_dict()
281
  ),
282
  outputs=[
283
  input_area, task_confirm_area, chat_input_area, result_area,
284
  team_area, report_tab, map_tab, user_input,
285
+ agent_stream_output, status_bar, session_state
286
  ]
287
  )
288
 
289
+ # Step 2, 3, 4 Sequence
290
  ready_plan_btn.click(
291
  fn=lambda: (gr.update(visible=False), gr.update(visible=False),
292
  gr.update(visible=True), gr.update(selected="ai_conversation_tab")),
293
  outputs=[task_confirm_area, chat_input_area, team_area, tabs]
294
  ).then(
295
+ fn=self.step2_wrapper,
296
  inputs=[session_state],
297
  outputs=[reasoning_output, status_bar, *agent_displays, session_state]
298
  ).then(
299
+ fn=self.step3_wrapper,
300
  inputs=[session_state],
301
  outputs=[report_output, session_state]
302
  ).then(
303
  fn=lambda: (gr.update(visible=True), gr.update(visible=True), gr.update(selected="report_tab")),
304
  outputs=[report_tab, map_tab, tabs]
305
  ).then(
306
+ fn=self.step4_wrapper,
307
  inputs=[session_state],
308
  outputs=[
309
  timeline_display, metrics_display, result_display,
 
311
  ]
312
  ).then(fn=lambda: gr.update(visible=True), outputs=[result_area])
313
 
314
+ # Settings & Docs Handlers
315
  settings_btn.click(fn=lambda: gr.update(visible=True), outputs=[settings_modal])
316
  close_settings_btn.click(fn=lambda: gr.update(visible=False), outputs=[settings_modal])
 
 
317
  save_settings_btn.click(
318
  fn=self.save_settings,
319
  inputs=[google_maps_key, openweather_key, gemini_api_key, model_choice, session_state],
320
  outputs=[settings_status, session_state]
321
  )
322
 
 
323
  theme_btn.click(fn=None, js="""
324
  () => {
325
  const container = document.querySelector('.gradio-container');
core/helpers.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LifeFlow AI - Core Helpers
3
+ 純資料處理工具,無 UI 依賴
4
+ """
5
+
6
+ def decode_polyline(polyline_str):
7
+ """
8
+ 解碼 Google Maps Polyline 字符串為 (lat, lng) 列表
9
+ 純 Python 實現,無需額外依賴
10
+ """
11
+ index, lat, lng = 0, 0, 0
12
+ coordinates = []
13
+ changes = {'latitude': 0, 'longitude': 0}
14
+
15
+ while index < len(polyline_str):
16
+ for unit in ['latitude', 'longitude']:
17
+ shift, result = 0, 0
18
+ while True:
19
+ byte = ord(polyline_str[index]) - 63
20
+ index += 1
21
+ result |= (byte & 0x1f) << shift
22
+ shift += 5
23
+ if not byte >= 0x20:
24
+ break
25
+ if (result & 1):
26
+ changes[unit] = ~(result >> 1)
27
+ else:
28
+ changes[unit] = (result >> 1)
29
+
30
+ lat += changes['latitude']
31
+ lng += changes['longitude']
32
+ coordinates.append((lat / 100000.0, lng / 100000.0))
33
+
34
+ return coordinates
core/session.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LifeFlow AI - Session Management
3
+ 負責定義用戶會話的數據結構 (Model)
4
+ """
5
+ import uuid
6
+ from typing import Dict, Any, Optional
7
+ from src.agent.base import UserState, Location
8
+ from src.infra.context import set_session_id
9
+ from src.infra.config import get_settings
10
+ from src.infra.logger import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+ class UserSession:
15
+ """
16
+ 單個用戶的會話數據
17
+ 每個用戶有獨立的實例,確保資料隔離
18
+ """
19
+
20
+ def __init__(self):
21
+ self.session_id: Optional[str] = None
22
+ self.planner_agent = None
23
+ self.core_team = None
24
+ self.user_state: Optional[UserState] = None
25
+ self.task_list: list = []
26
+ self.reasoning_messages: list = []
27
+ self.chat_history: list = []
28
+ self.planning_completed: bool = False
29
+
30
+ # 存儲位置信息(用於重新初始化 Agent)
31
+ self.lat: Optional[float] = None
32
+ self.lng: Optional[float] = None
33
+
34
+ # [Security Fix] 用戶個人的自定義設定 (API Keys, Model Choice)
35
+ self.custom_settings: Dict[str, Any] = {}
36
+
37
+ # 系統預設設定
38
+ self.agno_settings = get_settings()
39
+
40
+ def to_dict(self) -> Dict[str, Any]:
41
+ """序列化為字典(用於 Gradio State)"""
42
+ return {
43
+ 'session_id': self.session_id,
44
+ 'task_list': self.task_list,
45
+ 'reasoning_messages': self.reasoning_messages,
46
+ 'chat_history': self.chat_history,
47
+ 'planning_completed': self.planning_completed,
48
+ 'lat': self.lat,
49
+ 'lng': self.lng,
50
+ 'custom_settings': self.custom_settings
51
+ }
52
+
53
+ @classmethod
54
+ def from_dict(cls, data: Dict[str, Any]) -> 'UserSession':
55
+ """從字典恢復(用於 Gradio State)"""
56
+ session = cls()
57
+ session.session_id = data.get('session_id')
58
+ session.task_list = data.get('task_list', [])
59
+ session.reasoning_messages = data.get('reasoning_messages', [])
60
+ session.chat_history = data.get('chat_history', [])
61
+ session.planning_completed = data.get('planning_completed', False)
62
+ session.lat = data.get('lat')
63
+ session.lng = data.get('lng')
64
+ session.custom_settings = data.get('custom_settings', {})
65
+ return session
core/visualizers.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LifeFlow AI - Visualizers
3
+ 負責生成 Plotly 圖表物件 (完整版)
4
+ """
5
+ import plotly.graph_objects as go
6
+ from core.helpers import decode_polyline
7
+ from src.infra.logger import get_logger
8
+
9
+ logger = get_logger(__name__)
10
+
11
+ def create_animated_map(structured_data=None):
12
+ """
13
+ 生成 Plotly 地圖,包含真實道路軌跡 (Polyline)
14
+ :param structured_data: 從資料庫載入的完整行程資料
15
+ """
16
+ fig = go.Figure()
17
+
18
+ # 如果沒有數據,回傳預設地圖 (台北101周邊)
19
+ if not structured_data:
20
+ default_lats = [25.033, 25.038]
21
+ default_lons = [121.565, 121.560]
22
+ fig.add_trace(go.Scattermapbox(
23
+ lat=default_lats, lon=default_lons,
24
+ mode='markers', marker=dict(size=10, color='#999'),
25
+ text=['No Data'], hoverinfo='text'
26
+ ))
27
+ center_lat, center_lon = 25.033, 121.565
28
+ else:
29
+ try:
30
+ timeline = structured_data.get("timeline", [])
31
+ precise_result = structured_data.get("precise_traffic_result", {})
32
+ legs = precise_result.get("legs", [])
33
+
34
+ # 1. 繪製路線 (Polyline) - 藍色路徑
35
+ route_lats = []
36
+ route_lons = []
37
+
38
+ for leg in legs:
39
+ poly_str = leg.get("polyline")
40
+ if poly_str:
41
+ decoded = decode_polyline(poly_str)
42
+ # 解碼後是 list of (lat, lng)
43
+ leg_lats = [coord[0] for coord in decoded]
44
+ leg_lons = [coord[1] for coord in decoded]
45
+
46
+ # 插入 None 來斷開不同路段 (如果你希望路段分開)
47
+ # 或者直接串接 (視覺上較連續)
48
+ route_lats.extend(leg_lats)
49
+ route_lons.extend(leg_lons)
50
+ route_lats.append(None) # 斷開不同路段,避免直線飛越
51
+ route_lons.append(None)
52
+
53
+ if route_lats:
54
+ fig.add_trace(go.Scattermapbox(
55
+ lat=route_lats,
56
+ lon=route_lons,
57
+ mode='lines',
58
+ line=dict(width=4, color='#4A90E2'),
59
+ name='Route',
60
+ hoverinfo='none'
61
+ ))
62
+
63
+ # 2. 繪製站點 (Markers)
64
+ lats, lons, hover_texts, colors, sizes = [], [], [], [], []
65
+
66
+ for i, stop in enumerate(timeline):
67
+ coords = stop.get("coordinates", {})
68
+ lat = coords.get("lat")
69
+ lng = coords.get("lng")
70
+
71
+ if lat and lng:
72
+ lats.append(lat)
73
+ lons.append(lng)
74
+
75
+ # 構建 Hover Text
76
+ name = stop.get("location", "Stop")
77
+ time_str = stop.get("time", "")
78
+ weather = stop.get("weather", "")
79
+ text = f"<b>{i}. {name}</b><br>🕒 {time_str}<br>🌤️ {weather}"
80
+ hover_texts.append(text)
81
+
82
+ # 樣式:起點綠色,終點紅色,中間黃色
83
+ if i == 0:
84
+ colors.append('#50C878') # Start
85
+ sizes.append(15)
86
+ elif i == len(timeline) - 1:
87
+ colors.append('#FF6B6B') # End
88
+ sizes.append(15)
89
+ else:
90
+ colors.append('#F5A623') # Middle
91
+ sizes.append(12)
92
+
93
+ fig.add_trace(go.Scattermapbox(
94
+ lat=lats, lon=lons,
95
+ mode='markers+text',
96
+ marker=dict(size=sizes, color=colors, allowoverlap=True),
97
+ text=[str(i) for i in range(len(lats))],
98
+ textposition="top center",
99
+ textfont=dict(size=14, color='black', family="Arial Black"),
100
+ hovertext=hover_texts,
101
+ hoverinfo='text',
102
+ name='Stops'
103
+ ))
104
+
105
+ # 計算中心點
106
+ if lats:
107
+ center_lat = sum(lats) / len(lats)
108
+ center_lon = sum(lons) / len(lons)
109
+ else:
110
+ center_lat, center_lon = 25.033, 121.565
111
+
112
+ except Exception as e:
113
+ logger.error(f"Error generating map data: {e}", exc_info=True)
114
+ center_lat, center_lon = 25.033, 121.565
115
+
116
+ # 設定地圖樣式
117
+ fig.update_layout(
118
+ mapbox=dict(
119
+ style='open-street-map',
120
+ center=dict(lat=center_lat, lon=center_lon),
121
+ zoom=12
122
+ ),
123
+ margin=dict(l=0, r=0, t=0, b=0),
124
+ height=500,
125
+ showlegend=False
126
+ )
127
+
128
+ return fig
services/__init__.py ADDED
File without changes
services/planner_service.py ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LifeFlow AI - Planner Service (Fixed: Session Persistence)
3
+ 核心業務邏輯層:負責協調 Agent 運作、狀態更新與資料處理。
4
+ """
5
+ import json
6
+ import time
7
+ import uuid
8
+ from datetime import datetime
9
+ from typing import Generator, Dict, Any, Tuple, Optional
10
+
11
+ # 導入 Core & UI 模組
12
+ from core.session import UserSession
13
+ from ui.renderers import (
14
+ create_agent_stream_output,
15
+ create_agent_card_enhanced,
16
+ create_task_card,
17
+ create_summary_card,
18
+ create_timeline_html_enhanced,
19
+ generate_chat_history_html_bubble,
20
+ get_reasoning_html_reversed,
21
+ create_result_visualization,
22
+ create_metrics_cards
23
+ )
24
+ from core.visualizers import create_animated_map
25
+
26
+ # 導入 Config
27
+ from config import AGENTS_INFO
28
+
29
+ # 導入 Agent 系統
30
+ from agno.models.google import Gemini
31
+ from agno.agent import RunEvent
32
+ from agno.run.team import TeamRunEvent
33
+ from src.agent.base import UserState, Location, get_context
34
+ from src.agent.planner import create_planner_agent
35
+ from src.agent.core_team import create_core_team
36
+ from src.infra.context import set_session_id
37
+ from src.infra.poi_repository import poi_repo
38
+ from src.tools import (
39
+ ScoutToolkit, OptimizationToolkit,
40
+ NavigationToolkit, WeatherToolkit, ReaderToolkit
41
+ )
42
+ from src.infra.logger import get_logger
43
+
44
+ logger = get_logger(__name__)
45
+
46
+ class PlannerService:
47
+ """
48
+ PlannerService 封裝了所有的業務流程 (Step 1-4)。
49
+ 修正:使用 class level dictionary 來保存活著的 session 物件,防止 Agent 丟失。
50
+ """
51
+
52
+ # 🌟 [Fix] In-Memory Store: 保存包含 Agent 實例的完整 Session 物件
53
+ _active_sessions: Dict[str, UserSession] = {}
54
+
55
+ def _get_live_session(self, incoming_session: UserSession) -> UserSession:
56
+ """
57
+ [Fix] 輔助方法:
58
+ 從 Gradio 傳來的 incoming_session 只有數據 (Agent 為 None)。
59
+ 我們需要從 _active_sessions 取回包含 Agent 的真實 Session。
60
+ """
61
+ sid = incoming_session.session_id
62
+
63
+ # 如果這個 Session ID 已經在內存中,取出它並同步數據
64
+ if sid and sid in self._active_sessions:
65
+ live_session = self._active_sessions[sid]
66
+ # 同步前端可能修改過的數據到 Live Session
67
+ live_session.lat = incoming_session.lat
68
+ live_session.lng = incoming_session.lng
69
+ # 注意:這裡不覆蓋 task_list,除非我們確定前端有修改權限
70
+ # 這裡簡單假設前端傳來的 chat history 是最新的
71
+ if len(incoming_session.chat_history) > len(live_session.chat_history):
72
+ live_session.chat_history = incoming_session.chat_history
73
+ return live_session
74
+
75
+ # 如果是新的 Session,或者內存中沒有,則註冊它
76
+ if sid:
77
+ self._active_sessions[sid] = incoming_session
78
+ return incoming_session
79
+
80
+ def initialize_agents(self, session: UserSession, lat: float, lng: float) -> UserSession:
81
+ """初始化 Agents 並保存到內存"""
82
+ # 1. 確保我們操作的是 Live Session
83
+ session = self._get_live_session(session)
84
+
85
+ session.lat = lat
86
+ session.lng = lng
87
+
88
+ if session.planner_agent is not None:
89
+ return session
90
+
91
+ # 生成或恢復 Session ID
92
+ if session.session_id is None:
93
+ session.session_id = str(uuid.uuid4())
94
+ # 註冊到內存
95
+ self._active_sessions[session.session_id] = session
96
+ set_session_id(session.session_id)
97
+ logger.info(f"🆔 New Session: {session.session_id}")
98
+ else:
99
+ set_session_id(session.session_id)
100
+ logger.info(f"🔄 Restoring Session: {session.session_id}")
101
+
102
+ # 設定用戶狀態
103
+ session.user_state = UserState(location=Location(lat=lat, lng=lng))
104
+
105
+ # 讀取設定
106
+ selected_model_id = 'gemini-2.5-flash'
107
+ gemini_api_key = session.custom_settings.get("gemini_api_key", "")
108
+ google_maps_api_key = session.custom_settings.get("google_maps_api_key", "")
109
+ openweather_api_key = session.custom_settings.get("openweather_api_key", "")
110
+
111
+ # 初始化模型
112
+ planner_model = Gemini(id=selected_model_id, thinking_budget=2048, api_key=gemini_api_key)
113
+ main_model = Gemini(id=selected_model_id, thinking_budget=1024, api_key=gemini_api_key)
114
+ lite_model = Gemini(id="gemini-2.5-flash-lite", api_key=gemini_api_key)
115
+
116
+ models_dict = {
117
+ "team": main_model, "scout": main_model, "optimizer": lite_model,
118
+ "navigator": lite_model, "weatherman": lite_model, "presenter": main_model,
119
+ }
120
+
121
+ tools_dict = {
122
+ "scout": [ScoutToolkit(google_maps_api_key)],
123
+ "optimizer": [OptimizationToolkit(google_maps_api_key)],
124
+ "navigator": [NavigationToolkit(google_maps_api_key)],
125
+ "weatherman": [WeatherToolkit(openweather_api_key)],
126
+ "presenter": [ReaderToolkit()],
127
+ }
128
+
129
+ planner_kwargs = {
130
+ "additional_context": get_context(session.user_state),
131
+ "timezone_identifier": session.user_state.utc_offset,
132
+ "debug_mode": False,
133
+ }
134
+
135
+ team_kwargs = {"timezone_identifier": session.user_state.utc_offset}
136
+
137
+ # 創建 Agents
138
+ session.planner_agent = create_planner_agent(planner_model, planner_kwargs, session_id=session.session_id)
139
+ session.core_team = create_core_team(models_dict, team_kwargs, tools_dict, session_id=session.session_id)
140
+
141
+ logger.info(f"✅ Agents initialized for session {session.session_id}")
142
+ return session
143
+
144
+ # ================= Step 1: Analyze Tasks =================
145
+
146
+ def run_step1_analysis(self, user_input: str, auto_location: bool,
147
+ lat: float, lon: float, session: UserSession) -> Generator[Dict[str, Any], None, None]:
148
+ if not user_input.strip():
149
+ yield {"type": "empty"}
150
+ return
151
+
152
+ if auto_location:
153
+ lat, lon = 25.033, 121.565
154
+
155
+ try:
156
+ # 🌟 [Fix] 使用 Live Session
157
+ session = self.initialize_agents(session, lat, lon)
158
+
159
+ # 階段 1: 初始化
160
+ self._add_reasoning(session, "planner", "🚀 Starting analysis...")
161
+ yield {
162
+ "type": "stream",
163
+ "stream_text": "🤔 Analyzing your request with AI...",
164
+ "agent_status": ("planner", "working", "Initializing..."),
165
+ "session": session
166
+ }
167
+ time.sleep(0.3)
168
+
169
+ # 階段 2: 提取任務
170
+ self._add_reasoning(session, "planner", f"Processing: {user_input[:50]}...")
171
+ current_text = "🤔 Analyzing your request with AI...\n📋 AI is extracting tasks..."
172
+ yield {
173
+ "type": "stream",
174
+ "stream_text": current_text,
175
+ "agent_status": ("planner", "working", "Extracting tasks..."),
176
+ "session": session
177
+ }
178
+
179
+ # 呼叫 Agent (Stream)
180
+ planner_stream = session.planner_agent.run(
181
+ f"help user to update the task_list, user's message: {user_input}",
182
+ stream=True, stream_events=True
183
+ )
184
+
185
+ accumulated_response = ""
186
+ displayed_text = current_text + "\n\n"
187
+
188
+ for chunk in planner_stream:
189
+ if chunk.event == RunEvent.run_content:
190
+ content = chunk.content
191
+ accumulated_response += content
192
+ if "@@@" not in accumulated_response:
193
+ displayed_text += content
194
+ yield {
195
+ "type": "stream",
196
+ "stream_text": displayed_text,
197
+ "agent_status": ("planner", "working", "Processing..."),
198
+ "session": session
199
+ }
200
+
201
+ # 解析 JSON 結果
202
+ json_data = "{" + accumulated_response.split("{", maxsplit=1)[-1]
203
+ json_data = json_data.replace("`", "").replace("@", "").replace("\\", " ").replace("\n", " ")
204
+
205
+ session.planner_agent.update_session_state(
206
+ session_id=session.session_id,
207
+ session_state_updates={"task_list": json_data}
208
+ )
209
+
210
+ try:
211
+ task_list_data = json.loads(json_data)
212
+ session.task_list = self._convert_task_list_to_ui_format(task_list_data)
213
+ except Exception as e:
214
+ logger.error(f"Failed to parse task_list: {e}")
215
+ session.task_list = []
216
+
217
+ self._add_reasoning(session, "planner", f"✅ Extracted {len(session.task_list)} tasks")
218
+
219
+ high_priority = sum(1 for t in session.task_list if t.get("priority") == "HIGH")
220
+ total_time = sum(int(t.get("duration", "0").split()[0]) for t in session.task_list if t.get("duration"))
221
+
222
+ yield {
223
+ "type": "complete",
224
+ "stream_text": displayed_text + f"\n✅ Analysis complete! Found {len(session.task_list)} tasks.",
225
+ "high_priority": high_priority,
226
+ "total_time": total_time,
227
+ "session": session
228
+ }
229
+
230
+ except Exception as e:
231
+ logger.error(f"Error in step1: {e}", exc_info=True)
232
+ yield {"type": "error", "message": str(e), "session": session}
233
+
234
+ # ================= Task Modification (Chat) =================
235
+
236
+ def modify_task_chat(self, user_message: str, session: UserSession) -> Generator[Dict[str, Any], None, None]:
237
+ if not user_message.strip():
238
+ yield {"type": "no_change", "session": session}
239
+ return
240
+
241
+ # 🌟 [Fix] 獲取 Live Session
242
+ session = self._get_live_session(session)
243
+
244
+ session.chat_history.append({
245
+ "role": "user", "message": user_message, "time": datetime.now().strftime("%H:%M:%S")
246
+ })
247
+ yield {"type": "update_history", "session": session}
248
+
249
+ try:
250
+ if session.planner_agent is None:
251
+ # 嘗試重新初始化 (如果遺失)
252
+ if session.lat and session.lng:
253
+ session = self.initialize_agents(session, session.lat, session.lng)
254
+ else:
255
+ yield {"type": "error", "message": "Session lost. Please restart.", "session": session}
256
+ return
257
+
258
+ session.chat_history.append({
259
+ "role": "assistant", "message": "🤔 AI is thinking...", "time": datetime.now().strftime("%H:%M:%S")
260
+ })
261
+ yield {"type": "update_history", "session": session}
262
+
263
+ planner_stream = session.planner_agent.run(
264
+ f"help user to update the task_list, user's message: {user_message}",
265
+ stream=True, stream_events=True
266
+ )
267
+
268
+ accumulated_response = ""
269
+ for chunk in planner_stream:
270
+ if chunk.event == RunEvent.run_content:
271
+ content = chunk.content
272
+ accumulated_response += content
273
+
274
+ json_data = "{" + accumulated_response.split("{", maxsplit=1)[-1]
275
+ json_data = json_data.replace("`", "").replace("@", "").replace("\\", " ").replace("\n", " ")
276
+
277
+ session.planner_agent.update_session_state(
278
+ session_id=session.session_id,
279
+ session_state_updates={"task_list": json_data}
280
+ )
281
+
282
+ task_list_data = json.loads(json_data)
283
+ session.task_list = self._convert_task_list_to_ui_format(task_list_data)
284
+
285
+ session.chat_history[-1] = {
286
+ "role": "assistant",
287
+ "message": "✅ Tasks updated based on your request",
288
+ "time": datetime.now().strftime("%H:%M:%S")
289
+ }
290
+ self._add_reasoning(session, "planner", f"Updated: {user_message[:30]}...")
291
+
292
+ yield {"type": "complete", "session": session}
293
+
294
+ except Exception as e:
295
+ logger.error(f"Chat error: {e}")
296
+ session.chat_history.append({
297
+ "role": "assistant", "message": f"❌ Error: {str(e)}", "time": datetime.now().strftime("%H:%M:%S")
298
+ })
299
+ yield {"type": "error", "session": session}
300
+
301
+ # ================= Step 2: Search POIs =================
302
+
303
+ def run_step2_search(self, session: UserSession) -> Dict[str, Any]:
304
+ # 🌟 [Fix] 獲取 Live Session
305
+ session = self._get_live_session(session)
306
+
307
+ self._add_reasoning(session, "team", "🚀 Core Team activated")
308
+ self._add_reasoning(session, "scout", "Searching for POIs...")
309
+ return {"session": session}
310
+
311
+ # ================= Step 3: Run Core Team =================
312
+
313
+ def run_step3_team(self, session: UserSession) -> Generator[Dict[str, Any], None, None]:
314
+ try:
315
+ # 🌟 [Fix] 獲取 Live Session (關鍵:這裡才有 Agent)
316
+ session = self._get_live_session(session)
317
+
318
+ if session.planner_agent is None:
319
+ raise ValueError("Agents not initialized. Please run Step 1 first.")
320
+
321
+ set_session_id(session.session_id)
322
+
323
+ # 從 Planner Agent 的 Memory 中獲取 Task List
324
+ task_list_input = session.planner_agent.get_session_state().get("task_list")
325
+
326
+ if isinstance(task_list_input, dict) or isinstance(task_list_input, list):
327
+ task_list_str = json.dumps(task_list_input, indent=2, ensure_ascii=False)
328
+ else:
329
+ task_list_str = str(task_list_input)
330
+
331
+ self._add_reasoning(session, "team", "🎯 Multi-agent collaboration started")
332
+ yield {"type": "start", "session": session}
333
+
334
+ # 執行 Team Run
335
+ team_stream = session.core_team.run(
336
+ f"Plan this trip: {task_list_str}",
337
+ stream=True, stream_events=True, session_id=session.session_id
338
+ )
339
+
340
+ report_content = ""
341
+ for event in team_stream:
342
+ if event.event in [TeamRunEvent.run_content]:
343
+ report_content += event.content
344
+ elif event.event == "tool_call":
345
+ tool_name = event.tool_call.get('function', {}).get('name', 'unknown')
346
+ self._add_reasoning(session, "team", f"🔧 Tool: {tool_name}")
347
+ yield {"type": "reasoning_update", "session": session}
348
+ elif event.event == TeamRunEvent.run_completed:
349
+ self._add_reasoning(session, "team", "🎉 Completed")
350
+
351
+ report_html = f"## 🎯 Planning Complete\n\n{report_content}"
352
+ yield {"type": "complete", "report_html": report_html, "session": session}
353
+
354
+ except Exception as e:
355
+ logger.error(f"Team run error: {e}", exc_info=True)
356
+ yield {"type": "error", "message": str(e), "session": session}
357
+
358
+ # ================= Step 4: Finalize =================
359
+
360
+ def run_step4_finalize(self, session: UserSession) -> Dict[str, Any]:
361
+ try:
362
+ session = self._get_live_session(session)
363
+
364
+ final_ref_id = poi_repo.get_last_id_by_session(session.session_id)
365
+ if not final_ref_id:
366
+ raise ValueError("No final result found")
367
+
368
+ structured_data = poi_repo.load(final_ref_id)
369
+
370
+ # 1. Timeline (右側 Tab)
371
+ timeline_html = create_timeline_html_enhanced(structured_data.get("timeline", []))
372
+
373
+ # 2. Metrics Cards (左側簡單指標) -> 使用新恢復的函數
374
+ metrics = structured_data.get("metrics", {})
375
+ traffic = structured_data.get("traffic_summary", {})
376
+ metrics_html = create_metrics_cards(metrics, traffic)
377
+
378
+ # 3. Detailed Visualization (中間結果區) -> 使用完整版函數
379
+ result_viz = create_result_visualization(session.task_list, structured_data)
380
+
381
+ # 4. Real Map (地圖 Tab) -> 傳入 structured_data
382
+ map_fig = create_animated_map(structured_data)
383
+
384
+ self._add_reasoning(session, "team", "🎉 All completed")
385
+ session.planning_completed = True
386
+
387
+ return {
388
+ "type": "success",
389
+ "timeline_html": timeline_html,
390
+ "metrics_html": metrics_html,
391
+ "result_html": result_viz,
392
+ "map_fig": map_fig,
393
+ "session": session
394
+ }
395
+
396
+ except Exception as e:
397
+ logger.error(f"Finalize error: {e}", exc_info=True)
398
+ return {"type": "error", "message": str(e), "session": session}
399
+
400
+ # ================= Helpers =================
401
+
402
+ def _add_reasoning(self, session: UserSession, agent: str, message: str):
403
+ session.reasoning_messages.append({
404
+ "agent": agent,
405
+ "message": message,
406
+ "time": datetime.now().strftime("%H:%M:%S")
407
+ })
408
+
409
+ def _convert_task_list_to_ui_format(self, task_list_data):
410
+ ui_tasks = []
411
+ if isinstance(task_list_data, dict):
412
+ tasks = task_list_data.get("tasks", [])
413
+ elif isinstance(task_list_data, list):
414
+ tasks = task_list_data
415
+ else:
416
+ return []
417
+
418
+ for i, task in enumerate(tasks, 1):
419
+ ui_task = {
420
+ "id": i,
421
+ "title": task.get("description", "Task"),
422
+ "priority": task.get("priority", "MEDIUM"),
423
+ "time": task.get("time_window", "Anytime"),
424
+ "duration": f"{task.get('duration_minutes', 30)} minutes",
425
+ "location": task.get("location_hint", "To be determined"),
426
+ "icon": self._get_task_icon(task.get("category", "other"))
427
+ }
428
+ ui_tasks.append(ui_task)
429
+ return ui_tasks
430
+
431
+ def _get_task_icon(self, category: str) -> str:
432
+ icons = {
433
+ "medical": "🏥", "shopping": "🛒", "postal": "📮",
434
+ "food": "🍽️", "entertainment": "🎭", "transportation": "🚗",
435
+ "other": "📋"
436
+ }
437
+ return icons.get(category.lower(), "📋")
438
+
439
+ def generate_task_list_html(self, session: UserSession) -> str:
440
+ if not session.task_list:
441
+ return "<p>No tasks available</p>"
442
+ html = ""
443
+ for task in session.task_list:
444
+ html += create_task_card(
445
+ task["id"], task["title"], task["priority"],
446
+ task["time"], task["duration"], task["location"],
447
+ task.get("icon", "📋")
448
+ )
449
+ return html
ui/components/header.py CHANGED
@@ -1,5 +1,6 @@
 
1
  """
2
- LifeFlow AI - Header Component
3
  """
4
  import gradio as gr
5
 
@@ -12,12 +13,11 @@ def create_header():
12
  """)
13
 
14
  def create_top_controls():
15
- """
16
- 創建右上角懸浮按鈕
17
- """
18
- with gr.Group(elem_id="top-controls-container"):
19
- theme_btn = gr.Button("🌓", elem_classes="control-btn", size="sm")
20
- settings_btn = gr.Button("⚙️", elem_classes="control-btn", size="sm")
21
- doc_btn = gr.Button("📖", elem_classes="control-btn", size="sm")
22
 
23
  return theme_btn, settings_btn, doc_btn
 
1
+
2
  """
3
+ LifeFlow AI - Header Component (Clean Style)
4
  """
5
  import gradio as gr
6
 
 
13
  """)
14
 
15
  def create_top_controls():
16
+ with gr.Group(elem_id="top-controls"):
17
+ # 按鈕本身樣式由 CSS 控制,這裡保持結構簡單
18
+ with gr.Row():
19
+ theme_btn = gr.Button("🌓", size="sm", min_width=40)
20
+ settings_btn = gr.Button("⚙️", size="sm", min_width=40)
21
+ doc_btn = gr.Button("📖", size="sm", min_width=40)
 
22
 
23
  return theme_btn, settings_btn, doc_btn
ui/components/input_form.py CHANGED
@@ -1,38 +1,51 @@
1
  """
2
- LifeFlow AI - Input Form Component
3
  """
4
  import gradio as gr
5
 
6
  def create_input_form(agent_stream_html):
7
- """創建輸入表單"""
8
- with gr.Group(visible=True) as input_area:
9
- gr.Markdown("### 📝 Tell us your plans")
10
- agent_stream_output = gr.HTML(value=agent_stream_html)
 
 
 
 
 
11
  user_input = gr.Textbox(
12
- label="What do you need to do?",
13
- placeholder="e.g., Visit hospital tomorrow morning, buy groceries, mail package before 3pm",
14
- lines=3
 
15
  )
16
- gr.Markdown("---")
17
 
18
- # Auto-location toggle
19
- auto_location = gr.Checkbox(label="📍 Auto-detect my location (Simulated)", value=False)
 
 
 
 
 
 
 
 
 
 
20
 
21
- # Manual Location Inputs (Group)
22
- with gr.Group(visible=True) as location_inputs:
23
- with gr.Row():
24
- lat_input = gr.Number(label="Latitude", value=25.033, precision=6)
25
- lon_input = gr.Number(label="Longitude", value=121.565, precision=6)
 
 
26
 
27
- analyze_btn = gr.Button("🚀 Analyze & Plan", variant="primary", size="lg")
 
28
 
29
  return (input_area, agent_stream_output, user_input, auto_location,
30
  location_inputs, lat_input, lon_input, analyze_btn)
31
 
32
  def toggle_location_inputs(auto_loc):
33
- """
34
- Visibility Toggle Logic:
35
- If Auto-detect is ON (True) -> Hide Manual Inputs (False)
36
- If Auto-detect is OFF (False) -> Show Manual Inputs (True)
37
- """
38
  return gr.update(visible=not auto_loc)
 
1
  """
2
+ LifeFlow AI - Input Form Component (UX Improved)
3
  """
4
  import gradio as gr
5
 
6
  def create_input_form(agent_stream_html):
7
+ """創建優化後的輸入表單"""
8
+
9
+ with gr.Group(elem_classes="glass-card") as input_area:
10
+ gr.Markdown("### 📝 What's your plan today?")
11
+
12
+ # 1. Agent 狀態區 (改為較小的視覺)
13
+ agent_stream_output = gr.HTML(value=agent_stream_html, label="Agent Status")
14
+
15
+ # 2. 主要輸入區
16
  user_input = gr.Textbox(
17
+ label="Describe your tasks",
18
+ placeholder="e.g., I need to visit the dentist at 10am, then buy groceries from Costco, and pick up a package...",
19
+ lines=3,
20
+ elem_id="main-input"
21
  )
 
22
 
23
+ # 3. 快速範例 (Quick Prompts) - UX 大加分
24
+ gr.Examples(
25
+ examples=[
26
+ ["Plan a trip to visit Taipei 101, then have lunch at Din Tai Fung, and go to the National Palace Museum."],
27
+ ["I need to go to the hospital for a checkup at 9 AM, then buy some flowers, and meet a friend for coffee at 2 PM."],
28
+ ["Errands run: Post office, bank, and supermarket. Start at 10 AM, finish by 1 PM."]
29
+ ],
30
+ inputs=user_input,
31
+ label="Quick Start Examples"
32
+ )
33
+
34
+ gr.Markdown("---")
35
 
36
+ # 4. 位置設定 (使用 Accordion 收納次要資訊)
37
+ with gr.Accordion("📍 Location Settings", open=False):
38
+ auto_location = gr.Checkbox(label="Auto-detect my location (Simulated)", value=False)
39
+ with gr.Group(visible=True) as location_inputs:
40
+ with gr.Row():
41
+ lat_input = gr.Number(label="Latitude", value=25.033, precision=6, scale=1)
42
+ lon_input = gr.Number(label="Longitude", value=121.565, precision=6, scale=1)
43
 
44
+ # 5. 主按鈕
45
+ analyze_btn = gr.Button("🚀 Analyze & Plan Trip", variant="primary", size="lg")
46
 
47
  return (input_area, agent_stream_output, user_input, auto_location,
48
  location_inputs, lat_input, lon_input, analyze_btn)
49
 
50
  def toggle_location_inputs(auto_loc):
 
 
 
 
 
51
  return gr.update(visible=not auto_loc)
ui/components/results.py CHANGED
@@ -5,14 +5,33 @@ LifeFlow AI - Results Component
5
  """
6
  import gradio as gr
7
 
 
 
 
8
  def create_team_area(create_agent_card_func):
9
- """創建 AI Team 展示區域"""
10
  with gr.Group(visible=False) as team_area:
11
- gr.Markdown("### 🤖 AI Expert Team")
12
- agent_displays = []
13
- for agent_key in ['planner', 'scout', 'optimizer', 'validator', 'weather', 'traffic']:
14
- agent_card = gr.HTML(value=create_agent_card_func(agent_key, "idle", "On standby"))
15
- agent_displays.append(agent_card)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  return team_area, agent_displays
18
 
 
5
  """
6
  import gradio as gr
7
 
8
+ import gradio as gr
9
+
10
+
11
  def create_team_area(create_agent_card_func):
12
+ """創建 AI Team 展示區域 (改為 Grid)"""
13
  with gr.Group(visible=False) as team_area:
14
+ gr.Markdown("### 🤖 Agent Status")
15
+ # 使用自定義 CSS grid wrapper
16
+ with gr.Column():
17
+ # 這裡我們需要一個容器來放入所有的 HTML 卡片,或者將它們合併為一個 HTML 輸出
18
+ # 為了簡單起見,我們將 agent_displays 保持為 list,但在前端用 flex/grid 呈現
19
+ # 注意:為了達到最佳 grid 效果,建議在 app.py 中將 agent updates 合併為一個 HTML string
20
+ # 但為了不改動邏輯,我們這裡運用 CSS (.agent-grid) 包裹
21
+
22
+ agent_container_start = gr.HTML('<div class="agent-grid">')
23
+ agent_displays = []
24
+ # 這裡對應 app.py 的邏輯,會生成多個 HTML 組件
25
+ for agent_key in ['planner', 'scout', 'optimizer', 'validator', 'weather', 'traffic']:
26
+ # 初始狀態
27
+ card_html = create_agent_card_func(agent_key, "idle", "Waiting")
28
+ agent_displays.append(gr.HTML(value=card_html, show_label=False))
29
+
30
+ agent_container_end = gr.HTML('</div>')
31
+
32
+
33
+ for display in agent_displays:
34
+ display.elem_classes = ["agent-grid-item"]
35
 
36
  return team_area, agent_displays
37
 
core/utils.py → ui/renderers.py RENAMED
@@ -1,11 +1,9 @@
1
  """
2
- LifeFlow AI - Core Utilities
 
3
  """
4
-
5
- import plotly.graph_objects as go
6
- from config import AGENTS_INFO
7
  from datetime import datetime
8
-
9
 
10
  def create_agent_stream_output() -> str:
11
  """創建 Agent 串流輸出 HTML"""
@@ -15,74 +13,116 @@ def create_agent_stream_output() -> str:
15
  </div>
16
  """
17
 
18
-
19
  def create_agent_card_enhanced(agent_key: str, status: str = "idle", message: str = "") -> str:
20
- """
21
- 創建增強的 Agent 卡片
22
-
23
- Args:
24
- agent_key: Agent 標識符
25
- status: 狀態 (idle/working/complete)
26
- message: 顯示訊息
27
- """
28
  agent = AGENTS_INFO.get(agent_key, {})
29
- avatar = agent.get("avatar", "🤖")
30
- name = agent.get("name", "Agent")
31
- role = agent.get("role", "")
32
- color = agent.get("color", "#4A90E2")
33
- glow = agent.get("glow", "rgba(74, 144, 226, 0.3)")
34
-
35
- status_class = f"status-{status}"
36
- card_class = "agent-card active" if status == "working" else "agent-card"
37
 
38
- status_text = {
39
- "idle": "On Standby",
40
- "working": "Working...",
41
- "complete": "Complete ✓"
42
- }.get(status, "Unknown")
43
 
44
- message_html = f'<div class="agent-message">{message}</div>' if message else ""
45
 
46
  return f"""
47
- <div class="{card_class}" style="--agent-color: {color}; --agent-glow: {glow};">
48
- <div class="agent-header">
49
- <div class="agent-avatar">{avatar}</div>
50
- <div class="agent-info">
51
- <h3>{name}</h3>
52
- <div class="agent-role">{role}</div>
53
- </div>
54
- </div>
55
- <span class="agent-status {status_class}">{status_text}</span>
56
- {message_html}
57
  </div>
58
  """
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
  def create_task_card(task_num: int, task_title: str, priority: str,
62
  time_window: dict, duration: str, location: str, icon: str = "📋") -> str:
63
- """創建任務卡片"""
64
  priority_color_map = {
65
  "HIGH": "#FF6B6B",
66
  "MEDIUM": "#F5A623",
67
  "LOW": "#7ED321"
68
  }
69
- if time_window is None:
70
- time_window = "Anytime"
71
 
72
- elif isinstance(time_window, dict):
 
 
73
  start = time_window.get('earliest_time', None)
74
  end = time_window.get('latest_time', None)
75
- start = datetime.fromisoformat(start).strftime("%H:%M") if start else "Before"
76
- end = datetime.fromisoformat(end).strftime("%H:%M") if end else "After"
77
-
78
- if start == "Before" and end == "After":
79
- time_window = "Anytime"
80
- elif start == "After":
81
- time_window = f"After {end}"
82
- elif end == "Before":
83
- time_window = f"Before {start}"
84
  else:
85
- time_window = f"{start} - {end}"
 
 
86
 
87
  priority_class = f"priority-{priority.lower()}"
88
  task_color = priority_color_map.get(priority, "#999")
@@ -99,7 +139,7 @@ def create_task_card(task_num: int, task_title: str, priority: str,
99
  <div class="task-details">
100
  <div class="task-detail-item">
101
  <span>🕐</span>
102
- <span>{time_window}</span>
103
  </div>
104
  <div class="task-detail-item">
105
  <span>⏱️</span>
@@ -113,9 +153,7 @@ def create_task_card(task_num: int, task_title: str, priority: str,
113
  </div>
114
  """
115
 
116
-
117
  def create_summary_card(total_tasks: int, high_priority: int, total_time: int) -> str:
118
- """創建摘要卡片""" #style="color: #FF6B6B;
119
  return f"""
120
  <div class="summary-card">
121
  <h3 style="margin: 0 0 15px 0; font-size: 20px;">📋 Task Summary</h3>
@@ -136,45 +174,13 @@ def create_summary_card(total_tasks: int, high_priority: int, total_time: int) -
136
  </div>
137
  """
138
 
139
-
140
- def create_animated_map():
141
- """創建動畫地圖(模擬)"""
142
- fig = go.Figure()
143
-
144
- # 模擬路線點
145
- lats = [25.033, 25.045, 25.055, 25.040]
146
- lons = [121.565, 121.575, 121.585, 121.570]
147
-
148
- fig.add_trace(go.Scattermapbox(
149
- lat=lats,
150
- lon=lons,
151
- mode='markers+lines',
152
- marker=dict(size=12, color='#4A90E2'),
153
- line=dict(width=3, color='#50C878'),
154
- text=['Start', 'Task 1', 'Task 2', 'End'],
155
- hoverinfo='text'
156
- ))
157
-
158
- fig.update_layout(
159
- mapbox=dict(
160
- style='open-street-map',
161
- center=dict(lat=25.043, lon=121.575),
162
- zoom=12
163
- ),
164
- margin=dict(l=0, r=0, t=0, b=0),
165
- height=500
166
- )
167
-
168
- return fig
169
-
170
-
171
  def get_reasoning_html_reversed(reasoning_messages: list = None) -> str:
172
  """獲取推理過程 HTML(反向排序,最新在上)"""
173
  if not reasoning_messages:
174
  return '<div class="reasoning-timeline"><p style="text-align: center; opacity: 0.6;">No reasoning messages yet...</p></div>'
175
 
176
  items_html = ""
177
- for msg in reversed(reasoning_messages[-20:]): # 只顯示最近20條,反向
178
  agent_info = AGENTS_INFO.get(msg.get('agent', 'planner'), {})
179
  color = agent_info.get('color', '#4A90E2')
180
  avatar = agent_info.get('avatar', '🤖')
@@ -192,12 +198,9 @@ def get_reasoning_html_reversed(reasoning_messages: list = None) -> str:
192
 
193
  return f'<div class="reasoning-timeline">{items_html}</div>'
194
 
195
-
196
  def create_celebration_animation() -> str:
197
- """創建慶祝動畫(純 CSS 撒花效果)- Gradio 兼容版本"""
198
  import random
199
-
200
- # 生成 100 個隨機位置和動畫的撒花元素
201
  confetti_html = ""
202
  colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2']
203
 
@@ -225,107 +228,125 @@ def create_celebration_animation() -> str:
225
  <div class="celebration-overlay">
226
  {confetti_html}
227
  </div>
228
-
229
  <style>
230
  @keyframes confetti-fall {{
231
- 0% {{
232
- transform: translateY(-100vh) rotate(0deg);
233
- opacity: 1;
234
- }}
235
- 100% {{
236
- transform: translateY(100vh) rotate(720deg);
237
- opacity: 0;
238
- }}
239
  }}
240
-
241
  @keyframes celebration-fade {{
242
  0% {{ opacity: 1; }}
243
  80% {{ opacity: 1; }}
244
  100% {{ opacity: 0; }}
245
  }}
246
-
247
  .celebration-overlay {{
248
- position: fixed;
249
- top: 0;
250
- left: 0;
251
- width: 100%;
252
- height: 100%;
253
- pointer-events: none;
254
- z-index: 9999;
255
- overflow: hidden;
256
  animation: celebration-fade 6s forwards;
257
  }}
258
-
259
  .confetti {{
260
- position: absolute;
261
- top: -20px;
262
- animation: confetti-fall linear infinite;
263
- opacity: 0.9;
264
  }}
265
  </style>
266
  """
267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
 
269
- def decode_polyline(polyline_str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  """
271
- 解碼 Google Maps Polyline 字符串為 (lat, lng) 列表
272
- 純 Python 實現,無需額外依賴
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  """
274
- index, lat, lng = 0, 0, 0
275
- coordinates = []
276
- changes = {'latitude': 0, 'longitude': 0}
277
-
278
- while index < len(polyline_str):
279
- for unit in ['latitude', 'longitude']:
280
- shift, result = 0, 0
281
- while True:
282
- byte = ord(polyline_str[index]) - 63
283
- index += 1
284
- result |= (byte & 0x1f) << shift
285
- shift += 5
286
- if not byte >= 0x20:
287
- break
288
- if (result & 1):
289
- changes[unit] = ~(result >> 1)
290
- else:
291
- changes[unit] = (result >> 1)
292
-
293
- lat += changes['latitude']
294
- lng += changes['longitude']
295
- coordinates.append((lat / 100000.0, lng / 100000.0))
296
-
297
- return coordinates
298
 
299
 
 
300
  def create_result_visualization(task_list: list, structured_data: dict) -> str:
301
- """
302
- 創建詳細的結果視覺化卡片 (綁定真實數據)
303
- """
304
- # 提取數據
305
  metrics = structured_data.get("metrics", {})
306
  traffic = structured_data.get("traffic_summary", {})
307
  timeline = structured_data.get("timeline", [])
308
 
309
- # 1. 計算摘要數據
310
  completed_tasks = metrics.get("completed_tasks", 0)
311
  total_tasks = metrics.get("total_tasks", 0)
312
-
313
  total_dist_km = traffic.get("total_distance_km", 0)
314
  total_dur_min = traffic.get("total_duration_min", 0)
315
 
316
- # 格式化時間 (例如 85 min -> 1h 25min)
317
  hours = int(total_dur_min // 60)
318
  mins = int(total_dur_min % 60)
319
  time_str = f"{hours}h {mins}m" if hours > 0 else f"{mins}min"
320
 
321
- # 2. 優化指標
322
  efficiency = metrics.get("route_efficiency_pct", 90)
323
  time_saved_pct = metrics.get("time_improvement_pct", 0)
324
  dist_saved_pct = metrics.get("distance_improvement_pct", 0)
325
-
326
  time_saved_val = metrics.get("time_saved_min", 0)
327
  dist_saved_val_km = metrics.get("distance_saved_m", 0) / 1000
328
 
 
329
  html = f"""
330
  <div class="result-visualization">
331
  <div class="result-header">
@@ -363,7 +384,7 @@ def create_result_visualization(task_list: list, structured_data: dict) -> str:
363
  <div class="summary-metric success">
364
  <div class="metric-icon">⚡</div>
365
  <div class="metric-content">
366
- <div class="metric-value">{efficiency}%</div>
367
  <div class="metric-label">Efficiency Score</div>
368
  </div>
369
  </div>
@@ -376,14 +397,11 @@ def create_result_visualization(task_list: list, structured_data: dict) -> str:
376
  """
377
 
378
  # 綁定時間線數據
379
- # 跳過 start point (index 0) 如果不需要顯示,或者全部顯示
380
  for i, stop in enumerate(timeline):
381
  time = stop.get("time", "")
382
  loc_name = stop.get("location", "")
383
  weather = stop.get("weather", "")
384
- travel_time = stop.get("travel_time_from_prev", "")
385
 
386
- # 簡單的圖標邏輯
387
  icon = "🏁" if i == 0 else ("🔚" if i == len(timeline) - 1 else "📍")
388
 
389
  html += f"""
@@ -400,7 +418,6 @@ def create_result_visualization(task_list: list, structured_data: dict) -> str:
400
  </div>
401
  """
402
 
403
- # 添加交通時間 (如果不是最後一個)
404
  if i < len(timeline) - 1:
405
  next_travel = timeline[i + 1].get("travel_time_from_prev", "0 mins")
406
  html += f"""
@@ -419,7 +436,6 @@ def create_result_visualization(task_list: list, structured_data: dict) -> str:
419
  <div class="metrics-grid">
420
  """
421
 
422
- # 綁定指標數據
423
  viz_metrics = [
424
  ("Route Efficiency", efficiency, "#50C878"),
425
  ("Time Saved", time_saved_pct, "#4A90E2"),
@@ -427,7 +443,7 @@ def create_result_visualization(task_list: list, structured_data: dict) -> str:
427
  ]
428
 
429
  for label, value, color in viz_metrics:
430
- # 限制 value 0-100 之間顯示
431
  display_val = min(max(value, 0), 100)
432
  html += f"""
433
  <div class="metric-bar">
@@ -455,11 +471,5 @@ def create_result_visualization(task_list: list, structured_data: dict) -> str:
455
  </div>
456
  </div>
457
  </div>
458
-
459
- <style>
460
- /* ... 保留原本的 CSS ... */
461
- .timeline-info {{display: flex; flex-direction: column; }}
462
- .timeline-meta {{font - size: 11px; color: #666; margin-top: 2px; }}
463
- </style>
464
  """
465
  return html
 
1
  """
2
+ LifeFlow AI - UI Renderers
3
+ 負責生成 HTML 字串與前端展示元件
4
  """
 
 
 
5
  from datetime import datetime
6
+ from config import AGENTS_INFO
7
 
8
  def create_agent_stream_output() -> str:
9
  """創建 Agent 串流輸出 HTML"""
 
13
  </div>
14
  """
15
 
 
16
  def create_agent_card_enhanced(agent_key: str, status: str = "idle", message: str = "") -> str:
 
 
 
 
 
 
 
 
17
  agent = AGENTS_INFO.get(agent_key, {})
18
+ color = agent.get("color", "#6366f1")
 
 
 
 
 
 
 
19
 
20
+ status_color = "#94a3b8" # gray
21
+ if status == "working": status_color = "#f59e0b" # orange
22
+ if status == "complete": status_color = "#10b981" # green
 
 
23
 
24
+ active_class = "active" if status == "working" else ""
25
 
26
  return f"""
27
+ <div class="agent-card-mini {active_class}" style="border-top: 3px solid {color}">
28
+ <div class="agent-avatar-mini">{agent.get("avatar", "🤖")}</div>
29
+ <div class="agent-name-mini">{agent.get("name", "Agent")}</div>
30
+ <span class="agent-status-dot" style="background-color: {status_color}"></span>
31
+ <div style="font-size: 10px; color: #64748b; margin-top: 4px;">{status}</div>
 
 
 
 
 
32
  </div>
33
  """
34
 
35
+ def generate_chat_history_html_bubble(session) -> str:
36
+ """生成對話氣泡 HTML (使用 session 物件)"""
37
+ # 注意:這裡依賴 session 物件的 chat_history 屬性
38
+ if not session.chat_history:
39
+ return """
40
+ <div style="text-align: center; padding: 40px; color: #94a3b8;">
41
+ <div style="font-size: 40px; margin-bottom: 10px;">👋</div>
42
+ <p>Hi! I'm LifeFlow. Tell me your plans, or ask me to modify the tasks above.</p>
43
+ </div>
44
+ """
45
+
46
+ html = '<div class="chat-history-container">'
47
+ for msg in session.chat_history:
48
+ role = msg["role"] # user or assistant
49
+ role_class = "user" if role == "user" else "assistant"
50
+
51
+ html += f"""
52
+ <div class="chat-message {role_class}">
53
+ <div class="chat-bubble">
54
+ {msg["message"]}
55
+ <div class="chat-time">{msg["time"]}</div>
56
+ </div>
57
+ </div>
58
+ """
59
+ html += '</div>'
60
+ return html
61
+
62
+ def create_timeline_html_enhanced(timeline):
63
+ if not timeline: return "<div>No timeline data</div>"
64
+
65
+ html = '<div class="timeline-container">'
66
+ for i, stop in enumerate(timeline):
67
+ time = stop.get("time", "--:--")
68
+ location = stop.get("location", "Unknown")
69
+ weather = stop.get("weather", "")
70
+
71
+ icon = "📍"
72
+ if i == 0:
73
+ icon = "🏠"
74
+ elif i == len(timeline) - 1:
75
+ icon = "🏁"
76
+ elif "food" in location.lower():
77
+ icon = "🍽️"
78
+ elif "hospital" in location.lower():
79
+ icon = "🏥"
80
+
81
+ html += f"""
82
+ <div class="timeline-item">
83
+ <div class="timeline-left">
84
+ <div class="timeline-icon-box">{icon}</div>
85
+ </div>
86
+ <div class="timeline-content-card">
87
+ <div style="display:flex; justify-content:space-between; margin-bottom:5px;">
88
+ <span style="font-weight:bold; color:#334155; font-size:1.1rem;">{location}</span>
89
+ <span style="background:#eff6ff; color:#3b82f6; padding:2px 8px; border-radius:12px; font-size:0.8rem; font-weight:600;">{time}</span>
90
+ </div>
91
+ <div style="font-size:0.9rem; color:#64748b;">
92
+ {f'🌤️ {weather}' if weather else ''}
93
+ </div>
94
+ </div>
95
+ </div>
96
+ """
97
+ html += '</div>'
98
+ return html
99
 
100
  def create_task_card(task_num: int, task_title: str, priority: str,
101
  time_window: dict, duration: str, location: str, icon: str = "📋") -> str:
 
102
  priority_color_map = {
103
  "HIGH": "#FF6B6B",
104
  "MEDIUM": "#F5A623",
105
  "LOW": "#7ED321"
106
  }
 
 
107
 
108
+ # 處理 time_window 邏輯
109
+ display_time = "Anytime"
110
+ if isinstance(time_window, dict):
111
  start = time_window.get('earliest_time', None)
112
  end = time_window.get('latest_time', None)
113
+ start_str = datetime.fromisoformat(start).strftime("%H:%M") if start else "Before"
114
+ end_str = datetime.fromisoformat(end).strftime("%H:%M") if end else "After"
115
+
116
+ if start_str == "Before" and end_str == "After":
117
+ display_time = "Anytime"
118
+ elif start_str == "After":
119
+ display_time = f"After {end_str}"
120
+ elif end_str == "Before":
121
+ display_time = f"Before {start_str}"
122
  else:
123
+ display_time = f"{start_str} - {end_str}"
124
+ elif time_window:
125
+ display_time = str(time_window)
126
 
127
  priority_class = f"priority-{priority.lower()}"
128
  task_color = priority_color_map.get(priority, "#999")
 
139
  <div class="task-details">
140
  <div class="task-detail-item">
141
  <span>🕐</span>
142
+ <span>{display_time}</span>
143
  </div>
144
  <div class="task-detail-item">
145
  <span>⏱️</span>
 
153
  </div>
154
  """
155
 
 
156
  def create_summary_card(total_tasks: int, high_priority: int, total_time: int) -> str:
 
157
  return f"""
158
  <div class="summary-card">
159
  <h3 style="margin: 0 0 15px 0; font-size: 20px;">📋 Task Summary</h3>
 
174
  </div>
175
  """
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  def get_reasoning_html_reversed(reasoning_messages: list = None) -> str:
178
  """獲取推理過程 HTML(反向排序,最新在上)"""
179
  if not reasoning_messages:
180
  return '<div class="reasoning-timeline"><p style="text-align: center; opacity: 0.6;">No reasoning messages yet...</p></div>'
181
 
182
  items_html = ""
183
+ for msg in reversed(reasoning_messages[-20:]):
184
  agent_info = AGENTS_INFO.get(msg.get('agent', 'planner'), {})
185
  color = agent_info.get('color', '#4A90E2')
186
  avatar = agent_info.get('avatar', '🤖')
 
198
 
199
  return f'<div class="reasoning-timeline">{items_html}</div>'
200
 
 
201
  def create_celebration_animation() -> str:
202
+ """創建慶祝動畫(純 CSS 撒花效果)"""
203
  import random
 
 
204
  confetti_html = ""
205
  colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2']
206
 
 
228
  <div class="celebration-overlay">
229
  {confetti_html}
230
  </div>
 
231
  <style>
232
  @keyframes confetti-fall {{
233
+ 0% {{ transform: translateY(-100vh) rotate(0deg); opacity: 1; }}
234
+ 100% {{ transform: translateY(100vh) rotate(720deg); opacity: 0; }}
 
 
 
 
 
 
235
  }}
 
236
  @keyframes celebration-fade {{
237
  0% {{ opacity: 1; }}
238
  80% {{ opacity: 1; }}
239
  100% {{ opacity: 0; }}
240
  }}
 
241
  .celebration-overlay {{
242
+ position: fixed; top: 0; left: 0; width: 100%; height: 100%;
243
+ pointer-events: none; z-index: 9999; overflow: hidden;
 
 
 
 
 
 
244
  animation: celebration-fade 6s forwards;
245
  }}
 
246
  .confetti {{
247
+ position: absolute; top: -20px;
248
+ animation: confetti-fall linear infinite; opacity: 0.9;
 
 
249
  }}
250
  </style>
251
  """
252
 
253
+ def create_result_visualization(task_list: list, structured_data: dict) -> str:
254
+ """創建詳細的結果視覺化卡片"""
255
+ metrics = structured_data.get("metrics", {})
256
+ traffic = structured_data.get("traffic_summary", {})
257
+ timeline = structured_data.get("timeline", [])
258
+
259
+ completed_tasks = metrics.get("completed_tasks", 0)
260
+ total_tasks = metrics.get("total_tasks", 0)
261
+ total_dist_km = traffic.get("total_distance_km", 0)
262
+ total_dur_min = traffic.get("total_duration_min", 0)
263
+
264
+ hours = int(total_dur_min // 60)
265
+ mins = int(total_dur_min % 60)
266
+ time_str = f"{hours}h {mins}m" if hours > 0 else f"{mins}min"
267
 
268
+ efficiency = metrics.get("route_efficiency_pct", 90)
269
+ time_saved_val = metrics.get("time_saved_min", 0)
270
+ dist_saved_val_km = metrics.get("distance_saved_m", 0) / 1000
271
+
272
+ # ... (為簡潔起見,這裡保留原有的 HTML 拼接邏輯,只是函數位置改變)
273
+ # 這裡的代碼與原 utils.py 中的邏輯完全一致,只是為了空間我縮略了中間的 HTML string
274
+ # 在實際實施時,請複製原 utils.py 中完整的 HTML 模板代碼
275
+
276
+ html = f"""
277
+ <div class="result-visualization">
278
+ <div class="result-header">
279
+ <h2 style="color: #50C878; margin: 0; font-size: 28px; display: flex; align-items: center; gap: 10px;">
280
+ <span class="celebration-icon" style="font-size: 36px; animation: bounce 1s infinite;">🎉</span>
281
+ <span>Planning Complete!</span>
282
+ </h2>
283
+ <p style="margin: 10px 0 0 0; opacity: 0.8; font-size: 14px;">Optimized based on real-time traffic & weather</p>
284
+ </div>
285
+ <div class="quick-summary" style="margin-top: 25px;">
286
+ <div class="metric-value">{completed_tasks} / {total_tasks}</div>
287
+ </div>
288
+ </div>
289
  """
290
+ # 注意:請確保將完整的 HTML 邏輯搬移過來
291
+
292
+ # 為了確保功能正常,這裡是一個簡化的佔位符,請使用原來的完整代碼替換這裡
293
+ # 實作時,請將原 utils.py 中 create_result_visualization 的完整內容複製到這裡
294
+ return html # 替換為完整 HTML
295
+
296
+
297
+ def create_metrics_cards(metrics, traffic_summary):
298
+ """生成左側的簡單 Metrics (三張卡片)"""
299
+ if traffic_summary is None: traffic_summary = {}
300
+ if metrics is None: metrics = {}
301
+
302
+ total_distance = traffic_summary.get("total_distance_km", 0)
303
+ total_duration = traffic_summary.get("total_duration_min", 0)
304
+ efficiency = metrics.get("route_efficiency_pct", 90)
305
+
306
+ # 根據效率改變顏色
307
+ eff_color = "#50C878" if efficiency >= 80 else "#F5A623"
308
+
309
+ return f"""
310
+ <div class="metrics-container">
311
+ <div class="metric-card">
312
+ <h3>📏 Distance</h3>
313
+ <p class="metric-value">{total_distance:.1f} km</p>
314
+ </div>
315
+ <div class="metric-card">
316
+ <h3>⏱️ Duration</h3>
317
+ <p class="metric-value">{total_duration:.0f} min</p>
318
+ </div>
319
+ <div class="metric-card" style="border-bottom: 3px solid {eff_color}">
320
+ <h3>⚡ Efficiency</h3>
321
+ <p class="metric-value" style="color: {eff_color}">{efficiency:.0f}%</p>
322
+ </div>
323
+ </div>
324
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
 
326
 
327
+ # 🔥 [Full Restoration] 恢復完整的結果視覺化
328
  def create_result_visualization(task_list: list, structured_data: dict) -> str:
329
+ """創建詳細的結果視覺化卡片"""
 
 
 
330
  metrics = structured_data.get("metrics", {})
331
  traffic = structured_data.get("traffic_summary", {})
332
  timeline = structured_data.get("timeline", [])
333
 
 
334
  completed_tasks = metrics.get("completed_tasks", 0)
335
  total_tasks = metrics.get("total_tasks", 0)
 
336
  total_dist_km = traffic.get("total_distance_km", 0)
337
  total_dur_min = traffic.get("total_duration_min", 0)
338
 
 
339
  hours = int(total_dur_min // 60)
340
  mins = int(total_dur_min % 60)
341
  time_str = f"{hours}h {mins}m" if hours > 0 else f"{mins}min"
342
 
 
343
  efficiency = metrics.get("route_efficiency_pct", 90)
344
  time_saved_pct = metrics.get("time_improvement_pct", 0)
345
  dist_saved_pct = metrics.get("distance_improvement_pct", 0)
 
346
  time_saved_val = metrics.get("time_saved_min", 0)
347
  dist_saved_val_km = metrics.get("distance_saved_m", 0) / 1000
348
 
349
+ # 開始構建 HTML
350
  html = f"""
351
  <div class="result-visualization">
352
  <div class="result-header">
 
384
  <div class="summary-metric success">
385
  <div class="metric-icon">⚡</div>
386
  <div class="metric-content">
387
+ <div class="metric-value">{efficiency:.0f}%</div>
388
  <div class="metric-label">Efficiency Score</div>
389
  </div>
390
  </div>
 
397
  """
398
 
399
  # 綁定時間線數據
 
400
  for i, stop in enumerate(timeline):
401
  time = stop.get("time", "")
402
  loc_name = stop.get("location", "")
403
  weather = stop.get("weather", "")
 
404
 
 
405
  icon = "🏁" if i == 0 else ("🔚" if i == len(timeline) - 1 else "📍")
406
 
407
  html += f"""
 
418
  </div>
419
  """
420
 
 
421
  if i < len(timeline) - 1:
422
  next_travel = timeline[i + 1].get("travel_time_from_prev", "0 mins")
423
  html += f"""
 
436
  <div class="metrics-grid">
437
  """
438
 
 
439
  viz_metrics = [
440
  ("Route Efficiency", efficiency, "#50C878"),
441
  ("Time Saved", time_saved_pct, "#4A90E2"),
 
443
  ]
444
 
445
  for label, value, color in viz_metrics:
446
+ if value is None: value = 0
447
  display_val = min(max(value, 0), 100)
448
  html += f"""
449
  <div class="metric-bar">
 
471
  </div>
472
  </div>
473
  </div>
 
 
 
 
 
 
474
  """
475
  return html
ui/theme.py CHANGED
@@ -1,593 +1,245 @@
1
  """
2
  LifeFlow AI - Theme System
3
- 提供完整的主題和樣式管理,支援動態主題切換
4
  """
5
 
6
-
7
  def get_enhanced_css() -> str:
8
- """
9
- 改進的 CSS 系統:
10
- 1. ✅ 修復色調功能 - 確保主題顏色正確應用
11
- 2. ✅ 完整支援深淺模式切換
12
- 3. ✅ 修復 Exit 按鈕樣式(Step 2)
13
- 4. ✅ 調整 Setting 和 Doc 按鈕位置和大小
14
- """
15
  return """
 
16
  <style>
17
  :root {
18
- /* 品牌色 - 固定不變,保持品牌識別度 */
19
- --primary-color: #4A90E2;
20
- --secondary-color: #50C878;
21
- --accent-color: #F5A623;
22
- --primary-glow: rgba(74, 144, 226, 0.3);
23
-
24
- /* 功能色 */
25
- --success-color: #7ED321;
26
- --warning-color: #F5A623;
27
- --danger-color: #FF6B6B;
28
- }
29
-
30
- /* ============= 動畫效果 ============= */
31
- @keyframes pulse {
32
- 0%, 100% { transform: scale(1); }
33
- 50% { transform: scale(1.05); }
34
- }
35
- @keyframes breathing {
36
- 0%, 100% { box-shadow: 0 0 10px var(--agent-glow, rgba(74, 144, 226, 0.3)); }
37
- 50% { box-shadow: 0 0 25px var(--agent-glow, rgba(74, 144, 226, 0.6)), 0 0 40px var(--agent-glow, rgba(74, 144, 226, 0.3)); }
38
- }
39
- @keyframes slide-in-left {
40
- from { transform: translateX(-100%); opacity: 0; }
41
- to { transform: translateX(0); opacity: 1; }
42
- }
43
- @keyframes fade-in {
44
- from { opacity: 0; }
45
- to { opacity: 1; }
46
- }
47
- @keyframes blink {
48
- 0%, 49% { opacity: 1; }
49
- 50%, 100% { opacity: 0; }
50
- }
51
-
52
- /* ============= 全域設定 ============= */
53
- .gradio-container {
54
- background: var(--background-fill-primary) !important;
55
- }
56
-
57
- /* ============= 🔧 修復:Header 標題 ============= */
58
  .app-header {
59
  text-align: center;
60
- padding: 40px 20px 30px 20px;
61
- position: relative;
62
  }
63
-
64
  .app-header h1 {
65
- font-size: 64px !important;
66
- margin: 0 !important;
67
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
68
  -webkit-background-clip: text;
69
  -webkit-text-fill-color: transparent;
70
- background-clip: text;
71
- font-weight: 700 !important;
72
  }
73
-
74
  .app-header p {
75
- font-size: 20px !important;
76
- color: var(--body-text-color);
77
- opacity: 0.8;
78
- margin-top: 10px !important;
79
  }
80
 
81
- /* ============= 🔧 修復:主題切換按鈕 ============= */
82
- #theme-toggle {
83
- position: fixed !important;
84
- top: 20px !important;
85
- right: 110px !important;
86
- z-index: 1000 !important;
87
- width: 40px !important;
88
- height: 40px !important;
89
- min-width: 40px !important;
90
- max-width: 40px !important;
91
- padding: 0 !important;
92
- font-size: 18px !important;
93
- border-radius: 10px !important;
94
- background: var(--background-fill-secondary) !important;
95
- border: 1.5px solid var(--border-color-primary) !important;
96
- box-shadow: 0 2px 6px rgba(0,0,0,0.1) !important;
97
- transition: all 0.3s ease !important;
98
- cursor: pointer !important;
99
- }
100
-
101
- #theme-toggle:hover {
102
- transform: translateY(-2px) !important;
103
- box-shadow: 0 4px 10px rgba(245, 166, 35, 0.3) !important;
104
- background: var(--accent-color) !important;
105
- color: white !important;
106
- }
107
-
108
- /* ============= 🔧 修復��Settings 按鈕 ============= */
109
- #settings-btn {
110
- position: fixed !important;
111
- top: 20px !important;
112
- right: 60px !important;
113
- z-index: 1000 !important;
114
- width: 40px !important;
115
- height: 40px !important;
116
- min-width: 40px !important;
117
- max-width: 40px !important;
118
- padding: 0 !important;
119
- font-size: 18px !important;
120
- border-radius: 10px !important;
121
- background: var(--background-fill-secondary) !important;
122
- border: 1.5px solid var(--border-color-primary) !important;
123
- box-shadow: 0 2px 6px rgba(0,0,0,0.1) !important;
124
- transition: all 0.3s ease !important;
125
- cursor: pointer !important;
126
- }
127
-
128
- #settings-btn:hover {
129
- transform: translateY(-2px) !important;
130
- box-shadow: 0 4px 10px rgba(74, 144, 226, 0.3) !important;
131
- background: var(--primary-color) !important;
132
- color: white !important;
133
- }
134
-
135
- /* ============= 🔧 修復:Doc 按鈕 ============= */
136
- #doc-btn {
137
- position: fixed !important;
138
- top: 20px !important;
139
- right: 10px !important;
140
- z-index: 1000 !important;
141
- width: 40px !important;
142
- height: 40px !important;
143
- min-width: 40px !important;
144
- max-width: 40px !important;
145
- padding: 0 !important;
146
- font-size: 18px !important;
147
- border-radius: 10px !important;
148
- background: var(--background-fill-secondary) !important;
149
- border: 1.5px solid var(--border-color-primary) !important;
150
- box-shadow: 0 2px 6px rgba(0,0,0,0.1) !important;
151
- transition: all 0.3s ease !important;
152
- cursor: pointer !important;
153
- }
154
-
155
- #doc-btn:hover {
156
- transform: translateY(-2px) !important;
157
- box-shadow: 0 4px 10px rgba(74, 144, 226, 0.3) !important;
158
- background: var(--primary-color) !important;
159
- color: white !important;
160
- }
161
-
162
- /* ============= 🔧 修復:Exit 按鈕樣式 (Step 2 - 頂部獨立) ============= */
163
- #exit-button {
164
- width: 100% !important;
165
- height: 50px !important;
166
- font-size: 16px !important;
167
- font-weight: 600 !important;
168
- border-radius: 10px !important;
169
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
170
- color: white !important;
171
  border: none !important;
172
- box-shadow: 0 3px 10px rgba(102, 126, 234, 0.3) !important;
173
- transition: all 0.3s ease !important;
174
- margin-bottom: 15px !important;
175
- cursor: pointer;
176
  }
177
-
178
- #exit-button:hover {
179
  transform: translateY(-2px);
180
- box-shadow: 0 5px 15px rgba(102, 126, 234, 0.5) !important;
181
  }
182
 
183
- /* ============= 🔧 新增:Exit 按鈕樣式 (內聯版本 - 與 Ready to plan 並排) ============= */
184
- #exit-button-inline {
185
- height: 50px !important;
186
- font-size: 15px !important;
187
- font-weight: 600 !important;
188
- border-radius: 10px !important;
189
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
190
- color: white !important;
191
- border: none !important;
192
- box-shadow: 0 3px 10px rgba(102, 126, 234, 0.3) !important;
193
- transition: all 0.3s ease !important;
194
- cursor: pointer;
195
- }
196
-
197
- #exit-button-inline:hover {
198
- transform: translateY(-2px);
199
- box-shadow: 0 5px 15px rgba(102, 126, 234, 0.5) !important;
200
- }
201
-
202
- /* ============= 串流輸出框 ============= */
203
- .stream-container {
204
- background: var(--background-fill-secondary);
205
- border-radius: 10px;
206
- padding: 15px;
207
- min-height: 150px;
208
- max-height: 300px;
209
- overflow-y: auto;
210
- margin: 15px 0;
211
- border: 1px solid var(--border-color-primary);
212
- font-family: 'Monaco', 'Courier New', monospace;
213
- font-size: 13px;
214
- line-height: 1.6;
215
- }
216
- .stream-text {
217
- color: var(--body-text-color);
218
- white-space: pre-wrap;
219
- word-wrap: break-word;
220
- }
221
- .stream-cursor {
222
- display: inline-block;
223
- width: 8px;
224
- height: 16px;
225
- background: var(--primary-color);
226
- animation: blink 1s infinite;
227
- margin-left: 2px;
228
- }
229
-
230
- /* ============= Agent 卡片 ============= */
231
- .agent-card {
232
- background: var(--background-fill-secondary);
233
- border-radius: 12px;
234
- padding: 15px;
235
- margin: 10px 0;
236
- border-left: 4px solid var(--agent-color, var(--primary-color));
237
- transition: all 0.3s ease;
238
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
239
- }
240
- .agent-card:hover {
241
- transform: translateX(5px);
242
- box-shadow: 0 4px 12px var(--agent-glow, rgba(74, 144, 226, 0.2));
243
- }
244
- .agent-card.active {
245
- animation: breathing 2s infinite;
246
- border-left-width: 6px;
247
  }
248
- .agent-header {
 
 
 
 
249
  display: flex;
 
250
  align-items: center;
251
- gap: 10px;
252
- margin-bottom: 8px;
253
- }
254
- .agent-avatar {
255
- font-size: 28px;
256
- filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
257
  }
258
- .agent-info h3 {
259
- margin: 0;
260
- font-size: 16px;
261
- color: var(--body-text-color);
262
  }
263
- .agent-role {
264
- font-size: 12px;
265
- color: var(--body-text-color);
266
- opacity: 0.7;
267
- }
268
- .agent-status {
269
- display: inline-block;
270
- padding: 4px 12px;
271
- border-radius: 12px;
272
- font-size: 11px;
273
- font-weight: 600;
274
- margin-top: 8px;
275
- }
276
- .status-idle {
277
- background: rgba(128, 128, 128, 0.2);
278
- color: var(--body-text-color);
279
- }
280
- .status-working {
281
- background: var(--primary-glow);
282
- color: var(--primary-color);
283
- animation: pulse 1.5s infinite;
284
- }
285
- .status-complete {
286
- background: rgba(126, 211, 33, 0.2);
287
- color: var(--success-color);
288
- }
289
- .agent-message {
290
- margin-top: 8px;
291
- padding: 8px;
292
- background: var(--background-fill-primary);
293
- border-radius: 6px;
294
- font-size: 13px;
295
- color: var(--body-text-color);
296
- opacity: 0.9;
297
  }
298
 
299
- /* ============= Task 卡片 ============= */
300
- .task-card {
301
- background: var(--background-fill-secondary);
302
- border-radius: 10px;
303
- padding: 16px;
304
- margin: 12px 0;
305
- border-left: 4px solid var(--task-priority-color, #999);
306
- box-shadow: 0 2px 6px rgba(0,0,0,0.08);
307
- transition: all 0.3s ease;
308
- }
309
- .task-card:hover {
310
- transform: translateY(-2px);
311
- box-shadow: 0 4px 12px rgba(0,0,0,0.12);
312
- }
313
- .task-header {
314
  display: flex;
315
- justify-content: space-between;
316
- align-items: center;
317
- margin-bottom: 10px;
 
 
 
 
 
318
  }
319
- .task-title {
320
- font-size: 16px;
321
- font-weight: 600;
322
- color: var(--body-text-color);
323
  display: flex;
324
- align-items: center;
325
  gap: 10px;
 
326
  }
327
- .task-icon {
328
- font-size: 24px;
 
329
  }
330
- .priority-badge {
331
- padding: 4px 12px;
332
- border-radius: 12px;
333
- font-size: 11px;
334
- font-weight: 700;
335
- text-transform: uppercase;
336
- }
337
- .priority-high {
338
- background: rgba(255, 107, 107, 0.2);
339
- color: #FF6B6B;
340
- }
341
- .priority-medium {
342
- background: rgba(245, 166, 35, 0.2);
343
- color: #F5A623;
344
- }
345
- .priority-low {
346
- background: rgba(126, 211, 33, 0.2);
347
- color: #7ED321;
348
- }
349
- .task-details {
350
- display: flex;
351
- gap: 20px;
352
- font-size: 13px;
353
- color: var(--body-text-color);
354
- opacity: 0.8;
355
- margin-top: 10px;
356
  }
357
- .task-detail-item {
358
- display: flex;
359
- align-items: center;
360
- gap: 6px;
 
 
361
  }
362
-
363
- /* ============= Summary Card ============= */
364
- .summary-card {
365
- background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
366
- border-radius: 12px;
367
- padding: 20px;
368
  color: white;
369
- margin: 15px 0;
370
- box-shadow: 0 4px 15px rgba(74, 144, 226, 0.3);
371
- }
372
- .summary-stats {
373
- display: flex;
374
- justify-content: space-around;
375
- margin-top: 15px;
376
- }
377
- .stat-item {
378
- text-align: center;
379
- }
380
- .stat-value {
381
- font-size: 28px;
382
- font-weight: bold;
383
- display: block;
384
  }
385
- .stat-label {
386
- font-size: 12px;
387
- opacity: 0.9;
388
- margin-top: 5px;
389
  }
390
-
391
- /* ============= 🔧 優化:Ready to Plan 按鈕樣式(內聯佈局) ============= */
392
- .ready-plan-button {
393
- height: 50px !important;
394
- font-size: 16px !important;
395
- font-weight: 700 !important;
396
- border-radius: 10px !important;
397
- background: linear-gradient(135deg, #50C878 0%, #7ED321 100%) !important;
398
- color: white !important;
399
- border: none !important;
400
- box-shadow: 0 3px 12px rgba(80, 200, 120, 0.4) !important;
401
- transition: all 0.3s ease !important;
402
- cursor: pointer;
403
- }
404
-
405
- .ready-plan-button:hover {
406
- transform: translateY(-2px);
407
- box-shadow: 0 5px 20px rgba(80, 200, 120, 0.6) !important;
408
- background: linear-gradient(135deg, #5DD68D 0%, #8FE63F 100%) !important;
409
- }
410
-
411
- .ready-plan-button:active {
412
- transform: translateY(0);
413
  }
414
 
415
- /* ============= Reasoning Timeline ============= */
416
- .reasoning-timeline {
 
417
  padding: 20px 0;
418
  }
419
- .reasoning-item {
420
- padding: 15px;
421
- margin: 10px 0;
422
- border-radius: 8px;
423
- background: var(--background-fill-secondary);
424
- border-left: 3px solid var(--agent-color, var(--primary-color));
425
- animation: fade-in 0.5s ease;
 
 
 
 
 
 
 
 
 
426
  }
427
- .reasoning-header {
 
428
  display: flex;
 
429
  align-items: center;
430
- gap: 10px;
431
- margin-bottom: 8px;
432
- }
433
- .reasoning-agent {
434
- font-weight: 600;
435
- color: var(--agent-color, var(--primary-color));
436
  }
437
- .reasoning-time {
438
- font-size: 11px;
439
- opacity: 0.6;
440
- margin-left: auto;
441
- }
442
- .reasoning-content {
443
- color: var(--body-text-color);
444
- opacity: 0.9;
445
- line-height: 1.6;
446
- }
447
-
448
- /* ============= Modal 樣式 ============= */
449
- .modal-overlay {
450
- position: fixed;
451
- top: 0;
452
- left: 0;
453
- right: 0;
454
- bottom: 0;
455
- background: rgba(0, 0, 0, 0.5);
456
- z-index: 1000;
457
  display: flex;
458
  align-items: center;
459
  justify-content: center;
 
 
460
  }
461
- .modal-content {
462
- background: var(--background-fill-primary);
463
- border-radius: 16px;
464
- padding: 30px;
465
- max-width: 600px;
466
- width: 90%;
467
- max-height: 80vh;
468
- overflow-y: auto;
469
- box-shadow: 0 10px 40px rgba(0,0,0,0.3);
470
- }
471
-
472
- /* ============= Responsive ============= */
473
- @media (max-width: 768px) {
474
- #top-controls {
475
- top: 10px;
476
- right: 10px;
477
- }
478
- #top-controls button {
479
- width: 40px !important;
480
- height: 40px !important;
481
- font-size: 18px !important;
482
- }
483
- .agent-card {
484
- padding: 12px;
485
- }
486
- .task-card {
487
- padding: 12px;
488
- }
489
- }
490
-
491
- /* ============= 滾動條美化 ============= */
492
- ::-webkit-scrollbar {
493
- width: 8px;
494
- height: 8px;
495
- }
496
- ::-webkit-scrollbar-track {
497
- background: var(--background-fill-secondary);
498
- }
499
- ::-webkit-scrollbar-thumb {
500
- background: var(--primary-color);
501
- border-radius: 4px;
502
- }
503
- ::-webkit-scrollbar-thumb:hover {
504
- background: var(--secondary-color);
505
  }
506
- /* ============= 🔧 主題切換 - 深色模式樣式 ============= */
507
- .theme-dark {
508
- background: #1a1a1a !important;
509
  }
510
 
511
- .theme-dark .gradio-container {
512
- background: #1a1a1a !important;
 
 
 
 
513
  }
514
-
515
- .theme-dark [data-testid="block"] {
516
- background: #2d2d2d !important;
 
 
 
 
517
  }
518
-
519
- .theme-dark .gr-box,
520
- .theme-dark .gr-form,
521
- .theme-dark .gr-input,
522
- .theme-dark .gr-group {
523
- background: #2d2d2d !important;
524
- border-color: #444444 !important;
525
  }
526
 
527
- .theme-dark p,
528
- .theme-dark label,
529
- .theme-dark span,
530
- .theme-dark h1,
531
- .theme-dark h2,
532
- .theme-dark h3 {
533
- color: #e0e0e0 !important;
534
- }
535
 
536
- .theme-dark input,
537
- .theme-dark textarea {
538
- background: #3a3a3a !important;
539
- color: #e0e0e0 !important;
540
- border-color: #555555 !important;
 
541
  }
542
-
543
- .theme-dark button {
544
- background: #3a3a3a !important;
545
- color: #e0e0e0 !important;
546
- border-color: #555555 !important;
547
  }
548
-
549
- /* 保持品牌色按鈕的顏色 */
550
- .theme-dark #exit-button,
551
- .theme-dark #exit-button-inline,
552
- .theme-dark .ready-plan-button,
553
- .theme-dark #theme-toggle,
554
- .theme-dark #settings-btn,
555
- .theme-dark #doc-btn {
556
- /* 保持原有的漸變色 */
557
- filter: brightness(0.9);
558
  }
559
  </style>
560
-
561
- <script>
562
- // 主題切換功能 - 頁面載入時執行
563
- (function() {
564
- // 等待 Gradio 完全載入
565
- function initTheme() {
566
- const savedTheme = localStorage.getItem('lifeflow-theme');
567
- console.log('Initializing theme, saved:', savedTheme);
568
-
569
- if (savedTheme === 'dark') {
570
- const container = document.querySelector('.gradio-container');
571
- if (container) {
572
- container.classList.add('theme-dark');
573
- document.body.classList.add('theme-dark');
574
- console.log('Dark theme applied on load');
575
- } else {
576
- // 如果還沒找到容器,稍後再試
577
- setTimeout(initTheme, 100);
578
- }
579
- }
580
- }
581
-
582
- // 頁面載入完成後執行
583
- if (document.readyState === 'loading') {
584
- document.addEventListener('DOMContentLoaded', initTheme);
585
- } else {
586
- initTheme();
587
- }
588
-
589
- // 也在 window load 時再次檢查
590
- window.addEventListener('load', initTheme);
591
- })();
592
- </script>
593
  """
 
1
  """
2
  LifeFlow AI - Theme System
3
+ 提供現代化、毛玻璃風格的主題和樣式管理
4
  """
5
 
 
6
  def get_enhanced_css() -> str:
 
 
 
 
 
 
 
7
  return """
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet">
9
  <style>
10
  :root {
11
+ --primary-color: #6366f1; /* Indigo */
12
+ --primary-dark: #4f46e5;
13
+ --secondary-color: #10b981; /* Emerald */
14
+ --accent-color: #f59e0b; /* Amber */
15
+ --background-gradient: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
16
+ --glass-bg: rgba(255, 255, 255, 0.7);
17
+ --glass-border: 1px solid rgba(255, 255, 255, 0.5);
18
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
19
+ --radius-lg: 16px;
20
+ --radius-md: 12px;
21
+ --font-main: 'Inter', sans-serif;
22
+ }
23
+
24
+ body, .gradio-container {
25
+ font-family: var(--font-main) !important;
26
+ background: #f8fafc !important;
27
+ }
28
+
29
+ /* ============= 毛玻璃卡片風格 ============= */
30
+ .glass-card {
31
+ background: var(--glass-bg);
32
+ backdrop-filter: blur(12px);
33
+ -webkit-backdrop-filter: blur(12px);
34
+ border: var(--glass-border);
35
+ border-radius: var(--radius-lg);
36
+ box-shadow: var(--shadow-lg);
37
+ }
38
+
39
+ /* ============= 標題優化 ============= */
 
 
 
 
 
 
 
 
 
 
 
40
  .app-header {
41
  text-align: center;
42
+ padding: 40px 0 20px;
43
+ animation: fade-in 0.8s ease-out;
44
  }
 
45
  .app-header h1 {
46
+ font-size: 3.5rem !important;
47
+ font-weight: 800 !important;
48
+ background: linear-gradient(to right, #6366f1, #ec4899);
49
  -webkit-background-clip: text;
50
  -webkit-text-fill-color: transparent;
51
+ margin-bottom: 0.5rem !important;
52
+ letter-spacing: -0.02em;
53
  }
 
54
  .app-header p {
55
+ font-size: 1.2rem !important;
56
+ color: #64748b;
57
+ font-weight: 400;
 
58
  }
59
 
60
+ /* ============= 按鈕優化 ============= */
61
+ button.primary {
62
+ background: linear-gradient(135deg, var(--primary-color), var(--primary-dark)) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  border: none !important;
64
+ box-shadow: 0 4px 6px rgba(99, 102, 241, 0.3) !important;
65
+ transition: all 0.2s ease !important;
 
 
66
  }
67
+ button.primary:hover {
 
68
  transform: translateY(-2px);
69
+ box-shadow: 0 8px 12px rgba(99, 102, 241, 0.4) !important;
70
  }
71
 
72
+ /* ============= Agent 狀態卡片 (Grid Layout) ============= */
73
+ .agent-grid {
74
+ display: grid;
75
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
76
+ gap: 12px;
77
+ margin-top: 10px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  }
79
+ .agent-card-mini {
80
+ background: white;
81
+ padding: 10px;
82
+ border-radius: var(--radius-md);
83
+ border: 1px solid #e2e8f0;
84
  display: flex;
85
+ flex-direction: column;
86
  align-items: center;
87
+ text-align: center;
88
+ transition: all 0.3s ease;
 
 
 
 
89
  }
90
+ .agent-card-mini.active {
91
+ border-color: var(--primary-color);
92
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
93
+ transform: scale(1.02);
94
  }
95
+ .agent-avatar-mini { font-size: 24px; margin-bottom: 4px; }
96
+ .agent-name-mini { font-weight: 600; font-size: 0.85rem; color: #1e293b; }
97
+ .agent-status-dot {
98
+ height: 8px; width: 8px; border-radius: 50%; display: inline-block; margin-top: 4px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  }
100
 
101
+ /* ============= 對話框樣式 (Chat Bubbles) ============= */
102
+ .chat-history {
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  display: flex;
104
+ flex-direction: column;
105
+ gap: 16px;
106
+ padding: 20px;
107
+ background: #fff;
108
+ border-radius: var(--radius-lg);
109
+ border: 1px solid #e2e8f0;
110
+ max-height: 500px;
111
+ overflow-y: auto;
112
  }
113
+ .chat-message {
 
 
 
114
  display: flex;
115
+ align-items: flex-end;
116
  gap: 10px;
117
+ max-width: 85%;
118
  }
119
+ .chat-message.user {
120
+ align-self: flex-end;
121
+ flex-direction: row-reverse;
122
  }
123
+ .chat-message.assistant {
124
+ align-self: flex-start;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  }
126
+ .chat-bubble {
127
+ padding: 12px 16px;
128
+ border-radius: 18px;
129
+ font-size: 0.95rem;
130
+ line-height: 1.5;
131
+ position: relative;
132
  }
133
+ .chat-message.user .chat-bubble {
134
+ background: var(--primary-color);
 
 
 
 
135
  color: white;
136
+ border-bottom-right-radius: 4px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  }
138
+ .chat-message.assistant .chat-bubble {
139
+ background: #f1f5f9;
140
+ color: #334155;
141
+ border-bottom-left-radius: 4px;
142
  }
143
+ .chat-time {
144
+ font-size: 0.7rem;
145
+ opacity: 0.7;
146
+ margin-top: 4px;
147
+ text-align: right;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  }
149
 
150
+ /* ============= Timeline 優化 ============= */
151
+ .timeline-container {
152
+ position: relative;
153
  padding: 20px 0;
154
  }
155
+ .timeline-container::before {
156
+ content: '';
157
+ position: absolute;
158
+ left: 24px;
159
+ top: 30px;
160
+ bottom: 30px;
161
+ width: 2px;
162
+ background: #e2e8f0;
163
+ z-index: 0;
164
+ }
165
+ .timeline-item {
166
+ position: relative;
167
+ display: flex;
168
+ gap: 20px;
169
+ margin-bottom: 24px;
170
+ z-index: 1;
171
  }
172
+ .timeline-left {
173
+ min-width: 50px;
174
  display: flex;
175
+ flex-direction: column;
176
  align-items: center;
 
 
 
 
 
 
177
  }
178
+ .timeline-icon-box {
179
+ width: 48px;
180
+ height: 48px;
181
+ border-radius: 50%;
182
+ background: white;
183
+ border: 2px solid var(--primary-color);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  display: flex;
185
  align-items: center;
186
  justify-content: center;
187
+ font-size: 20px;
188
+ box-shadow: 0 4px 6px rgba(0,0,0,0.05);
189
  }
190
+ .timeline-content-card {
191
+ flex: 1;
192
+ background: white;
193
+ padding: 16px;
194
+ border-radius: var(--radius-md);
195
+ border: 1px solid #e2e8f0;
196
+ box-shadow: 0 2px 4px rgba(0,0,0,0.02);
197
+ transition: transform 0.2s;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  }
199
+ .timeline-content-card:hover {
200
+ transform: translateX(4px);
201
+ border-color: var(--primary-color);
202
  }
203
 
204
+ /* ============= Metric Cards ============= */
205
+ .metrics-container {
206
+ display: grid;
207
+ grid-template-columns: repeat(3, 1fr);
208
+ gap: 15px;
209
+ margin-bottom: 20px;
210
  }
211
+ .metric-card {
212
+ background: white;
213
+ padding: 15px;
214
+ border-radius: var(--radius-md);
215
+ text-align: center;
216
+ border: 1px solid #e2e8f0;
217
+ box-shadow: 0 2px 4px rgba(0,0,0,0.02);
218
  }
219
+ .metric-value {
220
+ font-size: 1.5rem;
221
+ font-weight: 700;
222
+ color: #1e293b;
223
+ margin: 5px 0;
 
 
224
  }
225
 
226
+ /* ============= 動畫定義 ============= */
227
+ @keyframes fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
228
+ @keyframes pulse-ring { 0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.7); } 70% { box-shadow: 0 0 0 10px rgba(99, 102, 241, 0); } 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); } }
 
 
 
 
 
229
 
230
+ /* Dark Mode Overrides (Minimal) */
231
+ .theme-dark body { background: #0f172a !important; }
232
+ .theme-dark .chat-history, .theme-dark .timeline-content-card, .theme-dark .metric-card, .theme-dark .agent-card-mini {
233
+ background: #1e293b;
234
+ border-color: #334155;
235
+ color: #e2e8f0;
236
  }
237
+ .theme-dark h1, .theme-dark h2, .theme-dark h3, .theme-dark p, .theme-dark span {
238
+ color: #e2e8f0 !important;
 
 
 
239
  }
240
+ .theme-dark .chat-message.assistant .chat-bubble {
241
+ background: #334155;
242
+ color: #e2e8f0;
 
 
 
 
 
 
 
243
  }
244
  </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  """