Spaces:
Running
Running
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 +177 -875
- core/helpers.py +34 -0
- core/session.py +65 -0
- core/visualizers.py +128 -0
- services/__init__.py +0 -0
- services/planner_service.py +449 -0
- ui/components/header.py +8 -8
- ui/components/input_form.py +35 -22
- ui/components/results.py +25 -6
- core/utils.py → ui/renderers.py +175 -165
- ui/theme.py +179 -527
app.py
CHANGED
|
@@ -1,911 +1,217 @@
|
|
| 1 |
"""
|
| 2 |
-
LifeFlow AI -
|
| 3 |
-
|
| 4 |
"""
|
| 5 |
|
| 6 |
-
import sys
|
| 7 |
-
from pathlib import Path
|
| 8 |
import gradio as gr
|
| 9 |
-
from
|
| 10 |
-
import time as time_module
|
| 11 |
-
import json
|
| 12 |
-
import uuid
|
| 13 |
-
from typing import Dict, Any, Optional
|
| 14 |
|
| 15 |
-
# =====
|
| 16 |
-
from
|
|
|
|
| 17 |
|
| 18 |
-
# =====
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 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 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
"time": datetime.now().strftime("%H:%M:%S")
|
| 413 |
-
}
|
| 414 |
|
| 415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 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 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
)
|
| 437 |
|
| 438 |
-
def
|
| 439 |
-
"""Step 2
|
| 440 |
-
|
| 441 |
-
self.
|
|
|
|
| 442 |
|
| 443 |
-
|
| 444 |
-
|
| 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 |
-
|
| 452 |
"🗺️ Scout is searching...",
|
| 453 |
-
*
|
| 454 |
-
|
| 455 |
)
|
| 456 |
|
| 457 |
-
def
|
| 458 |
-
"""Step 3
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 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,
|
| 533 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
"🎉 Planning completed!",
|
| 535 |
-
*
|
| 536 |
-
|
| 537 |
)
|
| 538 |
-
|
| 539 |
-
|
| 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 |
-
|
| 544 |
-
|
| 545 |
-
for k in ["planner", "scout", "optimizer", "validator", "weather", "traffic"]
|
| 546 |
-
]
|
| 547 |
return (
|
| 548 |
-
|
| 549 |
-
gr.update(
|
| 550 |
-
f"Error: {
|
| 551 |
-
*
|
| 552 |
-
|
| 553 |
)
|
| 554 |
|
| 555 |
-
# ===== 輔助方法 =====
|
| 556 |
-
|
| 557 |
-
# [Security Fix] 新增:保存設定到 User Session
|
| 558 |
def save_settings(self, google_key, weather_key, gemini_key, model, session_data):
|
| 559 |
-
"""
|
| 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 |
-
#
|
| 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
|
| 937 |
|
| 938 |
auto_location.change(fn=toggle_location_inputs, inputs=[auto_location], outputs=[location_inputs])
|
| 939 |
|
| 940 |
-
|
| 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 |
-
|
| 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,
|
| 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=
|
| 991 |
inputs=[session_state],
|
| 992 |
outputs=[reasoning_output, status_bar, *agent_displays, session_state]
|
| 993 |
).then(
|
| 994 |
-
fn=
|
| 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=
|
| 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 |
-
|
| 19 |
-
|
| 20 |
-
|
| 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 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
user_input = gr.Textbox(
|
| 12 |
-
label="
|
| 13 |
-
placeholder="e.g.,
|
| 14 |
-
lines=3
|
|
|
|
| 15 |
)
|
| 16 |
-
gr.Markdown("---")
|
| 17 |
|
| 18 |
-
#
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
#
|
| 22 |
-
with gr.
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
|
|
|
| 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("### 🤖
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
agent_displays
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 -
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
"complete": "Complete ✓"
|
| 42 |
-
}.get(status, "Unknown")
|
| 43 |
|
| 44 |
-
|
| 45 |
|
| 46 |
return f"""
|
| 47 |
-
<div class="{
|
| 48 |
-
<div class="agent-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 73 |
start = time_window.get('earliest_time', None)
|
| 74 |
end = time_window.get('latest_time', None)
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
if
|
| 79 |
-
|
| 80 |
-
elif
|
| 81 |
-
|
| 82 |
-
elif
|
| 83 |
-
|
| 84 |
else:
|
| 85 |
-
|
|
|
|
|
|
|
| 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>{
|
| 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:]):
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 262 |
-
animation: confetti-fall linear infinite;
|
| 263 |
-
opacity: 0.9;
|
| 264 |
}}
|
| 265 |
</style>
|
| 266 |
"""
|
| 267 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
"""
|
| 271 |
-
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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-
|
| 20 |
-
--secondary-color: #
|
| 21 |
-
--accent-color: #
|
| 22 |
-
--
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
--
|
| 26 |
-
--
|
| 27 |
-
--
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 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
|
| 61 |
-
|
| 62 |
}
|
| 63 |
-
|
| 64 |
.app-header h1 {
|
| 65 |
-
font-size:
|
| 66 |
-
|
| 67 |
-
background: linear-gradient(
|
| 68 |
-webkit-background-clip: text;
|
| 69 |
-webkit-text-fill-color: transparent;
|
| 70 |
-
|
| 71 |
-
|
| 72 |
}
|
| 73 |
-
|
| 74 |
.app-header p {
|
| 75 |
-
font-size:
|
| 76 |
-
color:
|
| 77 |
-
|
| 78 |
-
margin-top: 10px !important;
|
| 79 |
}
|
| 80 |
|
| 81 |
-
/* =============
|
| 82 |
-
|
| 83 |
-
|
| 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
|
| 173 |
-
transition: all 0.
|
| 174 |
-
margin-bottom: 15px !important;
|
| 175 |
-
cursor: pointer;
|
| 176 |
}
|
| 177 |
-
|
| 178 |
-
#exit-button:hover {
|
| 179 |
transform: translateY(-2px);
|
| 180 |
-
box-shadow: 0
|
| 181 |
}
|
| 182 |
|
| 183 |
-
/* =============
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 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-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
display: flex;
|
|
|
|
| 250 |
align-items: center;
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
}
|
| 254 |
-
.agent-avatar {
|
| 255 |
-
font-size: 28px;
|
| 256 |
-
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
|
| 257 |
}
|
| 258 |
-
.agent-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
}
|
| 263 |
-
.agent-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 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 |
-
/* =============
|
| 300 |
-
.
|
| 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 |
-
|
| 316 |
-
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
}
|
| 319 |
-
.
|
| 320 |
-
font-size: 16px;
|
| 321 |
-
font-weight: 600;
|
| 322 |
-
color: var(--body-text-color);
|
| 323 |
display: flex;
|
| 324 |
-
align-items:
|
| 325 |
gap: 10px;
|
|
|
|
| 326 |
}
|
| 327 |
-
.
|
| 328 |
-
|
|
|
|
| 329 |
}
|
| 330 |
-
.
|
| 331 |
-
|
| 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 |
-
.
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
|
|
|
|
|
|
| 361 |
}
|
| 362 |
-
|
| 363 |
-
|
| 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 |
-
|
| 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 |
-
.
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
}
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 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 |
-
/* =============
|
| 416 |
-
.
|
|
|
|
| 417 |
padding: 20px 0;
|
| 418 |
}
|
| 419 |
-
.
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
}
|
| 427 |
-
.
|
|
|
|
| 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 |
-
.
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 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 |
-
.
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
padding:
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 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 |
-
|
| 508 |
-
|
| 509 |
}
|
| 510 |
|
| 511 |
-
|
| 512 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
}
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
}
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
background: #2d2d2d !important;
|
| 524 |
-
border-color: #444444 !important;
|
| 525 |
}
|
| 526 |
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
.
|
| 530 |
-
.theme-dark h1,
|
| 531 |
-
.theme-dark h2,
|
| 532 |
-
.theme-dark h3 {
|
| 533 |
-
color: #e0e0e0 !important;
|
| 534 |
-
}
|
| 535 |
|
| 536 |
-
|
| 537 |
-
.theme-dark
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
border-color: #
|
|
|
|
| 541 |
}
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
background: #3a3a3a !important;
|
| 545 |
-
color: #e0e0e0 !important;
|
| 546 |
-
border-color: #555555 !important;
|
| 547 |
}
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 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 |
"""
|