Spaces:
Running
Running
feat: Complete UI/UX redesign and fix critical workflow bugs
Browse files- app.py +263 -383
- core/session.py +7 -18
- core/visualizers.py +49 -64
- services/planner_service.py +92 -22
- ui/components/header.py +27 -17
- ui/components/input_form.py +23 -16
- ui/components/progress_stepper.py +217 -0
- ui/renderers.py +119 -458
- ui/theme.py +253 -290
app.py
CHANGED
|
@@ -1,475 +1,355 @@
|
|
| 1 |
"""
|
| 2 |
-
LifeFlow AI - Main Application (
|
| 3 |
-
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
import gradio as gr
|
| 7 |
-
|
| 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 |
-
|
| 18 |
create_summary_card,
|
| 19 |
-
|
| 20 |
-
get_reasoning_html_reversed,
|
| 21 |
-
generate_chat_history_html_bubble
|
| 22 |
)
|
|
|
|
|
|
|
| 23 |
from core.visualizers import create_animated_map
|
| 24 |
|
| 25 |
-
# ===== UI Components =====
|
| 26 |
-
from ui.components.header import create_header
|
| 27 |
-
from ui.components.input_form import create_input_form, toggle_location_inputs
|
| 28 |
-
from ui.components.confirmation import create_confirmation_area
|
| 29 |
-
from ui.components.results import create_team_area, create_result_area, create_tabs
|
| 30 |
-
from ui.components.modals import create_settings_modal, create_doc_modal
|
| 31 |
-
|
| 32 |
-
|
| 33 |
class LifeFlowAI:
|
| 34 |
def __init__(self):
|
| 35 |
self.service = PlannerService()
|
| 36 |
|
| 37 |
-
def
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
has_gemini = bool(session.custom_settings.get("gemini_api_key"))
|
| 40 |
has_google = bool(session.custom_settings.get("google_maps_api_key"))
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
if not has_google: missing.append("Google Maps Key")
|
| 48 |
-
return f"⚠️ Missing Keys: {', '.join(missing)} - Please configure Settings ↗"
|
| 49 |
|
| 50 |
-
|
| 51 |
-
"""輔助函數:生成 Agent 卡片 HTML"""
|
| 52 |
|
| 53 |
-
# 🔥 [關鍵修正] 更新為你的新 Agent 名單 (共 5 個)
|
| 54 |
-
# 必須與 create_team_area 中的順序完全一致!
|
| 55 |
-
agents = ['team', 'scout', 'optimizer', 'navigator', 'weatherman', 'presenter']
|
| 56 |
-
|
| 57 |
-
outputs = []
|
| 58 |
-
for agent in agents:
|
| 59 |
-
if agent == active_agent:
|
| 60 |
-
outputs.append(create_agent_card_enhanced(agent, status, message))
|
| 61 |
-
else:
|
| 62 |
-
outputs.append(create_agent_card_enhanced(agent, "idle", "On standby"))
|
| 63 |
-
|
| 64 |
-
return outputs
|
| 65 |
-
|
| 66 |
-
def _update_task_summary(self, session: UserSession) -> str:
|
| 67 |
-
"""統一計算並回傳 Summary Card HTML"""
|
| 68 |
-
tasks = session.task_list
|
| 69 |
-
if not tasks:
|
| 70 |
-
return create_summary_card(0, 0, 0)
|
| 71 |
-
|
| 72 |
-
high_priority = sum(1 for t in tasks if t.get("priority", "").upper() == "HIGH")
|
| 73 |
-
|
| 74 |
-
total_time = 0
|
| 75 |
-
for t in tasks:
|
| 76 |
-
dur_str = str(t.get("duration", "0"))
|
| 77 |
-
try:
|
| 78 |
-
val = int(dur_str.split()[0])
|
| 79 |
-
total_time += val
|
| 80 |
-
except (ValueError, IndexError):
|
| 81 |
-
pass
|
| 82 |
-
|
| 83 |
-
return create_summary_card(len(tasks), high_priority, total_time)
|
| 84 |
-
|
| 85 |
-
# -------------------------------------------------------------------------
|
| 86 |
-
# Step 1: Analyze (分析任務)
|
| 87 |
-
# -------------------------------------------------------------------------
|
| 88 |
def analyze_wrapper(self, user_input, auto_loc, lat, lon, session_data):
|
| 89 |
session = UserSession.from_dict(session_data)
|
| 90 |
-
|
| 91 |
-
# 1. 輸入驗證
|
| 92 |
-
if not user_input or not user_input.strip():
|
| 93 |
-
agent_outputs = self._get_agent_outputs("team", "idle", "Waiting")
|
| 94 |
-
yield (
|
| 95 |
-
"<div style='color: #ef4444; font-weight: bold; padding: 5px;'>⚠️ Please describe your plans first!</div>",
|
| 96 |
-
gr.HTML(), gr.HTML(),
|
| 97 |
-
get_reasoning_html_reversed(session.reasoning_messages),
|
| 98 |
-
gr.update(visible=False), gr.update(visible=False), gr.HTML(),
|
| 99 |
-
"⚠️ Input Required", gr.update(visible=True), *agent_outputs, session.to_dict()
|
| 100 |
-
)
|
| 101 |
-
return
|
| 102 |
-
|
| 103 |
-
# 2. 執行分析
|
| 104 |
iterator = self.service.run_step1_analysis(user_input, auto_loc, lat, lon, session)
|
| 105 |
-
|
| 106 |
for event in iterator:
|
| 107 |
evt_type = event.get("type")
|
| 108 |
-
agent_status = event.get("agent_status", ("team", "idle", "Waiting"))
|
| 109 |
-
agent_outputs = self._get_agent_outputs(*agent_status)
|
| 110 |
current_session = event.get("session", session)
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
if evt_type == "stream":
|
| 114 |
-
html_output = create_agent_stream_output(event.get("stream_text", ""))
|
| 115 |
yield (
|
| 116 |
-
|
| 117 |
-
gr.
|
| 118 |
-
gr.update(
|
| 119 |
-
f"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
)
|
| 121 |
-
|
| 122 |
-
summary_html = self._update_task_summary(current_session)
|
| 123 |
-
|
| 124 |
-
task_html = self.service.generate_task_list_html(current_session)
|
| 125 |
-
|
| 126 |
-
final_text = event.get("stream_text", "Analysis complete!")
|
| 127 |
-
html_output = create_agent_stream_output(final_text)
|
| 128 |
-
|
| 129 |
-
final_agents = self._get_agent_outputs("team", "complete", "Tasks ready")
|
| 130 |
|
|
|
|
| 131 |
yield (
|
| 132 |
-
|
| 133 |
-
gr.
|
| 134 |
-
gr.
|
| 135 |
-
|
| 136 |
-
gr.update(
|
| 137 |
-
|
| 138 |
-
|
|
|
|
| 139 |
)
|
| 140 |
|
| 141 |
-
elif evt_type == "
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
yield (
|
| 146 |
-
|
| 147 |
-
gr.update(visible=
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
)
|
| 150 |
|
| 151 |
-
|
| 152 |
-
# -------------------------------------------------------------------------
|
| 153 |
-
# Chat Modification (修改任務)
|
| 154 |
-
# -------------------------------------------------------------------------
|
| 155 |
def chat_wrapper(self, msg, session_data):
|
| 156 |
session = UserSession.from_dict(session_data)
|
| 157 |
iterator = self.service.modify_task_chat(msg, session)
|
| 158 |
-
|
| 159 |
for event in iterator:
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
yield (
|
| 166 |
-
generate_chat_history_html_bubble(current_session), # Output 1: Chat History
|
| 167 |
-
self.service.generate_task_list_html(current_session), # Output 2: Task List
|
| 168 |
-
new_summary_html, # Output 3: ✅ Summary Card (新增這個)
|
| 169 |
-
current_session.to_dict() # Output 4: Session State
|
| 170 |
-
)
|
| 171 |
|
| 172 |
-
|
| 173 |
-
# Step 2 -> 3 Transition (驗證與過渡)
|
| 174 |
-
# -------------------------------------------------------------------------
|
| 175 |
-
def transition_to_planning(self, session_data):
|
| 176 |
session = UserSession.from_dict(session_data)
|
| 177 |
|
| 178 |
-
#
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
reasoning = get_reasoning_html_reversed(session.reasoning_messages)
|
| 182 |
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
gr.update(visible=True), # chat_input_area
|
| 186 |
-
gr.update(visible=False), # team_area
|
| 187 |
-
gr.update(), # tabs
|
| 188 |
-
gr.update(), # 🔥 [新增] report_tab (維持現狀)
|
| 189 |
-
gr.update(), # 🔥 [新增] map_tab (維持現狀)
|
| 190 |
-
reasoning,
|
| 191 |
-
"⚠️ Error: No tasks to plan! Please add tasks.",
|
| 192 |
-
*agent_outputs,
|
| 193 |
-
session.to_dict()
|
| 194 |
-
)
|
| 195 |
|
| 196 |
-
#
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
gr.update(selected="report_tab"), # tabs (預設看 Log)
|
| 208 |
-
gr.update(visible=True), # 🔥 [關鍵修改] report_tab 設為可見!
|
| 209 |
-
gr.update(visible=False), # 🔥 [關鍵修改] map_tab 設為可見!
|
| 210 |
-
reasoning_html,
|
| 211 |
-
"🗺️ Scout is searching locations...",
|
| 212 |
-
*agent_outputs,
|
| 213 |
-
current_session.to_dict()
|
| 214 |
-
)
|
| 215 |
-
|
| 216 |
-
# -------------------------------------------------------------------------
|
| 217 |
-
# Step 3 Wrapper (Team Analysis)
|
| 218 |
-
# -------------------------------------------------------------------------
|
| 219 |
-
def step3_wrapper(self, session_data):
|
| 220 |
-
session = UserSession.from_dict(session_data)
|
| 221 |
|
| 222 |
-
|
| 223 |
-
if not session.task_list:
|
| 224 |
-
agent_outputs = self._get_agent_outputs("team", "idle", "No tasks")
|
| 225 |
-
# yield 順序: Report, Reasoning, Agents(6個), Session
|
| 226 |
-
yield ("", "", *agent_outputs, session.to_dict())
|
| 227 |
-
return
|
| 228 |
|
| 229 |
iterator = self.service.run_step3_team(session)
|
| 230 |
-
current_report = "⏳ Generating plan..."
|
| 231 |
|
| 232 |
for event in iterator:
|
|
|
|
| 233 |
evt_type = event.get("type")
|
| 234 |
-
current_session = event.get("session", session)
|
| 235 |
-
|
| 236 |
-
# 準備 UI Data
|
| 237 |
-
reasoning_html = get_reasoning_html_reversed(current_session.reasoning_messages)
|
| 238 |
-
agent_status = event.get("agent_status", ("team", "working", "Processing..."))
|
| 239 |
-
agent_outputs = self._get_agent_outputs(*agent_status)
|
| 240 |
|
| 241 |
-
# 更新 Report 暫存
|
| 242 |
if evt_type == "report_stream":
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
|
| 254 |
-
# -------------------------------------------------------------------------
|
| 255 |
-
# Step 4 Wrapper (Optimization & Results)
|
| 256 |
-
# -------------------------------------------------------------------------
|
| 257 |
def step4_wrapper(self, session_data):
|
| 258 |
session = UserSession.from_dict(session_data)
|
| 259 |
-
if not session.task_list: # 二次防呆
|
| 260 |
-
default_map = create_animated_map()
|
| 261 |
-
agent_outputs = self._get_agent_outputs("team", "idle", "No tasks")
|
| 262 |
-
return (
|
| 263 |
-
"", "", "", default_map,
|
| 264 |
-
gr.update(), gr.update(), "⚠️ Planning aborted",
|
| 265 |
-
*agent_outputs, session.to_dict()
|
| 266 |
-
)
|
| 267 |
-
|
| 268 |
result = self.service.run_step4_finalize(session)
|
| 269 |
-
current_session = result.get("session", session)
|
| 270 |
|
| 271 |
-
|
| 272 |
-
|
| 273 |
return (
|
| 274 |
-
|
| 275 |
-
gr.update(visible=True),
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
)
|
| 278 |
else:
|
| 279 |
-
default_map = create_animated_map()
|
| 280 |
-
agent_outputs = self._get_agent_outputs("team", "idle", "Error")
|
| 281 |
-
err = result.get("message", "Error")
|
| 282 |
-
return (
|
| 283 |
-
f"Error: {err}", "", "", default_map,
|
| 284 |
-
gr.update(), gr.update(), f"Error: {err}",
|
| 285 |
-
*agent_outputs, current_session.to_dict()
|
| 286 |
-
)
|
| 287 |
-
|
| 288 |
-
def save_settings(self, google_key, weather_key, gemini_key, model, session_data):
|
| 289 |
-
session = UserSession.from_dict(session_data)
|
| 290 |
-
session.custom_settings['google_maps_api_key'] = google_key
|
| 291 |
-
session.custom_settings['openweather_api_key'] = weather_key
|
| 292 |
-
session.custom_settings['gemini_api_key'] = gemini_key
|
| 293 |
-
session.custom_settings['model'] = model
|
| 294 |
-
new_status = self._check_api_status(session)
|
| 295 |
-
return "✅ Settings saved locally!", session.to_dict(), new_status
|
| 296 |
-
|
| 297 |
-
# -------------------------------------------------------------------------
|
| 298 |
-
# UI Builder
|
| 299 |
-
# -------------------------------------------------------------------------
|
| 300 |
-
def build_interface(self):
|
| 301 |
-
def reset_app(old_session_data):
|
| 302 |
-
old_session = UserSession.from_dict(old_session_data)
|
| 303 |
-
new_session = UserSession()
|
| 304 |
-
new_session.custom_settings = old_session.custom_settings
|
| 305 |
-
status_msg = self._check_api_status(new_session)
|
| 306 |
return (
|
| 307 |
gr.update(visible=True), gr.update(visible=False),
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
gr.update(visible=False), "",
|
| 311 |
-
create_agent_stream_output(),
|
| 312 |
-
status_msg,
|
| 313 |
-
"", "", "",
|
| 314 |
-
new_session.to_dict()
|
| 315 |
)
|
| 316 |
|
| 317 |
-
|
| 318 |
-
s = UserSession.from_dict(session_data)
|
| 319 |
-
return self._check_api_status(s)
|
| 320 |
|
| 321 |
-
|
|
|
|
| 322 |
gr.HTML(get_enhanced_css())
|
| 323 |
-
|
| 324 |
-
session_state = gr.State(
|
| 325 |
-
|
| 326 |
-
with gr.
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
with gr.
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
doc_modal, close_doc_btn = create_doc_modal()
|
| 350 |
|
| 351 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
|
| 353 |
-
# ---------------------------------------------------------------------
|
| 354 |
-
# Event 1: Analyze (Step 1)
|
| 355 |
-
# ---------------------------------------------------------------------
|
| 356 |
analyze_event = analyze_btn.click(
|
| 357 |
fn=self.analyze_wrapper,
|
| 358 |
-
inputs=[user_input,
|
| 359 |
-
outputs=[
|
| 360 |
-
|
| 361 |
-
reasoning_output, task_confirm_area, chat_input_area,
|
| 362 |
-
chat_history_output, status_bar, input_area,
|
| 363 |
-
*agent_displays, session_state
|
| 364 |
-
]
|
| 365 |
-
)
|
| 366 |
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
# ---------------------------------------------------------------------
|
| 370 |
-
chat_event = chat_send.click(
|
| 371 |
-
fn=self.chat_wrapper,
|
| 372 |
-
inputs=[chat_input, session_state],
|
| 373 |
-
outputs=[
|
| 374 |
-
chat_history_output, # 1. 對話框
|
| 375 |
-
task_list_display, # 2. 任務列表
|
| 376 |
-
task_summary_display, # 🔥 [Critical Fix] 3. 摘要卡片 (必須加這行!)
|
| 377 |
-
session_state # 4. Session
|
| 378 |
-
]
|
| 379 |
-
).then(fn=lambda: "", outputs=[chat_input])
|
| 380 |
-
|
| 381 |
-
# ---------------------------------------------------------------------
|
| 382 |
-
# Event 3: Planning Chain (Step 2 -> 3 -> 4)
|
| 383 |
-
# 使用 transition_to_planning 進行驗證,如果沒任務會自動停止
|
| 384 |
-
# ---------------------------------------------------------------------
|
| 385 |
-
step2_event = ready_plan_btn.click(
|
| 386 |
-
fn=self.transition_to_planning,
|
| 387 |
-
inputs=[session_state],
|
| 388 |
-
outputs=[
|
| 389 |
-
task_confirm_area,
|
| 390 |
-
chat_input_area,
|
| 391 |
-
team_area,
|
| 392 |
-
tabs,
|
| 393 |
-
report_tab, # 🔥 [新增] 這裡要接 report_tab
|
| 394 |
-
map_tab, # 🔥 [新增] 這裡要接 map_tab
|
| 395 |
-
reasoning_output,
|
| 396 |
-
status_bar,
|
| 397 |
-
*agent_displays,
|
| 398 |
-
session_state
|
| 399 |
-
]
|
| 400 |
-
)
|
| 401 |
|
| 402 |
-
|
|
|
|
|
|
|
|
|
|
| 403 |
fn=self.step3_wrapper,
|
| 404 |
inputs=[session_state],
|
| 405 |
-
outputs=[
|
| 406 |
-
|
| 407 |
-
reasoning_output, # 2. AI Conversation Log (這裡漏掉了!)
|
| 408 |
-
*agent_displays, # 3-8. Agent Grid (這裡也漏掉了!)
|
| 409 |
-
session_state # 9. Session
|
| 410 |
-
]
|
| 411 |
-
)
|
| 412 |
-
|
| 413 |
-
step3_ui_update = step3_event.then(
|
| 414 |
-
fn=lambda: (gr.update(visible=True), gr.update(visible=True), gr.update(selected="report_tab")),
|
| 415 |
-
outputs=[report_tab, map_tab, tabs]
|
| 416 |
)
|
| 417 |
|
| 418 |
-
|
| 419 |
fn=self.step4_wrapper,
|
| 420 |
inputs=[session_state],
|
|
|
|
| 421 |
outputs=[
|
| 422 |
-
|
| 423 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
]
|
| 425 |
-
).then(fn=lambda:
|
| 426 |
-
|
| 427 |
-
# ---------------------------------------------------------------------
|
| 428 |
-
# Reset & Cancels
|
| 429 |
-
# ---------------------------------------------------------------------
|
| 430 |
-
reset_outputs = [
|
| 431 |
-
input_area, task_confirm_area, chat_input_area, result_area,
|
| 432 |
-
team_area, report_tab, map_tab, user_input,
|
| 433 |
-
agent_stream_output, status_bar,
|
| 434 |
-
task_summary_display, task_list_display, chat_history_output,
|
| 435 |
-
session_state
|
| 436 |
-
]
|
| 437 |
-
|
| 438 |
-
home_btn.click(
|
| 439 |
-
fn=reset_app,
|
| 440 |
-
inputs=[session_state],
|
| 441 |
-
outputs=reset_outputs,
|
| 442 |
-
cancels=[analyze_event, step4_event, chat_event]
|
| 443 |
-
)
|
| 444 |
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
|
| 452 |
-
# Others
|
| 453 |
-
settings_btn.click(fn=lambda: gr.update(visible=True), outputs=[settings_modal])
|
| 454 |
-
close_settings_btn.click(fn=lambda: gr.update(visible=False), outputs=[settings_modal])
|
| 455 |
-
save_settings_btn.click(
|
| 456 |
-
fn=self.save_settings,
|
| 457 |
-
inputs=[google_maps_key, openweather_key, gemini_api_key, model_choice, session_state],
|
| 458 |
-
outputs=[settings_status, session_state, status_bar]
|
| 459 |
-
)
|
| 460 |
-
theme_btn.click(fn=None,
|
| 461 |
-
js="() => { document.querySelector('.gradio-container').classList.toggle('theme-dark'); }")
|
| 462 |
-
doc_btn.click(fn=lambda: gr.update(visible=True), outputs=[doc_modal])
|
| 463 |
-
close_doc_btn.click(fn=lambda: gr.update(visible=False), outputs=[doc_modal])
|
| 464 |
-
demo.load(fn=initial_check, inputs=[session_state], outputs=[status_bar])
|
| 465 |
return demo
|
| 466 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
|
| 468 |
def main():
|
| 469 |
app = LifeFlowAI()
|
| 470 |
demo = app.build_interface()
|
| 471 |
-
demo.launch(server_name="0.0.0.0", server_port=
|
| 472 |
-
|
| 473 |
|
| 474 |
if __name__ == "__main__":
|
| 475 |
main()
|
|
|
|
| 1 |
"""
|
| 2 |
+
LifeFlow AI - Main Application (Fixed Output Mismatch)
|
| 3 |
+
✅ 修復 Step 4 回傳值數量不匹配導致的 AttributeError
|
| 4 |
+
✅ 確保 outputs 清單包含所有 7 個元件
|
| 5 |
"""
|
| 6 |
|
| 7 |
import gradio as gr
|
| 8 |
+
import inspect
|
| 9 |
+
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from ui.theme import get_enhanced_css
|
| 11 |
+
from ui.components.header import create_header
|
| 12 |
+
from ui.components.progress_stepper import create_progress_stepper, update_stepper
|
| 13 |
+
from ui.components.input_form import create_input_form, toggle_location_inputs
|
| 14 |
+
from ui.components.modals import create_settings_modal, create_doc_modal
|
| 15 |
from ui.renderers import (
|
| 16 |
+
create_agent_dashboard,
|
| 17 |
create_summary_card,
|
| 18 |
+
create_task_card
|
|
|
|
|
|
|
| 19 |
)
|
| 20 |
+
from core.session import UserSession
|
| 21 |
+
from services.planner_service import PlannerService
|
| 22 |
from core.visualizers import create_animated_map
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
class LifeFlowAI:
|
| 25 |
def __init__(self):
|
| 26 |
self.service = PlannerService()
|
| 27 |
|
| 28 |
+
def _get_dashboard_html(self, active_agent: str = None, status: str = "idle", message: str = "Waiting") -> str:
|
| 29 |
+
agents = ['team', 'scout', 'optimizer', 'navigator', 'weatherman', 'presenter']
|
| 30 |
+
status_dict = {a: {'status': 'idle', 'message': 'Standby'} for a in agents}
|
| 31 |
+
if active_agent in status_dict:
|
| 32 |
+
status_dict[active_agent] = {'status': status, 'message': message}
|
| 33 |
+
if active_agent != 'team' and status == 'working':
|
| 34 |
+
status_dict['team'] = {'status': 'working', 'message': f'Monitoring {active_agent}...'}
|
| 35 |
+
return create_agent_dashboard(status_dict)
|
| 36 |
+
|
| 37 |
+
def _check_api_status(self, session_data):
|
| 38 |
+
session = UserSession.from_dict(session_data)
|
| 39 |
has_gemini = bool(session.custom_settings.get("gemini_api_key"))
|
| 40 |
has_google = bool(session.custom_settings.get("google_maps_api_key"))
|
| 41 |
+
msg = "✅ System Ready" if (has_gemini and has_google) else "⚠️ Missing API Keys"
|
| 42 |
+
return msg
|
| 43 |
|
| 44 |
+
def _get_gradio_chat_history(self, session):
|
| 45 |
+
history = []
|
| 46 |
+
for msg in session.chat_history:
|
| 47 |
+
history.append({"role": msg["role"], "content": msg["message"]})
|
| 48 |
+
return history
|
|
|
|
|
|
|
| 49 |
|
| 50 |
+
# ================= Event Wrappers =================
|
|
|
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
def analyze_wrapper(self, user_input, auto_loc, lat, lon, session_data):
|
| 53 |
session = UserSession.from_dict(session_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
iterator = self.service.run_step1_analysis(user_input, auto_loc, lat, lon, session)
|
|
|
|
| 55 |
for event in iterator:
|
| 56 |
evt_type = event.get("type")
|
|
|
|
|
|
|
| 57 |
current_session = event.get("session", session)
|
| 58 |
+
if evt_type == "error":
|
|
|
|
|
|
|
|
|
|
| 59 |
yield (
|
| 60 |
+
gr.update(), # step1
|
| 61 |
+
gr.update(), # step2
|
| 62 |
+
gr.update(), # step3
|
| 63 |
+
gr.HTML(f"<div style='color:red'>{event.get('message')}</div>"), # s1_stream
|
| 64 |
+
gr.update(), # task_list
|
| 65 |
+
gr.update(), # task_summary
|
| 66 |
+
gr.update(), # chatbot
|
| 67 |
+
current_session.to_dict() # state
|
| 68 |
)
|
| 69 |
+
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
+
if evt_type == "stream":
|
| 72 |
yield (
|
| 73 |
+
gr.update(visible=True),
|
| 74 |
+
gr.update(visible=False),
|
| 75 |
+
gr.update(visible=False),
|
| 76 |
+
event.get("stream_text", ""),
|
| 77 |
+
gr.update(),
|
| 78 |
+
gr.update(),
|
| 79 |
+
gr.update(),
|
| 80 |
+
current_session.to_dict()
|
| 81 |
)
|
| 82 |
|
| 83 |
+
elif evt_type == "complete":
|
| 84 |
+
tasks_html = self.service.generate_task_list_html(current_session)
|
| 85 |
+
date_str = datetime.now().strftime("%Y-%m-%d")
|
| 86 |
+
loc_str = "Selected Location"
|
| 87 |
+
if current_session.lat and current_session.lng:
|
| 88 |
+
loc_str = f"{current_session.lat:.2f}, {current_session.lng:.2f}"
|
| 89 |
+
|
| 90 |
+
summary_html = create_summary_card(len(current_session.task_list), event.get("high_priority", 0), event.get("total_time", 0), location=loc_str, date=date_str)
|
| 91 |
+
chat_history = self._get_gradio_chat_history(current_session)
|
| 92 |
+
if not chat_history:
|
| 93 |
+
chat_history = [
|
| 94 |
+
{"role": "assistant", "content": event.get('stream_text', "")},
|
| 95 |
+
{"role": "assistant", "content": "Hi! I'm LifeFlow. Tell me if you want to change priorities, add stops, or adjust times."}]
|
| 96 |
+
current_session.chat_history.append({"role": "assistant", "message": "Hi! I'm LifeFlow...", "time": ""})
|
| 97 |
|
| 98 |
yield (
|
| 99 |
+
gr.update(visible=False), # Hide S1
|
| 100 |
+
gr.update(visible=True), # Show S2
|
| 101 |
+
gr.update(visible=False), # Hide S3
|
| 102 |
+
"", # Clear Stream
|
| 103 |
+
gr.HTML(tasks_html),
|
| 104 |
+
gr.HTML(summary_html),
|
| 105 |
+
chat_history,
|
| 106 |
+
current_session.to_dict()
|
| 107 |
)
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
def chat_wrapper(self, msg, session_data):
|
| 110 |
session = UserSession.from_dict(session_data)
|
| 111 |
iterator = self.service.modify_task_chat(msg, session)
|
|
|
|
| 112 |
for event in iterator:
|
| 113 |
+
sess = event.get("session", session)
|
| 114 |
+
tasks_html = self.service.generate_task_list_html(sess)
|
| 115 |
+
summary_html = event.get("summary_html", gr.HTML())
|
| 116 |
+
gradio_history = self._get_gradio_chat_history(sess)
|
| 117 |
+
yield (gradio_history, tasks_html, summary_html, sess.to_dict())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
+
def step3_wrapper(self, session_data):
|
|
|
|
|
|
|
|
|
|
| 120 |
session = UserSession.from_dict(session_data)
|
| 121 |
|
| 122 |
+
# Init variables
|
| 123 |
+
log_content = ""
|
| 124 |
+
report_content = ""
|
|
|
|
| 125 |
|
| 126 |
+
tasks_html = self.service.generate_task_list_html(session)
|
| 127 |
+
init_dashboard = self._get_dashboard_html('team', 'working', 'Initializing...')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
+
# HTML Content (No Indentation)
|
| 130 |
+
loading_html = inspect.cleandoc("""
|
| 131 |
+
<div style="text-align: center; padding: 60px 20px; color: #64748b;">
|
| 132 |
+
<div style="font-size: 48px; margin-bottom: 20px; animation: pulse 1.5s infinite;">🧠</div>
|
| 133 |
+
<div style="font-size: 1.2rem; font-weight: 600; color: #334155;">AI Team is analyzing your request...</div>
|
| 134 |
+
<div style="font-size: 0.9rem; margin-top: 8px;">Checking routes, weather, and optimizing schedule.</div>
|
| 135 |
+
</div>
|
| 136 |
+
<style>@keyframes pulse { 0% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.1); opacity: 0.7; } 100% { transform: scale(1); opacity: 1; } }</style>
|
| 137 |
+
""")
|
| 138 |
+
|
| 139 |
+
init_log = '<div style="padding: 10px; color: #94a3b8; font-style: italic;">Waiting for agents...</div>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
+
yield (init_dashboard, init_log, loading_html, tasks_html, session.to_dict())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
iterator = self.service.run_step3_team(session)
|
|
|
|
| 144 |
|
| 145 |
for event in iterator:
|
| 146 |
+
sess = event.get("session", session)
|
| 147 |
evt_type = event.get("type")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
|
|
|
| 149 |
if evt_type == "report_stream":
|
| 150 |
+
report_content = event.get("content", "")
|
| 151 |
+
|
| 152 |
+
if evt_type == "reasoning_update":
|
| 153 |
+
agent, status, msg = event.get("agent_status")
|
| 154 |
+
time_str = datetime.now().strftime('%H:%M:%S')
|
| 155 |
+
log_entry = f"""
|
| 156 |
+
<div style="margin-bottom: 8px; border-left: 3px solid #6366f1; padding-left: 10px;">
|
| 157 |
+
<div style="font-size: 0.75rem; color: #94a3b8;">{time_str} • {agent.upper()}</div>
|
| 158 |
+
<div style="color: #334155; font-size: 0.9rem;">{msg}</div>
|
| 159 |
+
</div>
|
| 160 |
+
"""
|
| 161 |
+
log_content = log_entry + log_content
|
| 162 |
+
dashboard_html = self._get_dashboard_html(agent, status, msg)
|
| 163 |
+
log_html = f'<div style="height: 500px; overflow-y: auto; padding: 10px; background: #fff;">{log_content}</div>'
|
| 164 |
+
current_report = report_content + "\n\n" if report_content else loading_html
|
| 165 |
+
|
| 166 |
+
yield (dashboard_html, log_html, current_report, tasks_html, sess.to_dict())
|
| 167 |
+
|
| 168 |
+
if evt_type == "complete":
|
| 169 |
+
final_report = event.get("report_html", report_content)
|
| 170 |
+
final_log = f"""
|
| 171 |
+
<div style="margin-bottom: 8px; border-left: 3px solid #10b981; padding-left: 10px; background: #ecfdf5; padding: 10px;">
|
| 172 |
+
<div style="font-weight: bold; color: #059669;">✅ Planning Completed</div>
|
| 173 |
+
</div>
|
| 174 |
+
{log_content}
|
| 175 |
+
"""
|
| 176 |
+
final_log_html = f'<div style="height: 500px; overflow-y: auto; padding: 10px; background: #fff;">{final_log}</div>'
|
| 177 |
+
|
| 178 |
+
yield (
|
| 179 |
+
self._get_dashboard_html('team', 'complete', 'Planning Finished'),
|
| 180 |
+
final_log_html,
|
| 181 |
+
final_report,
|
| 182 |
+
tasks_html,
|
| 183 |
+
sess.to_dict()
|
| 184 |
+
)
|
| 185 |
|
|
|
|
|
|
|
|
|
|
| 186 |
def step4_wrapper(self, session_data):
|
| 187 |
session = UserSession.from_dict(session_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
result = self.service.run_step4_finalize(session)
|
|
|
|
| 189 |
|
| 190 |
+
# 🔥 Wrapper 回傳 7 個值
|
| 191 |
+
if result['type'] == 'success':
|
| 192 |
return (
|
| 193 |
+
gr.update(visible=False), # 1. Step 3 Hide
|
| 194 |
+
gr.update(visible=True), # 2. Step 4 Show
|
| 195 |
+
result['summary_tab_html'], # 3. Summary Tab HTML
|
| 196 |
+
result['report_md'], # 4. Report Tab Markdown
|
| 197 |
+
result['task_list_html'], # 5. Task List HTML
|
| 198 |
+
result['map_fig'], # 6. Map Plot
|
| 199 |
+
session.to_dict() # 7. Session State
|
| 200 |
)
|
| 201 |
else:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
return (
|
| 203 |
gr.update(visible=True), gr.update(visible=False),
|
| 204 |
+
"", "", "", None,
|
| 205 |
+
session.to_dict()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
)
|
| 207 |
|
| 208 |
+
# ================= Main UI Builder =================
|
|
|
|
|
|
|
| 209 |
|
| 210 |
+
def build_interface(self):
|
| 211 |
+
with gr.Blocks(title="LifeFlow AI", css=".gradio-container {max-width: 100% !important; padding: 0;}") as demo:
|
| 212 |
gr.HTML(get_enhanced_css())
|
| 213 |
+
|
| 214 |
+
session_state = gr.State(UserSession().to_dict())
|
| 215 |
+
|
| 216 |
+
with gr.Column(elem_classes="step-container"):
|
| 217 |
+
home_btn, theme_btn, settings_btn, doc_btn = create_header()
|
| 218 |
+
stepper = create_progress_stepper(1)
|
| 219 |
+
status_bar = gr.Markdown("Ready", visible=False)
|
| 220 |
+
|
| 221 |
+
# STEP 1
|
| 222 |
+
with gr.Group(visible=True, elem_classes="step-container centered-input-container") as step1_container:
|
| 223 |
+
(input_area, s1_stream_output, user_input, auto_loc, loc_group, lat_in, lon_in, analyze_btn) = create_input_form("")
|
| 224 |
+
|
| 225 |
+
# STEP 2
|
| 226 |
+
with gr.Group(visible=False, elem_classes="step-container") as step2_container:
|
| 227 |
+
gr.Markdown("### ✅ Review & Refine Tasks")
|
| 228 |
+
with gr.Row(elem_classes="step2-split-view"):
|
| 229 |
+
with gr.Column(scale=1):
|
| 230 |
+
with gr.Group(elem_classes="panel-container"):
|
| 231 |
+
gr.HTML('<div class="panel-header">📋 Your Tasks</div>')
|
| 232 |
+
with gr.Group(elem_classes="scrollable-content"):
|
| 233 |
+
task_summary_box = gr.HTML()
|
| 234 |
+
task_list_box = gr.HTML()
|
| 235 |
+
with gr.Column(scale=1):
|
| 236 |
+
with gr.Group(elem_classes="panel-container chat-panel-native"):
|
| 237 |
+
chatbot = gr.Chatbot(label="AI Assistant", type="messages", height=540, elem_classes="native-chatbot", bubble_full_width=False)
|
| 238 |
+
with gr.Row(elem_classes="chat-input-row"):
|
| 239 |
+
chat_input = gr.Textbox(show_label=False, placeholder="Type to modify tasks...", container=False, scale=5, autofocus=True)
|
| 240 |
+
chat_send = gr.Button("➤", variant="primary", scale=1, min_width=50)
|
| 241 |
+
with gr.Row(elem_classes="action-footer"):
|
| 242 |
+
back_btn = gr.Button("← Back", variant="secondary", scale=1)
|
| 243 |
+
plan_btn = gr.Button("🚀 Start Planning", variant="primary", scale=2)
|
| 244 |
+
|
| 245 |
+
# STEP 3
|
| 246 |
+
with gr.Group(visible=False, elem_classes="step-container") as step3_container:
|
| 247 |
+
gr.Markdown("### 🤖 AI Team Operations")
|
| 248 |
+
with gr.Group(elem_classes="agent-dashboard-container"):
|
| 249 |
+
agent_dashboard = gr.HTML(value=self._get_dashboard_html())
|
| 250 |
+
|
| 251 |
+
with gr.Row():
|
| 252 |
+
with gr.Column(scale=2):
|
| 253 |
+
with gr.Tabs():
|
| 254 |
+
with gr.Tab("📝 Full Report"):
|
| 255 |
+
with gr.Group(elem_classes="live-report-wrapper"):
|
| 256 |
+
live_report_md = gr.Markdown()
|
| 257 |
+
with gr.Tab("📋 Task List"):
|
| 258 |
+
with gr.Group(elem_classes="panel-container"):
|
| 259 |
+
with gr.Group(elem_classes="scrollable-content"):
|
| 260 |
+
task_list_s3 = gr.HTML()
|
| 261 |
+
with gr.Column(scale=1):
|
| 262 |
+
gr.Markdown("### ⚡ Activity Log")
|
| 263 |
+
with gr.Group(elem_classes="panel-container"):
|
| 264 |
+
planning_log = gr.HTML(value="Waiting...")
|
| 265 |
+
|
| 266 |
+
# STEP 4
|
| 267 |
+
with gr.Group(visible=False, elem_classes="step-container") as step4_container:
|
| 268 |
+
with gr.Row():
|
| 269 |
+
# Left: Tabs (Summary / Report / Tasks)
|
| 270 |
+
with gr.Column(scale=1, elem_classes="split-left-panel"):
|
| 271 |
+
with gr.Tabs():
|
| 272 |
+
with gr.Tab("📊 Summary"):
|
| 273 |
+
summary_tab_output = gr.HTML()
|
| 274 |
+
|
| 275 |
+
with gr.Tab("📝 Full Report"):
|
| 276 |
+
report_tab_output = gr.Markdown()
|
| 277 |
+
|
| 278 |
+
with gr.Tab("📋 Task List"):
|
| 279 |
+
task_list_tab_output = gr.HTML()
|
| 280 |
+
|
| 281 |
+
# Right: Hero Map
|
| 282 |
+
with gr.Column(scale=2, elem_classes="split-right-panel"):
|
| 283 |
+
map_view = gr.Plot(label="Route Map", show_label=False)
|
| 284 |
+
|
| 285 |
+
# Modals & Events
|
| 286 |
+
(settings_modal, g_key, w_key, gem_key, model_sel, close_set, save_set, set_stat) = create_settings_modal()
|
| 287 |
doc_modal, close_doc_btn = create_doc_modal()
|
| 288 |
|
| 289 |
+
settings_btn.click(fn=lambda: gr.update(visible=True), outputs=[settings_modal])
|
| 290 |
+
close_set.click(fn=lambda: gr.update(visible=False), outputs=[settings_modal])
|
| 291 |
+
doc_btn.click(fn=lambda: gr.update(visible=True), outputs=[doc_modal])
|
| 292 |
+
close_doc_btn.click(fn=lambda: gr.update(visible=False), outputs=[doc_modal])
|
| 293 |
+
theme_btn.click(fn=None, js="() => { document.querySelector('.gradio-container').classList.toggle('theme-dark'); }")
|
| 294 |
|
|
|
|
|
|
|
|
|
|
| 295 |
analyze_event = analyze_btn.click(
|
| 296 |
fn=self.analyze_wrapper,
|
| 297 |
+
inputs=[user_input, auto_loc, lat_in, lon_in, session_state],
|
| 298 |
+
outputs=[step1_container, step2_container, step3_container, s1_stream_output, task_list_box, task_summary_box, chatbot, session_state]
|
| 299 |
+
).then(fn=lambda: update_stepper(2), outputs=[stepper])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
+
chat_send.click(fn=self.chat_wrapper, inputs=[chat_input, session_state], outputs=[chatbot, task_list_box, task_summary_box, session_state]).then(fn=lambda: "", outputs=[chat_input])
|
| 302 |
+
chat_input.submit(fn=self.chat_wrapper, inputs=[chat_input, session_state], outputs=[chatbot, task_list_box, task_summary_box, session_state]).then(fn=lambda: "", outputs=[chat_input])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
+
step3_start = plan_btn.click(
|
| 305 |
+
fn=lambda: (gr.update(visible=False), gr.update(visible=True), update_stepper(3)),
|
| 306 |
+
outputs=[step2_container, step3_container, stepper]
|
| 307 |
+
).then(
|
| 308 |
fn=self.step3_wrapper,
|
| 309 |
inputs=[session_state],
|
| 310 |
+
outputs=[agent_dashboard, planning_log, live_report_md, task_list_s3, session_state],
|
| 311 |
+
show_progress="hidden"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
)
|
| 313 |
|
| 314 |
+
step3_start.then(
|
| 315 |
fn=self.step4_wrapper,
|
| 316 |
inputs=[session_state],
|
| 317 |
+
# 🔥🔥🔥 關鍵修正:這裡列出了 7 個 Outputs,必須對應 Wrapper 回傳的 7 個值
|
| 318 |
outputs=[
|
| 319 |
+
step3_container, # 1. Hide Step 3
|
| 320 |
+
step4_container, # 2. Show Step 4
|
| 321 |
+
summary_tab_output, # 3. Summary Tab
|
| 322 |
+
report_tab_output, # 4. Report Tab
|
| 323 |
+
task_list_tab_output, # 5. Task List Tab
|
| 324 |
+
map_view, # 6. Map
|
| 325 |
+
session_state # 7. State
|
| 326 |
]
|
| 327 |
+
).then(fn=lambda: update_stepper(4), outputs=[stepper])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
|
| 329 |
+
def reset_all(session_data):
|
| 330 |
+
old_session = UserSession.from_dict(session_data)
|
| 331 |
+
new_session = UserSession()
|
| 332 |
+
new_session.custom_settings = old_session.custom_settings
|
| 333 |
+
return (gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), update_stepper(1), new_session.to_dict(), "")
|
| 334 |
+
|
| 335 |
+
home_btn.click(fn=reset_all, inputs=[session_state], outputs=[step1_container, step2_container, step3_container, step4_container, stepper, session_state, user_input])
|
| 336 |
+
back_btn.click(fn=reset_all, inputs=[session_state], outputs=[step1_container, step2_container, step3_container, step4_container, stepper, session_state, user_input])
|
| 337 |
+
|
| 338 |
+
save_set.click(fn=self.save_settings, inputs=[g_key, w_key, gem_key, model_sel, session_state], outputs=[set_stat, session_state, status_bar])
|
| 339 |
+
auto_loc.change(fn=toggle_location_inputs, inputs=auto_loc, outputs=loc_group)
|
| 340 |
+
demo.load(fn=self._check_api_status, inputs=[session_state], outputs=[status_bar])
|
| 341 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
return demo
|
| 343 |
|
| 344 |
+
def save_settings(self, g, w, gem, m, s_data):
|
| 345 |
+
sess = UserSession.from_dict(s_data)
|
| 346 |
+
sess.custom_settings.update({'google_maps_api_key': g, 'openweather_api_key': w, 'gemini_api_key': gem, 'model': m})
|
| 347 |
+
return "✅ Saved", sess.to_dict(), "Settings Updated"
|
| 348 |
|
| 349 |
def main():
|
| 350 |
app = LifeFlowAI()
|
| 351 |
demo = app.build_interface()
|
| 352 |
+
demo.launch(server_name="0.0.0.0", server_port=8080, share=True, show_error=True)
|
|
|
|
| 353 |
|
| 354 |
if __name__ == "__main__":
|
| 355 |
main()
|
core/session.py
CHANGED
|
@@ -1,22 +1,13 @@
|
|
| 1 |
"""
|
| 2 |
LifeFlow AI - Session Management
|
| 3 |
-
|
| 4 |
"""
|
| 5 |
import uuid
|
| 6 |
from typing import Dict, Any, Optional
|
| 7 |
-
from src.agent.base import UserState
|
| 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
|
|
@@ -27,24 +18,22 @@ class UserSession:
|
|
| 27 |
self.chat_history: list = []
|
| 28 |
self.planning_completed: bool = False
|
| 29 |
|
| 30 |
-
#
|
|
|
|
|
|
|
| 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
|
|
@@ -52,13 +41,13 @@ class UserSession:
|
|
| 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', {})
|
|
|
|
| 1 |
"""
|
| 2 |
LifeFlow AI - Session Management
|
| 3 |
+
✅ 新增 final_report 欄位,用於在 Step 3 和 Step 4 之間傳遞報告內容
|
| 4 |
"""
|
| 5 |
import uuid
|
| 6 |
from typing import Dict, Any, Optional
|
| 7 |
+
from src.agent.base import UserState
|
|
|
|
| 8 |
from src.infra.config import get_settings
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
class UserSession:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
def __init__(self):
|
| 12 |
self.session_id: Optional[str] = None
|
| 13 |
self.planner_agent = None
|
|
|
|
| 18 |
self.chat_history: list = []
|
| 19 |
self.planning_completed: bool = False
|
| 20 |
|
| 21 |
+
# 🔥 新增:儲存 Step 3 生成的完整報告
|
| 22 |
+
self.final_report: str = ""
|
| 23 |
+
|
| 24 |
self.lat: Optional[float] = None
|
| 25 |
self.lng: Optional[float] = None
|
|
|
|
|
|
|
| 26 |
self.custom_settings: Dict[str, Any] = {}
|
|
|
|
|
|
|
| 27 |
self.agno_settings = get_settings()
|
| 28 |
|
| 29 |
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
| 30 |
return {
|
| 31 |
'session_id': self.session_id,
|
| 32 |
'task_list': self.task_list,
|
| 33 |
'reasoning_messages': self.reasoning_messages,
|
| 34 |
'chat_history': self.chat_history,
|
| 35 |
'planning_completed': self.planning_completed,
|
| 36 |
+
'final_report': self.final_report, # 🔥 序列化
|
| 37 |
'lat': self.lat,
|
| 38 |
'lng': self.lng,
|
| 39 |
'custom_settings': self.custom_settings
|
|
|
|
| 41 |
|
| 42 |
@classmethod
|
| 43 |
def from_dict(cls, data: Dict[str, Any]) -> 'UserSession':
|
|
|
|
| 44 |
session = cls()
|
| 45 |
session.session_id = data.get('session_id')
|
| 46 |
session.task_list = data.get('task_list', [])
|
| 47 |
session.reasoning_messages = data.get('reasoning_messages', [])
|
| 48 |
session.chat_history = data.get('chat_history', [])
|
| 49 |
session.planning_completed = data.get('planning_completed', False)
|
| 50 |
+
session.final_report = data.get('final_report', "") # 🔥 反序列化
|
| 51 |
session.lat = data.get('lat')
|
| 52 |
session.lng = data.get('lng')
|
| 53 |
session.custom_settings = data.get('custom_settings', {})
|
core/visualizers.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
-
LifeFlow AI - Visualizers
|
| 3 |
-
|
|
|
|
| 4 |
"""
|
| 5 |
import plotly.graph_objects as go
|
| 6 |
from core.helpers import decode_polyline
|
|
@@ -10,57 +11,49 @@ logger = get_logger(__name__)
|
|
| 10 |
|
| 11 |
def create_animated_map(structured_data=None):
|
| 12 |
"""
|
| 13 |
-
|
| 14 |
-
|
| 15 |
"""
|
|
|
|
| 16 |
fig = go.Figure()
|
| 17 |
|
| 18 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
if not structured_data:
|
| 20 |
-
|
| 21 |
-
default_lons = [121.565, 121.560]
|
| 22 |
fig.add_trace(go.Scattermapbox(
|
| 23 |
-
lat=
|
| 24 |
-
mode='markers', marker=dict(size=
|
| 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 |
-
#
|
| 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 |
-
|
| 43 |
-
|
| 44 |
-
|
| 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 |
-
|
| 57 |
-
|
| 58 |
-
line=dict(width=4, color='#4A90E2'),
|
| 59 |
-
name='Route',
|
| 60 |
-
hoverinfo='none'
|
| 61 |
))
|
| 62 |
|
| 63 |
-
#
|
| 64 |
lats, lons, hover_texts, colors, sizes = [], [], [], [], []
|
| 65 |
|
| 66 |
for i, stop in enumerate(timeline):
|
|
@@ -72,56 +65,48 @@ def create_animated_map(structured_data=None):
|
|
| 72 |
lats.append(lat)
|
| 73 |
lons.append(lng)
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
time_str = stop.get("time", "")
|
| 78 |
weather = stop.get("weather", "")
|
| 79 |
-
|
|
|
|
| 80 |
hover_texts.append(text)
|
| 81 |
|
| 82 |
-
#
|
| 83 |
if i == 0:
|
| 84 |
-
colors.append('#
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
colors.append('#FF6B6B') # End
|
| 88 |
-
sizes.append(15)
|
| 89 |
else:
|
| 90 |
-
colors.append('#
|
| 91 |
-
|
| 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"
|
| 114 |
-
|
| 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=
|
| 122 |
),
|
| 123 |
margin=dict(l=0, r=0, t=0, b=0),
|
| 124 |
-
height=
|
| 125 |
showlegend=False
|
| 126 |
)
|
| 127 |
|
|
|
|
| 1 |
"""
|
| 2 |
+
LifeFlow AI - Visualizers (Fixed)
|
| 3 |
+
✅ 確保 create_animated_map 永遠回傳 go.Figure 物件
|
| 4 |
+
✅ 防止回傳字串導致 Gradio 崩潰
|
| 5 |
"""
|
| 6 |
import plotly.graph_objects as go
|
| 7 |
from core.helpers import decode_polyline
|
|
|
|
| 11 |
|
| 12 |
def create_animated_map(structured_data=None):
|
| 13 |
"""
|
| 14 |
+
生成地圖物件。
|
| 15 |
+
注意:必須回傳 go.Figure(),絕對不能回傳字串!
|
| 16 |
"""
|
| 17 |
+
# 1. 初始化一個空地圖 (預設值)
|
| 18 |
fig = go.Figure()
|
| 19 |
|
| 20 |
+
# 設定預設中心點 (台北101)
|
| 21 |
+
center_lat, center_lon = 25.033, 121.565
|
| 22 |
+
zoom_level = 13
|
| 23 |
+
|
| 24 |
+
# 2. 如果沒有數據,直接回傳空地圖
|
| 25 |
if not structured_data:
|
| 26 |
+
# 加一個透明點定住中心
|
|
|
|
| 27 |
fig.add_trace(go.Scattermapbox(
|
| 28 |
+
lat=[center_lat], lon=[center_lon],
|
| 29 |
+
mode='markers', marker=dict(size=0, opacity=0)
|
|
|
|
| 30 |
))
|
|
|
|
| 31 |
else:
|
| 32 |
try:
|
| 33 |
timeline = structured_data.get("timeline", [])
|
| 34 |
precise_result = structured_data.get("precise_traffic_result", {})
|
| 35 |
legs = precise_result.get("legs", [])
|
| 36 |
|
| 37 |
+
# --- 繪製路線 (Polyline) ---
|
| 38 |
+
route_lats, route_lons = [], []
|
|
|
|
|
|
|
| 39 |
for leg in legs:
|
| 40 |
poly_str = leg.get("polyline")
|
| 41 |
if poly_str:
|
| 42 |
decoded = decode_polyline(poly_str)
|
| 43 |
+
route_lats.extend([c[0] for c in decoded])
|
| 44 |
+
route_lons.extend([c[1] for c in decoded])
|
| 45 |
+
# 斷開不同路段
|
| 46 |
+
route_lats.append(None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
route_lons.append(None)
|
| 48 |
|
| 49 |
if route_lats:
|
| 50 |
fig.add_trace(go.Scattermapbox(
|
| 51 |
+
lat=route_lats, lon=route_lons, mode='lines',
|
| 52 |
+
line=dict(width=5, color='#6366f1'), # Primary Purple
|
| 53 |
+
name='Route', hoverinfo='none'
|
|
|
|
|
|
|
|
|
|
| 54 |
))
|
| 55 |
|
| 56 |
+
# --- 繪製站點 (Markers) ---
|
| 57 |
lats, lons, hover_texts, colors, sizes = [], [], [], [], []
|
| 58 |
|
| 59 |
for i, stop in enumerate(timeline):
|
|
|
|
| 65 |
lats.append(lat)
|
| 66 |
lons.append(lng)
|
| 67 |
|
| 68 |
+
name = stop.get("location", f"Stop {i}")
|
| 69 |
+
time = stop.get("time", "")
|
|
|
|
| 70 |
weather = stop.get("weather", "")
|
| 71 |
+
|
| 72 |
+
text = f"<b>{i+1}. {name}</b><br>🕒 {time}<br>🌤️ {weather}"
|
| 73 |
hover_texts.append(text)
|
| 74 |
|
| 75 |
+
# 顏色邏輯:起點綠、終點紅、中間黃
|
| 76 |
if i == 0:
|
| 77 |
+
colors.append('#10b981'); sizes.append(20)
|
| 78 |
+
elif i == len(timeline)-1:
|
| 79 |
+
colors.append('#ef4444'); sizes.append(20)
|
|
|
|
|
|
|
| 80 |
else:
|
| 81 |
+
colors.append('#f59e0b'); sizes.append(15)
|
| 82 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
if lats:
|
| 84 |
+
fig.add_trace(go.Scattermapbox(
|
| 85 |
+
lat=lats, lon=lons, mode='markers+text',
|
| 86 |
+
marker=dict(size=sizes, color=colors, allowoverlap=True),
|
| 87 |
+
text=[str(i+1) for i in range(len(lats))],
|
| 88 |
+
textposition="top center",
|
| 89 |
+
textfont=dict(size=14, color='black', family="Arial Black"),
|
| 90 |
+
hovertext=hover_texts, hoverinfo='text', name='Stops'
|
| 91 |
+
))
|
| 92 |
+
|
| 93 |
+
# 更新中心點為路徑中心
|
| 94 |
center_lat = sum(lats) / len(lats)
|
| 95 |
center_lon = sum(lons) / len(lons)
|
|
|
|
|
|
|
| 96 |
|
| 97 |
except Exception as e:
|
| 98 |
+
logger.error(f"Map generation error: {e}", exc_info=True)
|
| 99 |
+
# 發生錯誤時,保持回傳預設的 fig,不要讓程式崩潰
|
| 100 |
|
| 101 |
+
# 3. 設定地圖樣式
|
| 102 |
fig.update_layout(
|
| 103 |
mapbox=dict(
|
| 104 |
style='open-street-map',
|
| 105 |
center=dict(lat=center_lat, lon=center_lon),
|
| 106 |
+
zoom=zoom_level
|
| 107 |
),
|
| 108 |
margin=dict(l=0, r=0, t=0, b=0),
|
| 109 |
+
height=700, # 地圖高度
|
| 110 |
showlegend=False
|
| 111 |
)
|
| 112 |
|
services/planner_service.py
CHANGED
|
@@ -12,15 +12,10 @@ from contextlib import contextmanager
|
|
| 12 |
# 導入 Core & UI 模組
|
| 13 |
from core.session import UserSession
|
| 14 |
from ui.renderers import (
|
| 15 |
-
create_agent_stream_output,
|
| 16 |
-
create_agent_card_enhanced,
|
| 17 |
create_task_card,
|
| 18 |
create_summary_card,
|
| 19 |
create_timeline_html_enhanced,
|
| 20 |
-
generate_chat_history_html_bubble,
|
| 21 |
-
get_reasoning_html_reversed,
|
| 22 |
create_result_visualization,
|
| 23 |
-
create_metrics_cards
|
| 24 |
)
|
| 25 |
from core.visualizers import create_animated_map
|
| 26 |
|
|
@@ -422,10 +417,15 @@ class PlannerService:
|
|
| 422 |
high_priority = sum(1 for t in session.task_list if t.get("priority") == "HIGH")
|
| 423 |
total_time = sum(int(t.get("duration", "0").split()[0]) for t in session.task_list if t.get("duration"))
|
| 424 |
summary_html = create_summary_card(len(session.task_list), high_priority, total_time)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
|
| 426 |
session.chat_history[-1] = {
|
| 427 |
"role": "assistant",
|
| 428 |
-
"message":
|
| 429 |
"time": datetime.now().strftime("%H:%M:%S")
|
| 430 |
}
|
| 431 |
self._add_reasoning(session, "planner", f"Updated: {user_message[:30]}...")
|
|
@@ -554,7 +554,8 @@ class PlannerService:
|
|
| 554 |
logger.info(f"Run time (s): {time.perf_counter() - start_time}")
|
| 555 |
|
| 556 |
# 迴圈結束
|
| 557 |
-
report_html = f"## 🎯 Planning Complete\n\n{report_content}"
|
|
|
|
| 558 |
yield {
|
| 559 |
"type": "complete",
|
| 560 |
"report_html": report_html,
|
|
@@ -571,36 +572,105 @@ class PlannerService:
|
|
| 571 |
def run_step4_finalize(self, session: UserSession) -> Dict[str, Any]:
|
| 572 |
try:
|
| 573 |
session = self._get_live_session(session)
|
| 574 |
-
|
| 575 |
final_ref_id = poi_repo.get_last_id_by_session(session.session_id)
|
| 576 |
-
if not final_ref_id:
|
| 577 |
-
raise ValueError(f"No final result found , {session.session_id}")
|
| 578 |
|
| 579 |
structured_data = poi_repo.load(final_ref_id)
|
| 580 |
|
| 581 |
-
# 1. Timeline
|
| 582 |
timeline_html = create_timeline_html_enhanced(structured_data.get("timeline", []))
|
| 583 |
|
| 584 |
-
# 2. Metrics
|
| 585 |
metrics = structured_data.get("metrics", {})
|
| 586 |
traffic = structured_data.get("traffic_summary", {})
|
| 587 |
-
metrics_html = create_metrics_cards(metrics, traffic)
|
| 588 |
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 593 |
map_fig = create_animated_map(structured_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 594 |
|
| 595 |
-
self._add_reasoning(session, "team", "🎉 All completed")
|
| 596 |
session.planning_completed = True
|
| 597 |
|
| 598 |
return {
|
| 599 |
"type": "success",
|
| 600 |
-
"
|
| 601 |
-
"
|
| 602 |
-
"
|
| 603 |
-
"map_fig": map_fig,
|
| 604 |
"session": session
|
| 605 |
}
|
| 606 |
|
|
|
|
| 12 |
# 導入 Core & UI 模組
|
| 13 |
from core.session import UserSession
|
| 14 |
from ui.renderers import (
|
|
|
|
|
|
|
| 15 |
create_task_card,
|
| 16 |
create_summary_card,
|
| 17 |
create_timeline_html_enhanced,
|
|
|
|
|
|
|
| 18 |
create_result_visualization,
|
|
|
|
| 19 |
)
|
| 20 |
from core.visualizers import create_animated_map
|
| 21 |
|
|
|
|
| 417 |
high_priority = sum(1 for t in session.task_list if t.get("priority") == "HIGH")
|
| 418 |
total_time = sum(int(t.get("duration", "0").split()[0]) for t in session.task_list if t.get("duration"))
|
| 419 |
summary_html = create_summary_card(len(session.task_list), high_priority, total_time)
|
| 420 |
+
#
|
| 421 |
+
|
| 422 |
+
done_message = session.chat_history[-1]["message"].replace(
|
| 423 |
+
"🤔 AI is thinking...",
|
| 424 |
+
"✅ Tasks updated based on your request")
|
| 425 |
|
| 426 |
session.chat_history[-1] = {
|
| 427 |
"role": "assistant",
|
| 428 |
+
"message": done_message,
|
| 429 |
"time": datetime.now().strftime("%H:%M:%S")
|
| 430 |
}
|
| 431 |
self._add_reasoning(session, "planner", f"Updated: {user_message[:30]}...")
|
|
|
|
| 554 |
logger.info(f"Run time (s): {time.perf_counter() - start_time}")
|
| 555 |
|
| 556 |
# 迴圈結束
|
| 557 |
+
session.final_report = report_html = f"## 🎯 Planning Complete\n\n{report_content}"
|
| 558 |
+
|
| 559 |
yield {
|
| 560 |
"type": "complete",
|
| 561 |
"report_html": report_html,
|
|
|
|
| 572 |
def run_step4_finalize(self, session: UserSession) -> Dict[str, Any]:
|
| 573 |
try:
|
| 574 |
session = self._get_live_session(session)
|
|
|
|
| 575 |
final_ref_id = poi_repo.get_last_id_by_session(session.session_id)
|
| 576 |
+
if not final_ref_id: raise ValueError(f"No results found")
|
|
|
|
| 577 |
|
| 578 |
structured_data = poi_repo.load(final_ref_id)
|
| 579 |
|
| 580 |
+
# 1. Timeline
|
| 581 |
timeline_html = create_timeline_html_enhanced(structured_data.get("timeline", []))
|
| 582 |
|
| 583 |
+
# 2. Summary Card & Metrics (快樂表)
|
| 584 |
metrics = structured_data.get("metrics", {})
|
| 585 |
traffic = structured_data.get("traffic_summary", {})
|
|
|
|
| 586 |
|
| 587 |
+
print("metrics\n", metrics)
|
| 588 |
+
|
| 589 |
+
# 🔥 組合 Summary Tab 的內容
|
| 590 |
+
# 包含: 總結卡片 + 演算法快樂表 + 時間軸
|
| 591 |
+
task_count = len(session.task_list)
|
| 592 |
+
high_prio = sum(1 for t in session.task_list if t.get("priority") == "HIGH")
|
| 593 |
+
total_time = traffic.get("total_duration_min", 0)
|
| 594 |
+
|
| 595 |
+
summary_card = create_summary_card(task_count, high_prio, int(total_time))
|
| 596 |
+
metrics_viz = create_result_visualization(session.task_list, structured_data)
|
| 597 |
+
|
| 598 |
+
# 將三個元件合併為一個 HTML (用於 Summary Tab)
|
| 599 |
+
# 順序: Card -> Viz -> Timeline
|
| 600 |
+
full_summary_html = f"{summary_card}<br/>{metrics_viz}<br/><h3>📍 Timeline</h3>{timeline_html}"
|
| 601 |
+
|
| 602 |
+
task_count = len(session.task_list)
|
| 603 |
+
high_prio = sum(1 for t in session.task_list if t.get("priority") == "HIGH")
|
| 604 |
+
|
| 605 |
+
# 時間 (優先使用優化後的時間,如果沒有則用交通總時間)
|
| 606 |
+
total_time = metrics.get("optimized_duration_min", traffic.get("total_duration_min", 0))
|
| 607 |
+
|
| 608 |
+
# 距離 (公尺轉公里)
|
| 609 |
+
dist_m = metrics.get("optimized_distance_m", 0)
|
| 610 |
+
total_dist_km = dist_m / 1000.0
|
| 611 |
+
|
| 612 |
+
# 效率與節省 (用於快樂表)
|
| 613 |
+
efficiency = metrics.get("route_efficiency_pct", 0)
|
| 614 |
+
saved_dist_m = metrics.get("distance_saved_m", 0)
|
| 615 |
+
saved_time_min = metrics.get("time_saved_min", 0)
|
| 616 |
+
|
| 617 |
+
summary_card = create_summary_card(task_count, high_prio, int(total_time))
|
| 618 |
+
|
| 619 |
+
# B. 進階數據快樂表 (AI Efficiency & Distance)
|
| 620 |
+
# 根據效率分數改變顏色 (高於 80% 綠色,否則橘色)
|
| 621 |
+
eff_color = "#047857" if efficiency >= 80 else "#d97706"
|
| 622 |
+
eff_bg = "#ecfdf5" if efficiency >= 80 else "#fffbeb"
|
| 623 |
+
eff_border = "#a7f3d0" if efficiency >= 80 else "#fde68a"
|
| 624 |
+
|
| 625 |
+
ai_stats_html = f"""
|
| 626 |
+
<div style="display: flex; gap: 12px; margin-bottom: 20px;">
|
| 627 |
+
<div style="flex: 1; background: {eff_bg}; padding: 16px; border-radius: 12px; border: 1px solid {eff_border};">
|
| 628 |
+
<div style="font-size: 0.8rem; color: {eff_color}; font-weight: 600; display: flex; align-items: center; gap: 4px;">
|
| 629 |
+
<span>🚀 AI EFFICIENCY</span>
|
| 630 |
+
</div>
|
| 631 |
+
<div style="font-size: 1.8rem; font-weight: 800; color: {eff_color}; line-height: 1.2;">
|
| 632 |
+
{efficiency:.1f}%
|
| 633 |
+
</div>
|
| 634 |
+
<div style="font-size: 0.75rem; color: {eff_color}; opacity: 0.9; margin-top: 4px;">
|
| 635 |
+
⚡ Saved {saved_time_min:.0f} mins
|
| 636 |
+
</div>
|
| 637 |
+
</div>
|
| 638 |
+
|
| 639 |
+
<div style="flex: 1; background: #eff6ff; padding: 16px; border-radius: 12px; border: 1px solid #bfdbfe;">
|
| 640 |
+
<div style="font-size: 0.8rem; color: #2563eb; font-weight: 600;">🚗 TOTAL DISTANCE</div>
|
| 641 |
+
<div style="font-size: 1.8rem; font-weight: 800; color: #1d4ed8; line-height: 1.2;">
|
| 642 |
+
{total_dist_km:.2f} <span style="font-size: 1rem;">km</span>
|
| 643 |
+
</div>
|
| 644 |
+
<div style="font-size: 0.75rem; color: #2563eb; opacity: 0.9; margin-top: 4px;">
|
| 645 |
+
📉 Reduced {saved_dist_m} m
|
| 646 |
+
</div>
|
| 647 |
+
</div>
|
| 648 |
+
</div>
|
| 649 |
+
"""
|
| 650 |
+
|
| 651 |
+
full_summary_html = f"{summary_card}{ai_stats_html}<h3>📍 Itinerary Timeline</h3>{timeline_html}"
|
| 652 |
+
|
| 653 |
+
# C. 其他 Tab 內容
|
| 654 |
+
task_list_html = self.generate_task_list_html(session)
|
| 655 |
+
|
| 656 |
+
# 3. Map
|
| 657 |
map_fig = create_animated_map(structured_data)
|
| 658 |
+
if isinstance(map_fig, str):
|
| 659 |
+
logger.error(f"CRITICAL: map_fig is a string ('{map_fig}'). Creating default map.")
|
| 660 |
+
from core.visualizers import create_animated_map as default_map_gen
|
| 661 |
+
map_fig = default_map_gen(None)
|
| 662 |
+
|
| 663 |
+
# 4. Task List
|
| 664 |
+
task_list_html = self.generate_task_list_html(session)
|
| 665 |
|
|
|
|
| 666 |
session.planning_completed = True
|
| 667 |
|
| 668 |
return {
|
| 669 |
"type": "success",
|
| 670 |
+
"summary_tab_html": full_summary_html, # 快樂表
|
| 671 |
+
"report_md": session.final_report, # Full Report
|
| 672 |
+
"task_list_html": task_list_html, # Task List
|
| 673 |
+
"map_fig": map_fig, # Map
|
| 674 |
"session": session
|
| 675 |
}
|
| 676 |
|
ui/components/header.py
CHANGED
|
@@ -1,30 +1,40 @@
|
|
| 1 |
"""
|
| 2 |
-
LifeFlow AI - Header Component (
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
import gradio as gr
|
| 5 |
|
| 6 |
def create_header():
|
| 7 |
"""
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
| 11 |
"""
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
home_btn = gr.Button("🏠", elem_classes="dock-btn")
|
| 16 |
-
theme_btn = gr.Button("🌓", elem_classes="dock-btn")
|
| 17 |
-
settings_btn = gr.Button("⚙️", elem_classes="dock-btn")
|
| 18 |
-
doc_btn = gr.Button("📖", elem_classes="dock-btn")
|
| 19 |
-
|
| 20 |
-
# 2. 左側標題區
|
| 21 |
-
with gr.Row(elem_classes="header-row"):
|
| 22 |
-
with gr.Column(scale=1):
|
| 23 |
gr.HTML("""
|
| 24 |
<div class="app-header-left">
|
| 25 |
-
<h1 style="margin: 0; font-size:
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
</div>
|
| 28 |
""")
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
return home_btn, theme_btn, settings_btn, doc_btn
|
|
|
|
| 1 |
"""
|
| 2 |
+
LifeFlow AI - Header Component (Optimized)
|
| 3 |
+
✅ 修復佈局問題,使用 Row/Column 避免 fixed positioning
|
| 4 |
+
✅ 響應式友好設計
|
| 5 |
"""
|
| 6 |
import gradio as gr
|
| 7 |
|
| 8 |
def create_header():
|
| 9 |
"""
|
| 10 |
+
創建優化後的 Header:
|
| 11 |
+
- 使用 Row/Column 佈局避免 fixed positioning 問題
|
| 12 |
+
- 響應式設計友好
|
| 13 |
+
- 按鈕水平排列
|
| 14 |
"""
|
| 15 |
+
with gr.Row(elem_classes="app-header-container"):
|
| 16 |
+
# 左側:Logo 和標題
|
| 17 |
+
with gr.Column(scale=4, min_width=300):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
gr.HTML("""
|
| 19 |
<div class="app-header-left">
|
| 20 |
+
<h1 style="margin: 0; font-size: 2rem; font-weight: 800;
|
| 21 |
+
background: linear-gradient(to right, #6366f1, #ec4899);
|
| 22 |
+
-webkit-background-clip: text;
|
| 23 |
+
-webkit-text-fill-color: transparent;">
|
| 24 |
+
✨ LifeFlow AI
|
| 25 |
+
</h1>
|
| 26 |
+
<p style="margin: 5px 0 0 0; color: #64748b; font-size: 0.95rem;">
|
| 27 |
+
Intelligent Trip Planner
|
| 28 |
+
</p>
|
| 29 |
</div>
|
| 30 |
""")
|
| 31 |
|
| 32 |
+
# 右側:功能按鈕
|
| 33 |
+
with gr.Column(scale=1, min_width=200):
|
| 34 |
+
with gr.Row(elem_classes="header-controls"):
|
| 35 |
+
home_btn = gr.Button("🏠", size="sm", elem_classes="header-btn")
|
| 36 |
+
theme_btn = gr.Button("🌓", size="sm", elem_classes="header-btn")
|
| 37 |
+
settings_btn = gr.Button("⚙️", size="sm", elem_classes="header-btn")
|
| 38 |
+
doc_btn = gr.Button("📖", size="sm", elem_classes="header-btn")
|
| 39 |
+
|
| 40 |
return home_btn, theme_btn, settings_btn, doc_btn
|
ui/components/input_form.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""
|
| 2 |
-
LifeFlow AI - Input Form Component (
|
|
|
|
| 3 |
"""
|
| 4 |
import gradio as gr
|
| 5 |
|
|
@@ -9,10 +10,18 @@ def create_input_form(agent_stream_html):
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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...",
|
|
@@ -20,26 +29,24 @@ def create_input_form(agent_stream_html):
|
|
| 20 |
elem_id="main-input"
|
| 21 |
)
|
| 22 |
|
| 23 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
gr.Examples(
|
| 25 |
examples=[
|
| 26 |
-
["Plan a trip to visit Taipei 101, then have lunch at Din Tai Fung
|
| 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")
|
|
|
|
| 1 |
"""
|
| 2 |
+
LifeFlow AI - Input Form Component (Fixed Layout)
|
| 3 |
+
✅ 強制將 AI 狀態欄位移至按鈕上方
|
| 4 |
"""
|
| 5 |
import gradio as gr
|
| 6 |
|
|
|
|
| 10 |
with gr.Group(elem_classes="glass-card") as input_area:
|
| 11 |
gr.Markdown("### 📝 What's your plan today?")
|
| 12 |
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
# 這裡的 HTML 元件會顯示串流文字
|
| 15 |
+
agent_stream_output = gr.HTML(
|
| 16 |
+
value=agent_stream_html,
|
| 17 |
+
label="Agent Status",
|
| 18 |
+
elem_classes="agent-stream-box-step1",
|
| 19 |
+
visible=True # 確保預設可見(即使是空的),佔據空間
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
gr.Markdown("---")
|
| 23 |
+
|
| 24 |
+
# 1. 主要輸入區
|
| 25 |
user_input = gr.Textbox(
|
| 26 |
label="Describe your tasks",
|
| 27 |
placeholder="e.g., I need to visit the dentist at 10am, then buy groceries from Costco, and pick up a package...",
|
|
|
|
| 29 |
elem_id="main-input"
|
| 30 |
)
|
| 31 |
|
| 32 |
+
# 2. 位置設定
|
| 33 |
+
with gr.Accordion("📍 Location Settings", open=False):
|
| 34 |
+
auto_location = gr.Checkbox(label="Auto-detect my location (Simulated)", value=False)
|
| 35 |
+
with gr.Group(visible=True) as location_inputs:
|
| 36 |
+
with gr.Row():
|
| 37 |
+
lat_input = gr.Number(label="Latitude", value=25.033, precision=6, scale=1)
|
| 38 |
+
lon_input = gr.Number(label="Longitude", value=121.565, precision=6, scale=1)
|
| 39 |
+
|
| 40 |
+
# 3. 快速範例
|
| 41 |
gr.Examples(
|
| 42 |
examples=[
|
| 43 |
+
["Plan a trip to visit Taipei 101, then have lunch at Din Tai Fung."],
|
|
|
|
| 44 |
["Errands run: Post office, bank, and supermarket. Start at 10 AM, finish by 1 PM."]
|
| 45 |
],
|
| 46 |
inputs=user_input,
|
| 47 |
label="Quick Start Examples"
|
| 48 |
)
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
# 5. 主按鈕
|
| 52 |
analyze_btn = gr.Button("🚀 Analyze & Plan Trip", variant="primary", size="lg")
|
ui/components/progress_stepper.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LifeFlow AI - Progress Stepper Component
|
| 3 |
+
✅ 顯示用戶當前所在步驟
|
| 4 |
+
✅ 視覺化流程進度
|
| 5 |
+
"""
|
| 6 |
+
import gradio as gr
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def create_progress_stepper(current_step: int = 1):
|
| 10 |
+
"""
|
| 11 |
+
創建流程進度條
|
| 12 |
+
|
| 13 |
+
Args:
|
| 14 |
+
current_step: 當前步驟 (1-4)
|
| 15 |
+
1: Analyze
|
| 16 |
+
2: Confirm
|
| 17 |
+
3: Optimize
|
| 18 |
+
4: Review
|
| 19 |
+
|
| 20 |
+
Returns:
|
| 21 |
+
HTML 組件
|
| 22 |
+
"""
|
| 23 |
+
steps = [
|
| 24 |
+
{'num': 1, 'label': 'Analyze', 'icon': '📋', 'desc': 'Extract tasks'},
|
| 25 |
+
{'num': 2, 'label': 'Confirm', 'icon': '✅', 'desc': 'Review & modify'},
|
| 26 |
+
{'num': 3, 'label': 'Optimize', 'icon': '⚡', 'desc': 'Route planning'},
|
| 27 |
+
{'num': 4, 'label': 'Review', 'icon': '🎉', 'desc': 'Check results'}
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
html = '<div class="progress-stepper">'
|
| 31 |
+
|
| 32 |
+
for i, step in enumerate(steps):
|
| 33 |
+
is_active = step['num'] == current_step
|
| 34 |
+
is_complete = step['num'] < current_step
|
| 35 |
+
|
| 36 |
+
state_class = ''
|
| 37 |
+
if is_complete:
|
| 38 |
+
state_class = 'complete'
|
| 39 |
+
elif is_active:
|
| 40 |
+
state_class = 'active'
|
| 41 |
+
|
| 42 |
+
html += f"""
|
| 43 |
+
<div class="step {state_class}">
|
| 44 |
+
<div class="step-number">{step['icon']}</div>
|
| 45 |
+
<div class="step-label">{step['label']}</div>
|
| 46 |
+
<div class="step-desc">{step['desc']}</div>
|
| 47 |
+
</div>
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
# 添加連接線
|
| 51 |
+
if i < len(steps) - 1:
|
| 52 |
+
connector_class = 'complete' if is_complete else ''
|
| 53 |
+
html += f'<div class="step-connector {connector_class}"></div>'
|
| 54 |
+
|
| 55 |
+
html += '</div>'
|
| 56 |
+
|
| 57 |
+
# 內嵌樣式
|
| 58 |
+
css = """
|
| 59 |
+
<style>
|
| 60 |
+
.progress-stepper {
|
| 61 |
+
display: flex;
|
| 62 |
+
align-items: center;
|
| 63 |
+
justify-content: space-between;
|
| 64 |
+
padding: 24px;
|
| 65 |
+
background: white;
|
| 66 |
+
border-radius: 16px;
|
| 67 |
+
margin-bottom: 24px;
|
| 68 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.step {
|
| 72 |
+
display: flex;
|
| 73 |
+
flex-direction: column;
|
| 74 |
+
align-items: center;
|
| 75 |
+
position: relative;
|
| 76 |
+
flex: 1;
|
| 77 |
+
min-width: 80px;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.step-number {
|
| 81 |
+
width: 56px;
|
| 82 |
+
height: 56px;
|
| 83 |
+
border-radius: 50%;
|
| 84 |
+
background: #e2e8f0;
|
| 85 |
+
display: flex;
|
| 86 |
+
align-items: center;
|
| 87 |
+
justify-content: center;
|
| 88 |
+
font-size: 24px;
|
| 89 |
+
transition: all 0.3s ease;
|
| 90 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 91 |
+
margin-bottom: 8px;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.step.active .step-number {
|
| 95 |
+
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
| 96 |
+
color: white;
|
| 97 |
+
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.2), 0 4px 8px rgba(0, 0, 0, 0.15);
|
| 98 |
+
transform: scale(1.1);
|
| 99 |
+
animation: pulse-ring 2s infinite;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.step.complete .step-number {
|
| 103 |
+
background: #10b981;
|
| 104 |
+
color: white;
|
| 105 |
+
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.step-label {
|
| 109 |
+
margin-top: 8px;
|
| 110 |
+
font-size: 14px;
|
| 111 |
+
font-weight: 600;
|
| 112 |
+
color: #64748b;
|
| 113 |
+
transition: color 0.3s;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.step.active .step-label {
|
| 117 |
+
color: #6366f1;
|
| 118 |
+
font-weight: 700;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.step.complete .step-label {
|
| 122 |
+
color: #10b981;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.step-desc {
|
| 126 |
+
font-size: 11px;
|
| 127 |
+
color: #94a3b8;
|
| 128 |
+
margin-top: 4px;
|
| 129 |
+
text-align: center;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.step-connector {
|
| 133 |
+
flex: 1;
|
| 134 |
+
height: 3px;
|
| 135 |
+
background: #e2e8f0;
|
| 136 |
+
margin: 0 8px;
|
| 137 |
+
transition: background 0.3s;
|
| 138 |
+
position: relative;
|
| 139 |
+
top: -20px;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.step-connector.complete {
|
| 143 |
+
background: #10b981;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
@keyframes pulse-ring {
|
| 147 |
+
0% {
|
| 148 |
+
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.7), 0 4px 8px rgba(0, 0, 0, 0.15);
|
| 149 |
+
}
|
| 150 |
+
50% {
|
| 151 |
+
box-shadow: 0 0 0 8px rgba(99, 102, 241, 0), 0 4px 8px rgba(0, 0, 0, 0.15);
|
| 152 |
+
}
|
| 153 |
+
100% {
|
| 154 |
+
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0), 0 4px 8px rgba(0, 0, 0, 0.15);
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/* 響應式 */
|
| 159 |
+
@media (max-width: 768px) {
|
| 160 |
+
.progress-stepper {
|
| 161 |
+
padding: 16px 8px;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.step {
|
| 165 |
+
min-width: 60px;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.step-number {
|
| 169 |
+
width: 44px;
|
| 170 |
+
height: 44px;
|
| 171 |
+
font-size: 18px;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.step-label {
|
| 175 |
+
font-size: 12px;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.step-desc {
|
| 179 |
+
display: none;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.step-connector {
|
| 183 |
+
margin: 0 4px;
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
@media (max-width: 480px) {
|
| 188 |
+
.progress-stepper {
|
| 189 |
+
flex-wrap: wrap;
|
| 190 |
+
gap: 12px;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.step-connector {
|
| 194 |
+
display: none;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.step {
|
| 198 |
+
flex-direction: row;
|
| 199 |
+
gap: 8px;
|
| 200 |
+
width: calc(50% - 6px);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.step-number {
|
| 204 |
+
width: 36px;
|
| 205 |
+
height: 36px;
|
| 206 |
+
font-size: 16px;
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
</style>
|
| 210 |
+
"""
|
| 211 |
+
|
| 212 |
+
return gr.HTML(css + html)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def update_stepper(step_number: int):
|
| 216 |
+
"""更新進度條到指定步驟"""
|
| 217 |
+
return create_progress_stepper(step_number)
|
ui/renderers.py
CHANGED
|
@@ -1,508 +1,169 @@
|
|
| 1 |
-
|
| 2 |
-
LifeFlow AI - UI Renderers
|
| 3 |
-
負責生成 HTML 字串與前端展示元件
|
| 4 |
-
"""
|
| 5 |
from datetime import datetime
|
| 6 |
from config import AGENTS_INFO
|
| 7 |
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
else:
|
| 18 |
-
# 這裡直接使用傳入的 text
|
| 19 |
-
display_content = f'{text}<span class="stream-cursor"></span>'
|
| 20 |
-
|
| 21 |
-
return f"""
|
| 22 |
-
<div class="stream-container">
|
| 23 |
-
<div class="stream-text" style="white-space: pre-wrap; line-height: 1.6; min-height: 60px;">{display_content}</div>
|
| 24 |
-
</div>
|
| 25 |
-
<style>
|
| 26 |
-
.stream-text {{
|
| 27 |
-
font-family: 'Inter', sans-serif;
|
| 28 |
-
color: #334155;
|
| 29 |
-
font-size: 1.05rem;
|
| 30 |
-
}}
|
| 31 |
-
.stream-cursor {{
|
| 32 |
-
display: inline-block;
|
| 33 |
-
width: 8px;
|
| 34 |
-
height: 18px;
|
| 35 |
-
background-color: #6366f1;
|
| 36 |
-
margin-left: 5px;
|
| 37 |
-
animation: blink 1s infinite;
|
| 38 |
-
vertical-align: sub;
|
| 39 |
-
}}
|
| 40 |
-
@keyframes blink {{ 0%, 100% {{ opacity: 1; }} 50% {{ opacity: 0; }} }}
|
| 41 |
-
</style>
|
| 42 |
-
"""
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def create_agent_card_enhanced(agent_key: str, status: str = "idle", message: str = "") -> str:
|
| 46 |
-
agent = AGENTS_INFO.get(agent_key, {})
|
| 47 |
-
color = agent.get("color", "#6366f1")
|
| 48 |
-
|
| 49 |
-
status_color = "#94a3b8" # gray
|
| 50 |
-
active_class = ""
|
| 51 |
-
|
| 52 |
-
if status == "working":
|
| 53 |
-
status_color = "#f59e0b" # orange
|
| 54 |
-
active_class = "working" # 🔥 這裡對應 CSS 的動畫 class
|
| 55 |
-
elif status == "complete":
|
| 56 |
-
status_color = "#10b981" # green
|
| 57 |
-
|
| 58 |
-
# 確保 active_class 被加入 div class 列表
|
| 59 |
-
return f"""
|
| 60 |
-
<div class="agent-card-mini {active_class}" style="border-top: 3px solid {color}">
|
| 61 |
-
<div class="agent-avatar-mini">{agent.get("avatar", "🤖")}</div>
|
| 62 |
-
<div class="agent-name-mini">{agent.get("name", "Agent")}</div>
|
| 63 |
-
<span class="agent-status-dot" style="background-color: {status_color}"></span>
|
| 64 |
-
<div style="font-size: 10px; color: #64748b; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;">{message}</div>
|
| 65 |
-
</div>
|
| 66 |
-
"""
|
| 67 |
-
|
| 68 |
-
def generate_chat_history_html_bubble(session) -> str:
|
| 69 |
-
"""生成對話氣泡 HTML (使用 session 物件)"""
|
| 70 |
-
# 注意:這裡依賴 session 物件的 chat_history 屬性
|
| 71 |
-
if not session.chat_history:
|
| 72 |
-
return """
|
| 73 |
-
<div style="text-align: center; padding: 40px; color: #94a3b8;">
|
| 74 |
-
<div style="font-size: 40px; margin-bottom: 10px;">👋</div>
|
| 75 |
-
<p>Hi! I'm LifeFlow. Tell me your plans, or ask me to modify the tasks above.</p>
|
| 76 |
-
</div>
|
| 77 |
-
"""
|
| 78 |
|
| 79 |
-
html = '<div class="chat-history-container">'
|
| 80 |
-
for msg in session.chat_history:
|
| 81 |
-
role = msg["role"] # user or assistant
|
| 82 |
-
role_class = "user" if role == "user" else "assistant"
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
</div>
|
| 90 |
</div>
|
| 91 |
"""
|
| 92 |
-
html += '</div>'
|
| 93 |
-
return html
|
| 94 |
-
|
| 95 |
-
def create_timeline_html_enhanced(timeline):
|
| 96 |
-
if not timeline: return "<div>No timeline data</div>"
|
| 97 |
-
|
| 98 |
-
html = '<div class="timeline-container">'
|
| 99 |
-
for i, stop in enumerate(timeline):
|
| 100 |
-
time = stop.get("time", "--:--")
|
| 101 |
-
location = stop.get("location", "Unknown")
|
| 102 |
-
weather = stop.get("weather", "")
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
elif "food" in location.lower():
|
| 110 |
-
icon = "🍽️"
|
| 111 |
-
elif "hospital" in location.lower():
|
| 112 |
-
icon = "🏥"
|
| 113 |
-
|
| 114 |
-
html += f"""
|
| 115 |
-
<div class="timeline-item">
|
| 116 |
-
<div class="timeline-left">
|
| 117 |
-
<div class="timeline-icon-box">{icon}</div>
|
| 118 |
</div>
|
| 119 |
-
<div class="
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
</div>
|
| 124 |
-
<div style="font-size:0.9rem; color:#64748b;">
|
| 125 |
-
{f'🌤️ {weather}' if weather else ''}
|
| 126 |
-
</div>
|
| 127 |
</div>
|
| 128 |
</div>
|
| 129 |
-
|
| 130 |
-
|
| 131 |
return html
|
| 132 |
|
| 133 |
-
def create_task_card(task_num: int, task_title: str, priority: str,
|
| 134 |
-
time_window: dict, duration: str, location: str, icon: str = "📋") -> str:
|
| 135 |
-
priority_color_map = {
|
| 136 |
-
"HIGH": "#FF6B6B",
|
| 137 |
-
"MEDIUM": "#F5A623",
|
| 138 |
-
"LOW": "#7ED321"
|
| 139 |
-
}
|
| 140 |
-
|
| 141 |
-
# 處理 time_window 邏輯
|
| 142 |
-
display_time = "Anytime"
|
| 143 |
-
if isinstance(time_window, dict):
|
| 144 |
-
start = time_window.get('earliest_time', None)
|
| 145 |
-
end = time_window.get('latest_time', None)
|
| 146 |
-
start_str = datetime.fromisoformat(start).strftime("%H:%M") if start else "Before"
|
| 147 |
-
end_str = datetime.fromisoformat(end).strftime("%H:%M") if end else "After"
|
| 148 |
-
|
| 149 |
-
if start_str == "Before" and end_str == "After":
|
| 150 |
-
display_time = "Anytime"
|
| 151 |
-
elif start_str == "After":
|
| 152 |
-
display_time = f"After {end_str}"
|
| 153 |
-
elif end_str == "Before":
|
| 154 |
-
display_time = f"Before {start_str}"
|
| 155 |
-
else:
|
| 156 |
-
display_time = f"{start_str} - {end_str}"
|
| 157 |
-
elif time_window:
|
| 158 |
-
display_time = str(time_window)
|
| 159 |
-
|
| 160 |
-
priority_class = f"priority-{priority.lower()}"
|
| 161 |
-
task_color = priority_color_map.get(priority, "#999")
|
| 162 |
|
|
|
|
|
|
|
| 163 |
return f"""
|
| 164 |
-
<div
|
| 165 |
-
<div
|
| 166 |
-
<div
|
| 167 |
-
<
|
| 168 |
-
<
|
| 169 |
-
|
| 170 |
-
<span class="priority-badge {priority_class}">{priority}</span>
|
| 171 |
-
</div>
|
| 172 |
-
<div class="task-details">
|
| 173 |
-
<div class="task-detail-item">
|
| 174 |
-
<span>🕐</span>
|
| 175 |
-
<span>{display_time}</span>
|
| 176 |
-
</div>
|
| 177 |
-
<div class="task-detail-item">
|
| 178 |
-
<span>⏱️</span>
|
| 179 |
-
<span>{duration}</span>
|
| 180 |
</div>
|
| 181 |
-
<div
|
| 182 |
-
<
|
| 183 |
-
<
|
| 184 |
</div>
|
| 185 |
</div>
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
return f"""
|
| 191 |
-
<div class="summary-card">
|
| 192 |
-
<h3 style="margin: 0 0 15px 0; font-size: 20px;">📋 Task Summary</h3>
|
| 193 |
-
<div class="summary-stats">
|
| 194 |
-
<div class="stat-item">
|
| 195 |
-
<span class="stat-value">{total_tasks}</span>
|
| 196 |
-
<span class="stat-label">Tasks</span>
|
| 197 |
-
</div>
|
| 198 |
-
<div class="stat-item">
|
| 199 |
-
<span class="stat-value">{high_priority}</span>
|
| 200 |
-
<span class="stat-label">High Priority</span>
|
| 201 |
-
</div>
|
| 202 |
-
<div class="stat-item">
|
| 203 |
-
<span class="stat-value">{total_time}</span>
|
| 204 |
-
<span class="stat-label">min total</span>
|
| 205 |
</div>
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
def get_reasoning_html_reversed(reasoning_messages: list = None) -> str:
|
| 211 |
-
"""獲取推理過程 HTML(反向排序,最新在上)"""
|
| 212 |
-
if not reasoning_messages:
|
| 213 |
-
return '<div class="reasoning-timeline"><p style="text-align: center; opacity: 0.6;">No reasoning messages yet...</p></div>'
|
| 214 |
-
|
| 215 |
-
items_html = ""
|
| 216 |
-
for msg in reversed(reasoning_messages[-20:]):
|
| 217 |
-
agent_info = AGENTS_INFO.get(msg.get('agent', 'planner'), {})
|
| 218 |
-
color = agent_info.get('color', '#4A90E2')
|
| 219 |
-
avatar = agent_info.get('avatar', '🤖')
|
| 220 |
-
|
| 221 |
-
items_html += f"""
|
| 222 |
-
<div class="reasoning-item" style="--agent-color: {color};">
|
| 223 |
-
<div class="reasoning-header">
|
| 224 |
-
<span>{avatar}</span>
|
| 225 |
-
<span class="reasoning-agent">{msg.get('agent', 'Agent')}</span>
|
| 226 |
-
<span class="reasoning-time">{msg.get('time', '')}</span>
|
| 227 |
</div>
|
| 228 |
-
<div class="reasoning-content">{msg.get('message', '')}</div>
|
| 229 |
-
</div>
|
| 230 |
-
"""
|
| 231 |
-
|
| 232 |
-
return f'<div class="reasoning-timeline">{items_html}</div>'
|
| 233 |
-
|
| 234 |
-
def create_celebration_animation() -> str:
|
| 235 |
-
"""創建慶祝動畫(純 CSS 撒花效果)"""
|
| 236 |
-
import random
|
| 237 |
-
confetti_html = ""
|
| 238 |
-
colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2']
|
| 239 |
-
|
| 240 |
-
for i in range(100):
|
| 241 |
-
left = random.randint(0, 100)
|
| 242 |
-
delay = random.uniform(0, 2)
|
| 243 |
-
duration = random.uniform(3, 6)
|
| 244 |
-
rotation = random.randint(0, 360)
|
| 245 |
-
color = random.choice(colors)
|
| 246 |
-
size = random.randint(8, 15)
|
| 247 |
-
|
| 248 |
-
confetti_html += f"""
|
| 249 |
-
<div class="confetti" style="
|
| 250 |
-
left: {left}%;
|
| 251 |
-
animation-delay: {delay}s;
|
| 252 |
-
animation-duration: {duration}s;
|
| 253 |
-
background-color: {color};
|
| 254 |
-
width: {size}px;
|
| 255 |
-
height: {size}px;
|
| 256 |
-
transform: rotate({rotation}deg);
|
| 257 |
-
"></div>
|
| 258 |
-
"""
|
| 259 |
-
|
| 260 |
-
return f"""
|
| 261 |
-
<div class="celebration-overlay">
|
| 262 |
-
{confetti_html}
|
| 263 |
-
</div>
|
| 264 |
-
<style>
|
| 265 |
-
@keyframes confetti-fall {{
|
| 266 |
-
0% {{ transform: translateY(-100vh) rotate(0deg); opacity: 1; }}
|
| 267 |
-
100% {{ transform: translateY(100vh) rotate(720deg); opacity: 0; }}
|
| 268 |
-
}}
|
| 269 |
-
@keyframes celebration-fade {{
|
| 270 |
-
0% {{ opacity: 1; }}
|
| 271 |
-
80% {{ opacity: 1; }}
|
| 272 |
-
100% {{ opacity: 0; }}
|
| 273 |
-
}}
|
| 274 |
-
.celebration-overlay {{
|
| 275 |
-
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
| 276 |
-
pointer-events: none; z-index: 9999; overflow: hidden;
|
| 277 |
-
animation: celebration-fade 6s forwards;
|
| 278 |
-
}}
|
| 279 |
-
.confetti {{
|
| 280 |
-
position: absolute; top: -20px;
|
| 281 |
-
animation: confetti-fall linear infinite; opacity: 0.9;
|
| 282 |
-
}}
|
| 283 |
-
</style>
|
| 284 |
-
"""
|
| 285 |
-
|
| 286 |
-
def create_result_visualization(task_list: list, structured_data: dict) -> str:
|
| 287 |
-
"""創建詳細的結果視覺化卡片"""
|
| 288 |
-
metrics = structured_data.get("metrics", {})
|
| 289 |
-
traffic = structured_data.get("traffic_summary", {})
|
| 290 |
-
timeline = structured_data.get("timeline", [])
|
| 291 |
-
|
| 292 |
-
completed_tasks = metrics.get("completed_tasks", 0)
|
| 293 |
-
total_tasks = metrics.get("total_tasks", 0)
|
| 294 |
-
total_dist_km = traffic.get("total_distance_km", 0)
|
| 295 |
-
total_dur_min = traffic.get("total_duration_min", 0)
|
| 296 |
-
|
| 297 |
-
hours = int(total_dur_min // 60)
|
| 298 |
-
mins = int(total_dur_min % 60)
|
| 299 |
-
time_str = f"{hours}h {mins}m" if hours > 0 else f"{mins}min"
|
| 300 |
-
|
| 301 |
-
efficiency = metrics.get("route_efficiency_pct", 90)
|
| 302 |
-
time_saved_val = metrics.get("time_saved_min", 0)
|
| 303 |
-
dist_saved_val_km = metrics.get("distance_saved_m", 0) / 1000
|
| 304 |
-
|
| 305 |
-
# ... (為簡潔起見,這裡保留原有的 HTML 拼接邏輯,只是函數位置改變)
|
| 306 |
-
# 這裡的代碼與原 utils.py 中的邏輯完全一致,只是為了空間我縮略了中間的 HTML string
|
| 307 |
-
# 在實際實施時,請複製原 utils.py 中完整的 HTML 模板代碼
|
| 308 |
-
|
| 309 |
-
html = f"""
|
| 310 |
-
<div class="result-visualization">
|
| 311 |
-
<div class="result-header">
|
| 312 |
-
<h2 style="color: #50C878; margin: 0; font-size: 28px; display: flex; align-items: center; gap: 10px;">
|
| 313 |
-
<span class="celebration-icon" style="font-size: 36px; animation: bounce 1s infinite;">🎉</span>
|
| 314 |
-
<span>Planning Complete!</span>
|
| 315 |
-
</h2>
|
| 316 |
-
<p style="margin: 10px 0 0 0; opacity: 0.8; font-size: 14px;">Optimized based on real-time traffic & weather</p>
|
| 317 |
-
</div>
|
| 318 |
-
<div class="quick-summary" style="margin-top: 25px;">
|
| 319 |
-
<div class="metric-value">{completed_tasks} / {total_tasks}</div>
|
| 320 |
</div>
|
| 321 |
</div>
|
| 322 |
"""
|
| 323 |
-
# 注意:請確保將完整的 HTML 邏輯搬移過來
|
| 324 |
-
|
| 325 |
-
# 為了確保功能正常,這裡是一個簡化的佔位符,請使用原來的完整代碼替換這裡
|
| 326 |
-
# 實作時,請將原 utils.py 中 create_result_visualization 的完整內容複製到這裡
|
| 327 |
-
return html # 替換為完整 HTML
|
| 328 |
|
| 329 |
|
| 330 |
-
def
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
if metrics is None: metrics = {}
|
| 334 |
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
|
| 342 |
return f"""
|
| 343 |
-
<div class="
|
| 344 |
-
<div
|
| 345 |
-
<
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
<
|
| 350 |
-
<p class="metric-value">{total_duration:.0f} min</p>
|
| 351 |
</div>
|
| 352 |
-
<div
|
| 353 |
-
<
|
| 354 |
-
<
|
| 355 |
</div>
|
| 356 |
</div>
|
| 357 |
"""
|
| 358 |
|
| 359 |
|
| 360 |
-
#
|
| 361 |
-
def
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
completed_tasks = metrics.get("completed_tasks", 0)
|
| 368 |
-
total_tasks = metrics.get("total_tasks", 0)
|
| 369 |
-
total_dist_km = traffic.get("total_distance_km", 0)
|
| 370 |
-
total_dur_min = traffic.get("total_duration_min", 0)
|
| 371 |
-
|
| 372 |
-
hours = int(total_dur_min // 60)
|
| 373 |
-
mins = int(total_dur_min % 60)
|
| 374 |
-
time_str = f"{hours}h {mins}m" if hours > 0 else f"{mins}min"
|
| 375 |
-
|
| 376 |
-
efficiency = metrics.get("route_efficiency_pct", 90)
|
| 377 |
-
time_saved_pct = metrics.get("time_improvement_pct", 0)
|
| 378 |
-
dist_saved_pct = metrics.get("distance_improvement_pct", 0)
|
| 379 |
-
time_saved_val = metrics.get("time_saved_min", 0)
|
| 380 |
-
dist_saved_val_km = metrics.get("distance_saved_m", 0) / 1000
|
| 381 |
-
|
| 382 |
-
# 開始構建 HTML
|
| 383 |
-
html = f"""
|
| 384 |
-
<div class="result-visualization">
|
| 385 |
-
<div class="result-header">
|
| 386 |
-
<h2 style="color: #50C878; margin: 0; font-size: 28px; display: flex; align-items: center; gap: 10px;">
|
| 387 |
-
<span class="celebration-icon" style="font-size: 36px; animation: bounce 1s infinite;">🎉</span>
|
| 388 |
-
<span>Planning Complete!</span>
|
| 389 |
-
</h2>
|
| 390 |
-
<p style="margin: 10px 0 0 0; opacity: 0.8; font-size: 14px;">Optimized based on real-time traffic & weather</p>
|
| 391 |
-
</div>
|
| 392 |
-
|
| 393 |
-
<div class="quick-summary" style="margin-top: 25px;">
|
| 394 |
-
<h3 style="font-size: 18px; margin: 0 0 15px 0; color: #333;">📊 Quick Summary</h3>
|
| 395 |
-
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px;">
|
| 396 |
-
<div class="summary-metric">
|
| 397 |
-
<div class="metric-icon">🎯</div>
|
| 398 |
-
<div class="metric-content">
|
| 399 |
-
<div class="metric-value">{completed_tasks} / {total_tasks}</div>
|
| 400 |
-
<div class="metric-label">Tasks Completed</div>
|
| 401 |
-
</div>
|
| 402 |
-
</div>
|
| 403 |
-
<div class="summary-metric">
|
| 404 |
-
<div class="metric-icon">⏱️</div>
|
| 405 |
-
<div class="metric-content">
|
| 406 |
-
<div class="metric-value">{time_str}</div>
|
| 407 |
-
<div class="metric-label">Total Duration</div>
|
| 408 |
-
</div>
|
| 409 |
-
</div>
|
| 410 |
-
<div class="summary-metric">
|
| 411 |
-
<div class="metric-icon">🚗</div>
|
| 412 |
-
<div class="metric-content">
|
| 413 |
-
<div class="metric-value">{total_dist_km:.1f} km</div>
|
| 414 |
-
<div class="metric-label">Total Distance</div>
|
| 415 |
-
</div>
|
| 416 |
-
</div>
|
| 417 |
-
<div class="summary-metric success">
|
| 418 |
-
<div class="metric-icon">⚡</div>
|
| 419 |
-
<div class="metric-content">
|
| 420 |
-
<div class="metric-value">{efficiency:.0f}%</div>
|
| 421 |
-
<div class="metric-label">Efficiency Score</div>
|
| 422 |
-
</div>
|
| 423 |
-
</div>
|
| 424 |
-
</div>
|
| 425 |
</div>
|
|
|
|
| 426 |
|
| 427 |
-
|
| 428 |
-
<h3 style="font-size: 18px; margin: 0 0 15px 0; color: #333;">🗓️ Timeline Preview</h3>
|
| 429 |
-
<div class="timeline-container">
|
| 430 |
-
"""
|
| 431 |
-
|
| 432 |
-
# 綁定時間線數據
|
| 433 |
for i, stop in enumerate(timeline):
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
|
| 438 |
-
|
|
|
|
|
|
|
|
|
|
| 439 |
|
| 440 |
html += f"""
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
</div>
|
| 450 |
-
</div>
|
| 451 |
</div>
|
|
|
|
|
|
|
|
|
|
| 452 |
"""
|
|
|
|
|
|
|
| 453 |
|
| 454 |
-
if i < len(timeline) - 1:
|
| 455 |
-
next_travel = timeline[i + 1].get("travel_time_from_prev", "0 mins")
|
| 456 |
-
html += f"""
|
| 457 |
-
<div class="timeline-travel">
|
| 458 |
-
<div class="travel-arrow">↓</div>
|
| 459 |
-
<div class="travel-time">Drive: {next_travel}</div>
|
| 460 |
-
</div>
|
| 461 |
-
"""
|
| 462 |
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
</div>
|
| 466 |
|
| 467 |
-
<div class="optimization-metrics" style="margin-top: 25px;">
|
| 468 |
-
<h3 style="font-size: 18px; margin: 0 0 15px 0; color: #333;">📈 Optimization Metrics</h3>
|
| 469 |
-
<div class="metrics-grid">
|
| 470 |
-
"""
|
| 471 |
|
| 472 |
-
|
| 473 |
-
("Route Efficiency", efficiency, "#50C878"),
|
| 474 |
-
("Time Saved", time_saved_pct, "#4A90E2"),
|
| 475 |
-
("Distance Reduced", dist_saved_pct, "#F5A623")
|
| 476 |
-
]
|
| 477 |
|
| 478 |
-
for label, value, color in viz_metrics:
|
| 479 |
-
if value is None: value = 0
|
| 480 |
-
display_val = min(max(value, 0), 100)
|
| 481 |
-
html += f"""
|
| 482 |
-
<div class="metric-bar">
|
| 483 |
-
<div class="metric-bar-header">
|
| 484 |
-
<span class="metric-bar-label">{label}</span>
|
| 485 |
-
<span class="metric-bar-value">{value:.1f}%</span>
|
| 486 |
-
</div>
|
| 487 |
-
<div class="metric-bar-track">
|
| 488 |
-
<div class="metric-bar-fill" style="width: {display_val}%; background: {color};"></div>
|
| 489 |
-
</div>
|
| 490 |
-
</div>
|
| 491 |
-
"""
|
| 492 |
|
| 493 |
-
|
| 494 |
-
</div>
|
| 495 |
-
<div class="optimization-stats" style="margin-top: 15px; display: flex; gap: 20px; flex-wrap: wrap;">
|
| 496 |
-
<div class="stat-badge">
|
| 497 |
-
<span class="stat-badge-icon">⏱️</span>
|
| 498 |
-
<span class="stat-badge-text">Saved: <strong>{time_saved_val} min</strong></span>
|
| 499 |
-
</div>
|
| 500 |
-
<div class="stat-badge">
|
| 501 |
-
<span class="stat-badge-icon">📏</span>
|
| 502 |
-
<span class="stat-badge-text">Reduced: <strong>{dist_saved_val_km:.1f} km</strong></span>
|
| 503 |
-
</div>
|
| 504 |
-
</div>
|
| 505 |
-
</div>
|
| 506 |
-
</div>
|
| 507 |
-
"""
|
| 508 |
-
return html
|
|
|
|
| 1 |
+
# ui/renderers.py
|
|
|
|
|
|
|
|
|
|
| 2 |
from datetime import datetime
|
| 3 |
from config import AGENTS_INFO
|
| 4 |
|
| 5 |
|
| 6 |
+
def _format_iso_time(iso_str: str) -> str:
|
| 7 |
+
if not iso_str or iso_str == "N/A": return ""
|
| 8 |
+
try:
|
| 9 |
+
dt = datetime.fromisoformat(iso_str)
|
| 10 |
+
return dt.strftime("%H:%M")
|
| 11 |
+
except Exception:
|
| 12 |
+
if "T" in iso_str: return iso_str.split("T")[1][:5]
|
| 13 |
+
return iso_str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
def create_agent_stream_output(text: str = None) -> str:
|
| 17 |
+
return f'<div style="font-family: monospace; color: #334155;">{text}</div>'
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def create_agent_dashboard(status_dict: dict) -> str:
|
| 21 |
+
leader_key = 'team'
|
| 22 |
+
member_keys = ['scout', 'weatherman', 'optimizer', 'navigator', 'presenter']
|
| 23 |
+
|
| 24 |
+
def _render_card(key):
|
| 25 |
+
info = AGENTS_INFO.get(key, {})
|
| 26 |
+
state = status_dict.get(key, {})
|
| 27 |
+
status = state.get('status', 'idle')
|
| 28 |
+
msg = state.get('message', 'Standby')
|
| 29 |
+
active_class = "working" if status == "working" else ""
|
| 30 |
+
icon = info.get('icon', '🤖')
|
| 31 |
+
name = info.get('name', key.title())
|
| 32 |
+
role = info.get('role', 'Agent')
|
| 33 |
+
color = info.get('color', '#6366f1')
|
| 34 |
+
|
| 35 |
+
return f"""
|
| 36 |
+
<div class="agent-card-wrap {active_class}">
|
| 37 |
+
<div class="agent-card-inner" style="border-top: 3px solid {color}">
|
| 38 |
+
<div class="agent-avatar">{icon}</div>
|
| 39 |
+
<div class="agent-name">{name}</div>
|
| 40 |
+
<div class="agent-role">{role}</div>
|
| 41 |
+
<div class="status-badge">{msg}</div>
|
| 42 |
</div>
|
| 43 |
</div>
|
| 44 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
+
html = f"""
|
| 47 |
+
<div class="agent-war-room">
|
| 48 |
+
<div class="org-chart">
|
| 49 |
+
<div class="org-level">
|
| 50 |
+
{_render_card(leader_key)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
</div>
|
| 52 |
+
<div class="connector-line"></div>
|
| 53 |
+
<div class="connector-horizontal"></div>
|
| 54 |
+
<div class="org-level">
|
| 55 |
+
{''.join([_render_card(k) for k in member_keys])}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
</div>
|
| 57 |
</div>
|
| 58 |
+
</div>
|
| 59 |
+
"""
|
| 60 |
return html
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
+
def create_summary_card(total_tasks: int, high_priority: int, total_time: int, location: str = "Taipei City",
|
| 64 |
+
date: str = "Today") -> str:
|
| 65 |
return f"""
|
| 66 |
+
<div style="background: #f8fafc; border-radius: 12px; padding: 16px; margin-bottom: 20px; border: 1px solid #e2e8f0;">
|
| 67 |
+
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px;">
|
| 68 |
+
<div>
|
| 69 |
+
<div style="font-size: 0.8rem; color: #64748b; font-weight: 600;">TRIP SUMMARY</div>
|
| 70 |
+
<div style="font-size: 1.1rem; font-weight: 700; color: #1e293b;">{location}</div>
|
| 71 |
+
<div style="font-size: 0.9rem; color: #6366f1;">📅 {date}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
</div>
|
| 73 |
+
<div style="text-align: right;">
|
| 74 |
+
<div style="font-size: 2rem; font-weight: 800; color: #6366f1; line-height: 1;">{total_tasks}</div>
|
| 75 |
+
<div style="font-size: 0.8rem; color: #64748b;">Tasks</div>
|
| 76 |
</div>
|
| 77 |
</div>
|
| 78 |
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
| 79 |
+
<div style="background: white; padding: 8px; border-radius: 8px; text-align: center; border: 1px solid #e2e8f0;">
|
| 80 |
+
<div style="font-size: 0.8rem; color: #64748b;">Duration</div>
|
| 81 |
+
<div style="font-weight: 600; color: #334155;">{total_time} min</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
</div>
|
| 83 |
+
<div style="background: white; padding: 8px; border-radius: 8px; text-align: center; border: 1px solid #e2e8f0;">
|
| 84 |
+
<div style="font-size: 0.8rem; color: #64748b;">High Prio</div>
|
| 85 |
+
<div style="font-weight: 600; color: #ef4444;">{high_priority}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
</div>
|
| 88 |
</div>
|
| 89 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
|
| 92 |
+
def create_task_card(task_num: int, task_title: str, priority: str, time_window: dict, duration: str, location: str,
|
| 93 |
+
icon: str = "📋") -> str:
|
| 94 |
+
p_color = {"HIGH": "#ef4444", "MEDIUM": "#f59e0b", "LOW": "#10b981"}.get(priority.upper(), "#94a3b8")
|
|
|
|
| 95 |
|
| 96 |
+
display_time = "Anytime"
|
| 97 |
+
if isinstance(time_window, dict):
|
| 98 |
+
s_clean = _format_iso_time(time_window.get('earliest_time', ''))
|
| 99 |
+
e_clean = _format_iso_time(time_window.get('latest_time', ''))
|
| 100 |
+
if s_clean and e_clean:
|
| 101 |
+
display_time = f"{s_clean} - {e_clean}"
|
| 102 |
+
elif s_clean:
|
| 103 |
+
display_time = f"After {s_clean}"
|
| 104 |
+
elif time_window:
|
| 105 |
+
display_time = str(time_window)
|
| 106 |
|
| 107 |
return f"""
|
| 108 |
+
<div class="task-card-item" style="border-left: 4px solid {p_color};">
|
| 109 |
+
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
| 110 |
+
<div style="font-weight: 700; color: #334155; display: flex; align-items: center; gap: 8px;">
|
| 111 |
+
<span style="background:#f1f5f9; padding:4px; border-radius:6px;">{icon}</span>
|
| 112 |
+
{task_title}
|
| 113 |
+
</div>
|
| 114 |
+
<span style="font-size: 0.7rem; font-weight: 700; color: {p_color}; background: {p_color}15; padding: 2px 8px; border-radius: 12px; height: fit-content;">{priority}</span>
|
|
|
|
| 115 |
</div>
|
| 116 |
+
<div style="font-size: 0.85rem; color: #64748b; display: flex; flex-direction: column; gap: 4px;">
|
| 117 |
+
<div style="display: flex; align-items: center; gap: 6px;"><span>📍</span> {location}</div>
|
| 118 |
+
<div style="display: flex; align-items: center; gap: 6px;"><span>🕒</span> {display_time} <span style="color:#cbd5e1">|</span> ⏳ {duration}</div>
|
| 119 |
</div>
|
| 120 |
</div>
|
| 121 |
"""
|
| 122 |
|
| 123 |
|
| 124 |
+
# 🔥🔥🔥 Updated Timeline Function
|
| 125 |
+
def create_timeline_html_enhanced(timeline):
|
| 126 |
+
if not timeline:
|
| 127 |
+
return """
|
| 128 |
+
<div style="text-align: center; padding: 20px; color: #94a3b8;">
|
| 129 |
+
No timeline data available yet.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
</div>
|
| 131 |
+
"""
|
| 132 |
|
| 133 |
+
html = '<div class="timeline-container">'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
for i, stop in enumerate(timeline):
|
| 135 |
+
location = stop.get('location', 'Unknown')
|
| 136 |
+
time_str = stop.get('time', '')
|
| 137 |
+
if not time_str or time_str == "N/A": time_str = "Flexible Time"
|
| 138 |
|
| 139 |
+
weather_str = stop.get('weather', '')
|
| 140 |
+
weather_html = ""
|
| 141 |
+
if weather_str and weather_str != "N/A":
|
| 142 |
+
weather_html = f'<div class="timeline-meta">🌤️ {weather_str}</div>'
|
| 143 |
|
| 144 |
html += f"""
|
| 145 |
+
<div class="timeline-stop">
|
| 146 |
+
<div class="timeline-left">
|
| 147 |
+
<div class="timeline-marker"></div>
|
| 148 |
+
</div>
|
| 149 |
+
<div class="timeline-card">
|
| 150 |
+
<div class="timeline-header">
|
| 151 |
+
<div class="timeline-location">{location}</div>
|
| 152 |
+
<div class="timeline-time-badge">🕒 {time_str}</div>
|
|
|
|
|
|
|
| 153 |
</div>
|
| 154 |
+
{weather_html}
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
"""
|
| 158 |
+
html += '</div>'
|
| 159 |
+
return html
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
+
# Empty Placeholders
|
| 163 |
+
def create_metrics_cards(metrics, traffic): return ""
|
|
|
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
+
def create_result_visualization(tasks, data): return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
+
def generate_chat_history_html_bubble(session): return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ui/theme.py
CHANGED
|
@@ -1,391 +1,354 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 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;
|
| 12 |
--primary-dark: #4f46e5;
|
| 13 |
-
--secondary-color: #10b981;
|
| 14 |
-
--accent-color: #f59e0b;
|
| 15 |
-
--
|
| 16 |
--glass-bg: rgba(255, 255, 255, 0.7);
|
| 17 |
--glass-border: 1px solid rgba(255, 255, 255, 0.5);
|
| 18 |
-
--shadow-
|
| 19 |
-
--
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 41 |
-
|
| 42 |
-
|
| 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 |
-
/* ============= Header 優化 (Flex Layout) ============= */
|
| 61 |
-
.header-row {
|
| 62 |
align-items: center !important;
|
| 63 |
-
margin-bottom:
|
| 64 |
-
padding:
|
| 65 |
-
background: rgba(255, 255, 255, 0.
|
| 66 |
-
backdrop-filter: blur(
|
| 67 |
-
|
| 68 |
-
border:
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
background: linear-gradient(to right, #6366f1, #ec4899);
|
| 72 |
-
-webkit-background-clip: text;
|
| 73 |
-
-webkit-text-fill-color: transparent;
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
/* 按鈕微調 */
|
| 77 |
-
.icon-btn {
|
| 78 |
-
background: transparent !important;
|
| 79 |
-
border: 1px solid #e2e8f0 !important;
|
| 80 |
-
box-shadow: none !important;
|
| 81 |
-
}
|
| 82 |
-
.icon-btn:hover {
|
| 83 |
-
background: #f1f5f9 !important;
|
| 84 |
-
border-color: #cbd5e1 !important;
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
/* ============= 懸浮功能選單 (Fixed Top-Right) ============= */
|
| 88 |
-
#floating-menu {
|
| 89 |
-
position: fixed !important;
|
| 90 |
-
top: 20px !important;
|
| 91 |
-
right: 20px !important;
|
| 92 |
-
z-index: 99999 !important;
|
| 93 |
-
|
| 94 |
-
/* 外觀 */
|
| 95 |
-
background: rgba(255, 255, 255, 0.95) !important;
|
| 96 |
-
backdrop-filter: blur(12px);
|
| 97 |
-
border: 1px solid rgba(255, 255, 255, 0.8);
|
| 98 |
-
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
| 99 |
-
border-radius: 50px !important;
|
| 100 |
-
padding: 5px 10px !important;
|
| 101 |
-
|
| 102 |
-
/* 🔥 核彈級排版修正:使用 Grid 🔥 */
|
| 103 |
-
display: grid !important;
|
| 104 |
-
/* 強制分為 4 個欄位,每個欄位寬度自動 */
|
| 105 |
-
grid-template-columns: auto auto auto auto !important;
|
| 106 |
-
gap: 8px !important;
|
| 107 |
-
|
| 108 |
-
/* 強制重置寬度,不讓它佔滿 */
|
| 109 |
-
width: fit-content !important;
|
| 110 |
-
min-width: auto !important;
|
| 111 |
}
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
| 116 |
}
|
| 117 |
|
| 118 |
-
|
| 119 |
-
.dock-btn {
|
| 120 |
display: flex !important;
|
|
|
|
|
|
|
| 121 |
align-items: center !important;
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
width: 40px !important;
|
| 125 |
-
height: 40px !important;
|
| 126 |
-
border-radius: 50% !important;
|
| 127 |
-
|
| 128 |
-
background: transparent !important;
|
| 129 |
-
border: none !important;
|
| 130 |
-
box-shadow: none !important;
|
| 131 |
-
|
| 132 |
-
margin: 0 !important;
|
| 133 |
-
padding: 0 !important;
|
| 134 |
-
font-size: 1.2rem !important;
|
| 135 |
-
}
|
| 136 |
-
|
| 137 |
-
.dock-btn:hover {
|
| 138 |
-
background: #f1f5f9 !important;
|
| 139 |
-
transform: translateY(-2px);
|
| 140 |
-
transition: all 0.2s ease;
|
| 141 |
}
|
| 142 |
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
width: auto !important;
|
| 148 |
-
flex: 0 0 auto !important;
|
| 149 |
-
margin: 0 !important;
|
| 150 |
padding: 0 !important;
|
| 151 |
-
border: none !important;
|
| 152 |
-
background: transparent !important;
|
| 153 |
-
}
|
| 154 |
-
|
| 155 |
-
/* 按鈕本體樣式優化 */
|
| 156 |
-
.header-controls button {
|
| 157 |
-
display: flex !important;
|
| 158 |
-
align-items: center !important;
|
| 159 |
-
justify-content: center !important;
|
| 160 |
-
|
| 161 |
-
width: 38px !important; /* 固定按鈕大小 */
|
| 162 |
-
height: 38px !important;
|
| 163 |
border-radius: 50% !important;
|
| 164 |
-
|
| 165 |
background: transparent !important;
|
| 166 |
-
border: 1px solid
|
| 167 |
box-shadow: none !important;
|
| 168 |
-
|
| 169 |
-
font-size: 1.2rem !important;
|
| 170 |
-
line-height: 1 !important;
|
| 171 |
color: #64748b !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
}
|
| 173 |
-
|
| 174 |
-
.header-
|
| 175 |
background: #f1f5f9 !important;
|
| 176 |
-
color:
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
box-shadow:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
}
|
| 181 |
|
| 182 |
-
/* ============= Agent
|
| 183 |
@keyframes breathing-glow {
|
| 184 |
-
0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.
|
| 185 |
-
|
| 186 |
-
100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); border-color: #6366f1; }
|
| 187 |
}
|
| 188 |
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
transform: scale(1.05); /* 稍微放大 */
|
| 193 |
-
z-index: 10;
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
/* ============= AI Conversation 優化 ============= */
|
| 197 |
-
.reasoning-timeline {
|
| 198 |
-
max-height: 400px;
|
| 199 |
-
overflow-y: auto;
|
| 200 |
-
padding-right: 10px;
|
| 201 |
-
/* 讓最新的訊息看起來像是在終端機中彈出 */
|
| 202 |
-
display: flex;
|
| 203 |
-
flex-direction: column-reverse;
|
| 204 |
-
}
|
| 205 |
-
|
| 206 |
-
/* ============= 按鈕優化 ============= */
|
| 207 |
-
button.primary {
|
| 208 |
-
background: linear-gradient(135deg, var(--primary-color), var(--primary-dark)) !important;
|
| 209 |
-
border: none !important;
|
| 210 |
-
box-shadow: 0 4px 6px rgba(99, 102, 241, 0.3) !important;
|
| 211 |
-
transition: all 0.2s ease !important;
|
| 212 |
-
}
|
| 213 |
-
button.primary:hover {
|
| 214 |
-
transform: translateY(-2px);
|
| 215 |
-
box-shadow: 0 8px 12px rgba(99, 102, 241, 0.4) !important;
|
| 216 |
}
|
| 217 |
|
| 218 |
-
/*
|
| 219 |
.agent-grid {
|
| 220 |
display: grid;
|
| 221 |
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
| 222 |
gap: 12px;
|
| 223 |
margin-top: 10px;
|
| 224 |
}
|
|
|
|
| 225 |
.agent-card-mini {
|
| 226 |
background: white;
|
| 227 |
-
padding:
|
| 228 |
border-radius: var(--radius-md);
|
| 229 |
-
border:
|
| 230 |
display: flex;
|
| 231 |
flex-direction: column;
|
| 232 |
align-items: center;
|
| 233 |
text-align: center;
|
| 234 |
-
transition: all 0.3s
|
|
|
|
|
|
|
| 235 |
}
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
| 240 |
}
|
| 241 |
-
|
| 242 |
-
.agent-
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
| 245 |
}
|
| 246 |
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
.chat-history {
|
| 249 |
-
display: flex;
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
border: 1px solid #e2e8f0;
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
display: flex;
|
| 261 |
-
align-items: flex-end;
|
| 262 |
-
gap: 10px;
|
| 263 |
-
max-width: 85%;
|
| 264 |
-
}
|
| 265 |
-
.chat-message.user {
|
| 266 |
-
align-self: flex-end;
|
| 267 |
-
flex-direction: row-reverse;
|
| 268 |
-
}
|
| 269 |
-
.chat-message.assistant {
|
| 270 |
-
align-self: flex-start;
|
| 271 |
-
}
|
| 272 |
-
.chat-bubble {
|
| 273 |
-
padding: 12px 16px;
|
| 274 |
-
border-radius: 18px;
|
| 275 |
-
font-size: 0.95rem;
|
| 276 |
-
line-height: 1.5;
|
| 277 |
-
position: relative;
|
| 278 |
-
}
|
| 279 |
-
.chat-message.user .chat-bubble {
|
| 280 |
-
background: var(--primary-color);
|
| 281 |
-
color: white;
|
| 282 |
-
border-bottom-right-radius: 4px;
|
| 283 |
-
}
|
| 284 |
-
.chat-message.assistant .chat-bubble {
|
| 285 |
-
background: #f1f5f9;
|
| 286 |
-
color: #334155;
|
| 287 |
-
border-bottom-left-radius: 4px;
|
| 288 |
}
|
| 289 |
-
.
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
text-align: right;
|
| 294 |
}
|
| 295 |
|
| 296 |
-
/* ============= Timeline
|
| 297 |
.timeline-container {
|
| 298 |
position: relative;
|
| 299 |
-
padding:
|
|
|
|
| 300 |
}
|
|
|
|
|
|
|
| 301 |
.timeline-container::before {
|
| 302 |
content: '';
|
| 303 |
position: absolute;
|
| 304 |
-
left:
|
| 305 |
-
top:
|
| 306 |
-
bottom:
|
| 307 |
width: 2px;
|
| 308 |
background: #e2e8f0;
|
| 309 |
z-index: 0;
|
| 310 |
}
|
|
|
|
|
|
|
| 311 |
.timeline-item {
|
| 312 |
position: relative;
|
| 313 |
-
display: flex;
|
|
|
|
| 314 |
gap: 20px;
|
| 315 |
margin-bottom: 24px;
|
| 316 |
z-index: 1;
|
|
|
|
| 317 |
}
|
|
|
|
|
|
|
| 318 |
.timeline-left {
|
| 319 |
-
min-width: 50px;
|
| 320 |
display: flex;
|
| 321 |
flex-direction: column;
|
| 322 |
align-items: center;
|
|
|
|
|
|
|
| 323 |
}
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
|
|
|
| 327 |
border-radius: 50%;
|
| 328 |
background: white;
|
| 329 |
-
border:
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
justify-content: center;
|
| 333 |
-
font-size: 20px;
|
| 334 |
-
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
| 335 |
}
|
| 336 |
-
|
|
|
|
|
|
|
| 337 |
flex: 1;
|
| 338 |
-
background: white;
|
| 339 |
-
|
| 340 |
-
border-radius:
|
| 341 |
-
|
| 342 |
-
box-shadow: 0 2px
|
| 343 |
-
transition: transform 0.2s;
|
|
|
|
| 344 |
}
|
| 345 |
-
|
|
|
|
| 346 |
transform: translateX(4px);
|
| 347 |
-
|
|
|
|
| 348 |
}
|
| 349 |
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
}
|
| 357 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
background: white;
|
| 359 |
-
padding: 15px;
|
| 360 |
-
border-radius: var(--radius-md);
|
| 361 |
-
text-align: center;
|
| 362 |
border: 1px solid #e2e8f0;
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
font-weight: 700;
|
| 368 |
-
color: #1e293b;
|
| 369 |
-
margin: 5px 0;
|
| 370 |
}
|
| 371 |
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
|
|
|
|
|
|
| 375 |
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
.theme-dark .chat-history, .theme-dark .timeline-content-card, .theme-dark .metric-card, .theme-dark .agent-card-mini {
|
| 379 |
-
background: #1e293b;
|
| 380 |
-
border-color: #334155;
|
| 381 |
-
color: #e2e8f0;
|
| 382 |
}
|
| 383 |
-
|
| 384 |
-
|
|
|
|
|
|
|
| 385 |
}
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
}
|
| 390 |
</style>
|
| 391 |
"""
|
|
|
|
| 1 |
+
# ui/theme.py
|
| 2 |
+
# ui/theme.py
|
|
|
|
|
|
|
| 3 |
|
| 4 |
def get_enhanced_css() -> str:
|
| 5 |
return """
|
| 6 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700;800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
| 7 |
<style>
|
| 8 |
+
/* ============= CSS Variables ============= */
|
| 9 |
:root {
|
| 10 |
+
--primary-color: #6366f1;
|
| 11 |
--primary-dark: #4f46e5;
|
| 12 |
+
--secondary-color: #10b981;
|
| 13 |
+
--accent-color: #f59e0b;
|
| 14 |
+
--danger-color: #ef4444;
|
| 15 |
--glass-bg: rgba(255, 255, 255, 0.7);
|
| 16 |
--glass-border: 1px solid rgba(255, 255, 255, 0.5);
|
| 17 |
+
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
| 18 |
+
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
| 19 |
+
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
| 20 |
+
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
| 21 |
+
--radius-sm: 8px;
|
| 22 |
--radius-md: 12px;
|
| 23 |
+
--radius-lg: 16px;
|
| 24 |
+
--radius-xl: 20px;
|
| 25 |
--font-main: 'Inter', sans-serif;
|
| 26 |
}
|
| 27 |
|
| 28 |
+
/* ============= Base Styles ============= */
|
| 29 |
body, .gradio-container {
|
| 30 |
font-family: var(--font-main) !important;
|
| 31 |
+
background: #f8fafc !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
+
* { box-sizing: border-box; }
|
| 35 |
+
|
| 36 |
+
/* ============= 1. Header (保留 Backup 樣式) ============= */
|
| 37 |
+
.app-header-container {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
align-items: center !important;
|
| 39 |
+
margin-bottom: 24px !important;
|
| 40 |
+
padding: 16px 24px !important;
|
| 41 |
+
background: rgba(255, 255, 255, 0.8) !important;
|
| 42 |
+
backdrop-filter: blur(12px) !important;
|
| 43 |
+
-webkit-backdrop-filter: blur(12px) !important;
|
| 44 |
+
border-radius: var(--radius-lg) !important;
|
| 45 |
+
border: 1px solid rgba(255, 255, 255, 0.5) !important;
|
| 46 |
+
box-shadow: var(--shadow-md) !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
+
.app-header-left {
|
| 50 |
+
display: flex;
|
| 51 |
+
flex-direction: column;
|
| 52 |
+
gap: 4px;
|
| 53 |
}
|
| 54 |
|
| 55 |
+
.header-controls {
|
|
|
|
| 56 |
display: flex !important;
|
| 57 |
+
gap: 8px !important;
|
| 58 |
+
justify-content: flex-end !important;
|
| 59 |
align-items: center !important;
|
| 60 |
+
width: 100%;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
}
|
| 62 |
|
| 63 |
+
.header-btn {
|
| 64 |
+
min-width: 44px !important;
|
| 65 |
+
max-width: 44px !important;
|
| 66 |
+
height: 44px !important;
|
|
|
|
|
|
|
|
|
|
| 67 |
padding: 0 !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
border-radius: 50% !important;
|
|
|
|
| 69 |
background: transparent !important;
|
| 70 |
+
border: 1px solid #e2e8f0 !important;
|
| 71 |
box-shadow: none !important;
|
| 72 |
+
font-size: 1.25rem !important;
|
|
|
|
|
|
|
| 73 |
color: #64748b !important;
|
| 74 |
+
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
| 75 |
+
cursor: pointer !important;
|
| 76 |
+
display: flex !important;
|
| 77 |
+
align-items: center !important;
|
| 78 |
+
justify-content: center !important;
|
| 79 |
}
|
| 80 |
+
|
| 81 |
+
.header-btn:hover {
|
| 82 |
background: #f1f5f9 !important;
|
| 83 |
+
border-color: var(--primary-color) !important;
|
| 84 |
+
color: var(--primary-color) !important;
|
| 85 |
+
transform: translateY(-2px) !important;
|
| 86 |
+
box-shadow: var(--shadow-md) !important;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.header-btn:active {
|
| 90 |
+
transform: translateY(0) !important;
|
| 91 |
}
|
| 92 |
|
| 93 |
+
/* ============= 2. Animations & Agent Cards (保留 Backup 樣式) ============= */
|
| 94 |
@keyframes breathing-glow {
|
| 95 |
+
0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.6), 0 0 20px rgba(99, 102, 241, 0.3); border-color: #6366f1; }
|
| 96 |
+
50% { box-shadow: 0 0 0 8px rgba(99, 102, 241, 0), 0 0 30px rgba(99, 102, 241, 0.5); border-color: #818cf8; }
|
| 97 |
+
100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0), 0 0 20px rgba(99, 102, 241, 0.3); border-color: #6366f1; }
|
| 98 |
}
|
| 99 |
|
| 100 |
+
@keyframes pulse-scale {
|
| 101 |
+
0%, 100% { transform: scale(1); }
|
| 102 |
+
50% { transform: scale(1.03); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
}
|
| 104 |
|
| 105 |
+
/* Agent Grid (用於 Step 3 的 Agent 卡片基礎) */
|
| 106 |
.agent-grid {
|
| 107 |
display: grid;
|
| 108 |
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
| 109 |
gap: 12px;
|
| 110 |
margin-top: 10px;
|
| 111 |
}
|
| 112 |
+
|
| 113 |
.agent-card-mini {
|
| 114 |
background: white;
|
| 115 |
+
padding: 12px;
|
| 116 |
border-radius: var(--radius-md);
|
| 117 |
+
border: 2px solid #e2e8f0;
|
| 118 |
display: flex;
|
| 119 |
flex-direction: column;
|
| 120 |
align-items: center;
|
| 121 |
text-align: center;
|
| 122 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 123 |
+
position: relative;
|
| 124 |
+
overflow: hidden;
|
| 125 |
}
|
| 126 |
+
|
| 127 |
+
.agent-card-mini.working {
|
| 128 |
+
animation: breathing-glow 2s ease-in-out infinite !important;
|
| 129 |
+
background: linear-gradient(135deg, #ffffff 0%, #eff6ff 100%) !important;
|
| 130 |
+
border-width: 2px !important;
|
| 131 |
+
z-index: 10 !important;
|
| 132 |
+
position: relative !important;
|
| 133 |
}
|
| 134 |
+
|
| 135 |
+
.agent-card-mini.working::before {
|
| 136 |
+
content: '';
|
| 137 |
+
position: absolute; top: -50%; left: -50%; width: 200%; height: 200%;
|
| 138 |
+
background: radial-gradient(circle, rgba(99, 102, 241, 0.1) 0%, transparent 70%);
|
| 139 |
+
animation: pulse-scale 2s ease-in-out infinite;
|
| 140 |
}
|
| 141 |
|
| 142 |
+
.agent-card-mini:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
|
| 143 |
+
.agent-avatar-mini { font-size: 28px; margin-bottom: 6px; position: relative; z-index: 1; }
|
| 144 |
+
.agent-name-mini { font-weight: 600; font-size: 0.85rem; color: #1e293b; position: relative; z-index: 1; }
|
| 145 |
+
.agent-status-dot { height: 8px; width: 8px; border-radius: 50%; display: inline-block; margin-top: 6px; position: relative; z-index: 1; }
|
| 146 |
+
|
| 147 |
+
/* ============= 3. Chat & Tasks (保留 Backup 樣式) ============= */
|
| 148 |
.chat-history {
|
| 149 |
+
display: flex; flex-direction: column; gap: 16px; padding: 20px;
|
| 150 |
+
background: #fff; border-radius: var(--radius-lg); border: 1px solid #e2e8f0;
|
| 151 |
+
max-height: 500px; overflow-y: auto;
|
| 152 |
+
}
|
| 153 |
+
.chat-message { display: flex; align-items: flex-end; gap: 10px; max-width: 85%; animation: fade-in 0.3s ease-out; }
|
| 154 |
+
.chat-message.user { align-self: flex-end; flex-direction: row-reverse; }
|
| 155 |
+
.chat-message.assistant { align-self: flex-start; }
|
| 156 |
+
.chat-bubble { padding: 12px 16px; border-radius: 18px; font-size: 0.95rem; line-height: 1.5; position: relative; box-shadow: var(--shadow-sm); }
|
| 157 |
+
.chat-message.user .chat-bubble { background: var(--primary-color); color: white; border-bottom-right-radius: 4px; }
|
| 158 |
+
.chat-message.assistant .chat-bubble { background: #f1f5f9; color: #334155; border-bottom-left-radius: 4px; }
|
| 159 |
+
.chat-time { font-size: 0.7rem; opacity: 0.7; margin-top: 4px; text-align: right; }
|
| 160 |
+
|
| 161 |
+
/* Task Cards */
|
| 162 |
+
.task-card-item {
|
| 163 |
+
background: white;
|
| 164 |
border: 1px solid #e2e8f0;
|
| 165 |
+
border-radius: 12px;
|
| 166 |
+
padding: 16px;
|
| 167 |
+
margin-bottom: 12px;
|
| 168 |
+
transition: transform 0.2s;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
}
|
| 170 |
+
.task-card-item:hover {
|
| 171 |
+
border-color: var(--primary-color);
|
| 172 |
+
transform: translateY(-2px);
|
| 173 |
+
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
|
|
| 174 |
}
|
| 175 |
|
| 176 |
+
/* ============= 4. Timeline Redesign (Step 4 修正) ============= */
|
| 177 |
.timeline-container {
|
| 178 |
position: relative;
|
| 179 |
+
padding: 10px 0 10px 20px;
|
| 180 |
+
margin-top: 10px;
|
| 181 |
}
|
| 182 |
+
|
| 183 |
+
/* 垂直連接線 */
|
| 184 |
.timeline-container::before {
|
| 185 |
content: '';
|
| 186 |
position: absolute;
|
| 187 |
+
left: 29px;
|
| 188 |
+
top: 20px;
|
| 189 |
+
bottom: 20px;
|
| 190 |
width: 2px;
|
| 191 |
background: #e2e8f0;
|
| 192 |
z-index: 0;
|
| 193 |
}
|
| 194 |
+
|
| 195 |
+
/* 時間軸項目 - 強制橫排 */
|
| 196 |
.timeline-item {
|
| 197 |
position: relative;
|
| 198 |
+
display: flex !important;
|
| 199 |
+
flex-direction: row !important;
|
| 200 |
gap: 20px;
|
| 201 |
margin-bottom: 24px;
|
| 202 |
z-index: 1;
|
| 203 |
+
align-items: stretch;
|
| 204 |
}
|
| 205 |
+
|
| 206 |
+
/* 左側圓點 */
|
| 207 |
.timeline-left {
|
|
|
|
| 208 |
display: flex;
|
| 209 |
flex-direction: column;
|
| 210 |
align-items: center;
|
| 211 |
+
min-width: 20px;
|
| 212 |
+
padding-top: 4px;
|
| 213 |
}
|
| 214 |
+
|
| 215 |
+
.timeline-marker {
|
| 216 |
+
width: 20px;
|
| 217 |
+
height: 20px;
|
| 218 |
border-radius: 50%;
|
| 219 |
background: white;
|
| 220 |
+
border: 4px solid var(--primary-color);
|
| 221 |
+
box-shadow: 0 0 0 4px #fff;
|
| 222 |
+
z-index: 2;
|
|
|
|
|
|
|
|
|
|
| 223 |
}
|
| 224 |
+
|
| 225 |
+
/* 右側卡片 */
|
| 226 |
+
.timeline-card {
|
| 227 |
flex: 1;
|
| 228 |
+
background: white !important;
|
| 229 |
+
border: 1px solid #e2e8f0 !important;
|
| 230 |
+
border-radius: 12px !important;
|
| 231 |
+
padding: 16px !important;
|
| 232 |
+
box-shadow: 0 1px 2px rgba(0,0,0,0.05) !important;
|
| 233 |
+
transition: transform 0.2s, box-shadow 0.2s;
|
| 234 |
+
display: block !important;
|
| 235 |
}
|
| 236 |
+
|
| 237 |
+
.timeline-card:hover {
|
| 238 |
transform: translateX(4px);
|
| 239 |
+
box-shadow: 0 4px 6px rgba(0,0,0,0.05) !important;
|
| 240 |
+
border-color: var(--primary-color) !important;
|
| 241 |
}
|
| 242 |
|
| 243 |
+
.timeline-header {
|
| 244 |
+
display: flex;
|
| 245 |
+
justify-content: space-between;
|
| 246 |
+
align-items: flex-start;
|
| 247 |
+
margin-bottom: 8px;
|
| 248 |
+
gap: 10px;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.timeline-location { font-size: 1rem; font-weight: 700; color: #1e293b; line-height: 1.3; }
|
| 252 |
+
|
| 253 |
+
.timeline-time-badge {
|
| 254 |
+
background: #f1f5f9; color: #64748b; font-size: 0.75rem; font-weight: 600;
|
| 255 |
+
padding: 4px 10px; border-radius: 20px; white-space: nowrap; display: flex; align-items: center; gap: 4px;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.timeline-meta {
|
| 259 |
+
display: flex; align-items: center; gap: 8px; font-size: 0.85rem; color: #64748b;
|
| 260 |
+
background: #f8fafc; padding: 8px; border-radius: 8px; margin-top: 8px;
|
| 261 |
}
|
| 262 |
+
|
| 263 |
+
.timeline-item:first-child .timeline-marker { border-color: #10b981 !important; }
|
| 264 |
+
.timeline-item:last-child .timeline-marker { border-color: #ef4444 !important; }
|
| 265 |
+
|
| 266 |
+
/* ============= 5. Step 3 War Room (新樣式) ============= */
|
| 267 |
+
.live-report-wrapper {
|
| 268 |
+
flex: 1;
|
| 269 |
background: white;
|
|
|
|
|
|
|
|
|
|
| 270 |
border: 1px solid #e2e8f0;
|
| 271 |
+
border-radius: 16px;
|
| 272 |
+
padding: 30px;
|
| 273 |
+
min-height: 600px;
|
| 274 |
+
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
|
|
|
|
|
|
|
|
|
| 275 |
}
|
| 276 |
|
| 277 |
+
.report-title {
|
| 278 |
+
font-size: 1.2rem; font-weight: 700; color: #1e293b;
|
| 279 |
+
margin-bottom: 16px; display: flex; align-items: center; gap: 8px;
|
| 280 |
+
border-bottom: 2px solid #f1f5f9; padding-bottom: 12px;
|
| 281 |
+
}
|
| 282 |
|
| 283 |
+
.war-room-wrapper {
|
| 284 |
+
width: 450px; flex-shrink: 0; display: flex; flex-direction: column; gap: 20px; position: sticky; top: 20px;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
}
|
| 286 |
+
|
| 287 |
+
.agent-war-room {
|
| 288 |
+
background: white; border-radius: 16px; border: 1px solid #e2e8f0; padding: 24px;
|
| 289 |
+
display: flex; flex-direction: column; align-items: center; box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
| 290 |
}
|
| 291 |
+
|
| 292 |
+
.org-chart { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 20px; }
|
| 293 |
+
.org-level { display: flex !important; flex-direction: row !important; flex-wrap: wrap; justify-content: center; gap: 12px; width: 100%; z-index: 2; }
|
| 294 |
+
.connector-line { width: 2px; height: 20px; background: #cbd5e1; }
|
| 295 |
+
.connector-horizontal { height: 2px; width: 80%; background: #cbd5e1; margin-top: -20px; margin-bottom: 10px; }
|
| 296 |
+
|
| 297 |
+
/* Step 3 的 Agent Card (複用 mini card 但調整寬度) */
|
| 298 |
+
.agent-card-wrap { width: 110px; text-align: center; display: flex; flex-direction: column; align-items: center; }
|
| 299 |
+
.agent-card-inner {
|
| 300 |
+
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px;
|
| 301 |
+
display: flex; flex-direction: column; align-items: center; width: 100%; transition: transform 0.2s;
|
| 302 |
+
}
|
| 303 |
+
.agent-card-wrap.working .agent-card-inner {
|
| 304 |
+
background: white; border-color: #6366f1;
|
| 305 |
+
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.15); animation: pulse 2s infinite;
|
| 306 |
+
}
|
| 307 |
+
.agent-avatar { font-size: 24px; margin-bottom: 4px; }
|
| 308 |
+
.agent-name { font-weight: 700; font-size: 0.8rem; color: #1e293b; }
|
| 309 |
+
.agent-role { font-size: 0.65rem; color: #64748b; text-transform: uppercase; }
|
| 310 |
+
.status-badge { font-size: 0.65rem; padding: 2px 6px; border-radius: 8px; background: #e2e8f0; color: #64748b; margin-top: 4px; }
|
| 311 |
+
.agent-card-wrap.working .status-badge { background: #e0e7ff; color: #6366f1; }
|
| 312 |
+
|
| 313 |
+
/* ============= 6. Utils & Layouts ============= */
|
| 314 |
+
.step-container { max-width: 1400px; margin: 0 auto; padding: 0 16px; animation: fade-in 0.4s ease-out; }
|
| 315 |
+
.centered-input-container { max-width: 1000px !important; margin: 0 auto; padding: 40px 0; }
|
| 316 |
+
|
| 317 |
+
.panel-container { background: white; border: 1px solid #e2e8f0; border-radius: 16px; height: 650px !important; display: flex !important; flex-direction: column !important; overflow: hidden; }
|
| 318 |
+
.panel-header { padding: 16px 20px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; font-weight: 700; color: var(--text-main); flex-shrink: 0; }
|
| 319 |
+
.scrollable-content { flex: 1; overflow-y: auto !important; padding: 20px; background: #fff; }
|
| 320 |
+
|
| 321 |
+
.chat-input-row { padding: 12px !important; border-top: 1px solid #e2e8f0; background: white; align-items: center !important; }
|
| 322 |
+
|
| 323 |
+
.split-view-container { display: flex; gap: 24px; height: calc(100vh - 140px); min-height: 600px; }
|
| 324 |
+
.split-left-panel { flex: 1; overflow-y: auto; }
|
| 325 |
+
.split-right-panel { flex: 2; position: sticky; top: 100px; height: 100%; }
|
| 326 |
+
|
| 327 |
+
/* Animations */
|
| 328 |
+
@keyframes fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
| 329 |
+
@keyframes slide-in-left { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } }
|
| 330 |
+
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4); } 70% { box-shadow: 0 0 0 6px rgba(0,0,0,0); } 100% { box-shadow: 0 0 0 0 rgba(0,0,0,0); } }
|
| 331 |
+
|
| 332 |
+
/* Scrollbar */
|
| 333 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 334 |
+
::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 4px; }
|
| 335 |
+
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
| 336 |
+
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
| 337 |
+
|
| 338 |
+
/* Dark Mode Support */
|
| 339 |
+
.theme-dark body, .theme-dark .gradio-container { background: #0f172a !important; }
|
| 340 |
+
.theme-dark .app-header-container, .theme-dark .chat-history, .theme-dark .timeline-card, .theme-dark .metric-card, .theme-dark .agent-card-mini, .theme-dark .agent-card-inner, .theme-dark .live-report-wrapper, .theme-dark .agent-war-room, .theme-dark .task-card-item {
|
| 341 |
+
background: #1e293b !important; border-color: #334155 !important; color: #e2e8f0 !important;
|
| 342 |
+
}
|
| 343 |
+
.theme-dark h1, .theme-dark h2, .theme-dark h3, .theme-dark p, .theme-dark span, .theme-dark .agent-name { color: #e2e8f0 !important; }
|
| 344 |
+
.theme-dark .chat-message.assistant .chat-bubble { background: #334155 !important; color: #e2e8f0 !important; }
|
| 345 |
+
.theme-dark .agent-name-mini, .theme-dark .metric-card h3 { color: #cbd5e1 !important; }
|
| 346 |
+
|
| 347 |
+
/* Step 1 Log */
|
| 348 |
+
.agent-stream-box-step1 {
|
| 349 |
+
background: #f8fafc; border-radius: 8px; padding: 12px; border: 1px solid #e2e8f0;
|
| 350 |
+
min-height: 60px; max-height: 150px; overflow-y: auto; font-family: monospace; font-size: 0.9rem;
|
| 351 |
+
margin-bottom: 16px; margin-top: 10px;
|
| 352 |
}
|
| 353 |
</style>
|
| 354 |
"""
|