Spaces:
Running
Release v1.0.0: Hybrid AI Architecture, Modern UI Overhaul, and Performance Optimizations
Browse files# 🚀 Major Features: Hybrid AI Architecture
- **Dual-Brain System**: Implemented "Primary Brain" (GPT-4o/Gemini) for complex reasoning and "Acceleration Layer" (Groq/Llama-3) for fast tool execution.
- **Model Allowlist**: Integrated `MODEL_OPTIONS` in `config.py` with tiered selection (Efficiency vs. Flagship).
- **Fast Mode**: Added toggle to offload heavy tasks (Scout/Navigator) to Groq/Llama-3-70B, reducing costs and latency.
# 🎨 UI/UX: Modern Redesign & Workflow
- **Settings Modal Revamp**:
- Redesigned with a "Purple/Gradient" theme for a professional look.
- Split configuration into "Services", "Primary Brain", and "Acceleration" tabs.
- Implemented **Passive Validation**: API keys are validated instantly on blur (Zero-cost check).
- Dynamic visibility: Automatically hides redundant keys based on provider selection.
- **Dashboard Layout**:
- Vertical control bar in the header for better space utilization.
- Repositioned "Stop & Back" button to the right column for better accessibility.
- Enforced white-card styling (`live-report-wrapper`) for Full Reports in Step 3 & 4.
- **Interactive Map**: Updated visualizers to use generic markers for waypoints and collapsed layer controls by default.
# ⚡ Logic & Performance
- **Zero-Cost Validation**: Refactored `validator.py` to use `list_models()` and Google Maps `Essentials (ID Only)` to prevent billing during tests.
- **Graceful Cancellation**: Implemented session-based cancellation to terminate background agents immediately upon user request.
- **Agent State Optimization**: Fixed Step 3 event loop to ensure Team Leader remains active during member handovers (eliminated "idle gap").
- **Safety Filter Handling**: Added `BLOCK_NONE` settings for Gemini to prevent silent failures on harmless prompts.
# 🐛 Fixes
- Fixed layout issues in Settings Modal where the footer was overlapping.
- Corrected Task ID display logic in the map visualizer (now shows Stop Sequence).
- Resolved issue where Groq models would occasionally output malformed JSON.
- app.py +278 -75
- config.py +31 -0
- core/visualizers.py +44 -58
- services/planner_service.py +258 -96
- src/__init__.py +1 -0
- src/agent/setting/planner.py +36 -4
- src/optimization/tsptw_solver.py +1 -1
- src/tools/optimizer_toolkit.py +2 -0
- ui/__init__.py +1 -0
- ui/components/header.py +8 -8
- ui/components/modals.py +87 -48
- ui/theme.py +174 -48
|
@@ -19,12 +19,19 @@ from ui.renderers import (
|
|
| 19 |
)
|
| 20 |
from core.session import UserSession
|
| 21 |
from services.planner_service import PlannerService
|
| 22 |
-
from
|
|
|
|
| 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}
|
|
@@ -57,10 +64,11 @@ class LifeFlowAI:
|
|
| 57 |
evt_type = event.get("type")
|
| 58 |
current_session = event.get("session", session)
|
| 59 |
if evt_type == "error":
|
|
|
|
| 60 |
yield (
|
| 61 |
-
gr.update(), #
|
| 62 |
-
gr.update(), #
|
| 63 |
-
gr.update(), #
|
| 64 |
gr.HTML(f"<div style='color:red'>{event.get('message')}</div>"), # s1_stream
|
| 65 |
gr.update(), # task_list
|
| 66 |
gr.update(), # task_summary
|
|
@@ -146,48 +154,83 @@ class LifeFlowAI:
|
|
| 146 |
|
| 147 |
yield (init_dashboard, init_log, loading_html, tasks_html, session.to_dict())
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
""
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
def step4_wrapper(self, session_data):
|
| 193 |
session = UserSession.from_dict(session_data)
|
|
@@ -211,6 +254,20 @@ class LifeFlowAI:
|
|
| 211 |
session.to_dict()
|
| 212 |
)
|
| 213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
# ================= Main UI Builder =================
|
| 215 |
|
| 216 |
def build_interface(self):
|
|
@@ -229,7 +286,7 @@ class LifeFlowAI:
|
|
| 229 |
session_state = gr.State(UserSession().to_dict())
|
| 230 |
|
| 231 |
with gr.Column(elem_classes="step-container"):
|
| 232 |
-
home_btn,
|
| 233 |
|
| 234 |
stepper = create_progress_stepper(1)
|
| 235 |
status_bar = gr.Markdown("Ready", visible=False)
|
|
@@ -261,20 +318,32 @@ class LifeFlowAI:
|
|
| 261 |
# STEP 3
|
| 262 |
with gr.Group(visible=False, elem_classes="step-container") as step3_container:
|
| 263 |
gr.Markdown("### 🤖 AI Team Operations")
|
|
|
|
|
|
|
| 264 |
with gr.Group(elem_classes="agent-dashboard-container"):
|
| 265 |
agent_dashboard = gr.HTML(value=self._get_dashboard_html())
|
| 266 |
|
|
|
|
| 267 |
with gr.Row():
|
| 268 |
-
|
|
|
|
| 269 |
with gr.Tabs():
|
| 270 |
with gr.Tab("📝 Full Report"):
|
|
|
|
| 271 |
with gr.Group(elem_classes="live-report-wrapper"):
|
| 272 |
live_report_md = gr.Markdown()
|
|
|
|
| 273 |
with gr.Tab("📋 Task List"):
|
| 274 |
with gr.Group(elem_classes="panel-container"):
|
| 275 |
with gr.Group(elem_classes="scrollable-content"):
|
| 276 |
task_list_s3 = gr.HTML()
|
|
|
|
|
|
|
| 277 |
with gr.Column(scale=1):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
gr.Markdown("### ⚡ Activity Log")
|
| 279 |
with gr.Group(elem_classes="panel-container"):
|
| 280 |
planning_log = gr.HTML(value="Waiting...")
|
|
@@ -289,19 +358,134 @@ class LifeFlowAI:
|
|
| 289 |
summary_tab_output = gr.HTML()
|
| 290 |
|
| 291 |
with gr.Tab("📝 Full Report"):
|
| 292 |
-
|
|
|
|
| 293 |
|
| 294 |
with gr.Tab("📋 Task List"):
|
| 295 |
task_list_tab_output = gr.HTML()
|
| 296 |
|
| 297 |
# Right: Hero Map
|
| 298 |
with gr.Column(scale=2, elem_classes="split-right-panel"):
|
| 299 |
-
#map_view = gr.Plot(label="Route Map", show_label=False)
|
| 300 |
map_view = gr.HTML(label="Route Map")
|
| 301 |
|
| 302 |
# Modals & Events
|
| 303 |
-
(settings_modal,
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
|
| 306 |
doc_modal, close_doc_btn = create_doc_modal()
|
| 307 |
|
|
@@ -309,11 +493,11 @@ class LifeFlowAI:
|
|
| 309 |
close_set.click(fn=lambda: gr.update(visible=False), outputs=[settings_modal])
|
| 310 |
doc_btn.click(fn=lambda: gr.update(visible=True), outputs=[doc_modal])
|
| 311 |
close_doc_btn.click(fn=lambda: gr.update(visible=False), outputs=[doc_modal])
|
| 312 |
-
theme_btn.click(fn=None, js="() => { document.querySelector('.gradio-container').classList.toggle('theme-dark'); }")
|
| 313 |
|
| 314 |
analyze_btn.click(
|
| 315 |
fn=self.analyze_wrapper,
|
| 316 |
-
inputs=[user_input, auto_loc, lat_in, lon_in, session_state],
|
| 317 |
outputs=[step1_container, step2_container, step3_container, s1_stream_output, task_list_box, task_summary_box, chatbot, session_state]
|
| 318 |
).then(fn=lambda: update_stepper(2), outputs=[stepper])
|
| 319 |
|
|
@@ -330,6 +514,16 @@ class LifeFlowAI:
|
|
| 330 |
show_progress="hidden"
|
| 331 |
)
|
| 332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
step3_start.then(
|
| 334 |
fn=self.step4_wrapper,
|
| 335 |
inputs=[session_state],
|
|
@@ -351,37 +545,46 @@ class LifeFlowAI:
|
|
| 351 |
new_session.custom_settings = old_session.custom_settings
|
| 352 |
return (gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), update_stepper(1), new_session.to_dict(), "")
|
| 353 |
|
| 354 |
-
home_btn.click(
|
| 355 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
|
| 357 |
-
save_set.click(fn=self.save_settings,
|
| 358 |
-
inputs=[g_key, w_key, llm_provider, model_key, model_sel, session_state],
|
| 359 |
-
outputs=[set_stat, session_state, status_bar]
|
| 360 |
-
).then(fn=lambda: gr.update(visible=False), outputs=[settings_modal])
|
| 361 |
|
| 362 |
auto_loc.change(fn=toggle_location_inputs, inputs=auto_loc, outputs=loc_group)
|
| 363 |
demo.load(fn=self._check_api_status, inputs=[session_state], outputs=[status_bar])
|
| 364 |
|
| 365 |
return demo
|
| 366 |
|
| 367 |
-
# 接收參數中加入了 prov (對應 UI 上的 llm_provider)
|
| 368 |
-
def save_settings(self, g, w, prov, model_api, m, s_data):
|
| 369 |
-
sess = UserSession.from_dict(s_data)
|
| 370 |
-
|
| 371 |
-
# 更新設定字典,加入 provider
|
| 372 |
-
sess.custom_settings.update({
|
| 373 |
-
'google_maps_api_key': g,
|
| 374 |
-
'openweather_api_key': w,
|
| 375 |
-
'llm_provider': prov, # ✅ 新增這行:儲存供應商
|
| 376 |
-
'model_api_key': model_api,
|
| 377 |
-
'model': m
|
| 378 |
-
})
|
| 379 |
-
return gr.update(visible=False), sess.to_dict(), "✅ Settings Updated"
|
| 380 |
-
|
| 381 |
def main():
|
| 382 |
app = LifeFlowAI()
|
| 383 |
demo = app.build_interface()
|
| 384 |
-
demo.launch(server_name="0.0.0.0", server_port=
|
| 385 |
#7860
|
| 386 |
if __name__ == "__main__":
|
| 387 |
main()
|
|
|
|
| 19 |
)
|
| 20 |
from core.session import UserSession
|
| 21 |
from services.planner_service import PlannerService
|
| 22 |
+
from services.validator import APIValidator # 記得 import
|
| 23 |
+
from config import MODEL_OPTIONS
|
| 24 |
|
| 25 |
class LifeFlowAI:
|
| 26 |
def __init__(self):
|
| 27 |
self.service = PlannerService()
|
| 28 |
|
| 29 |
+
def cancel_wrapper(self, session_data):
|
| 30 |
+
session = UserSession.from_dict(session_data)
|
| 31 |
+
if session.session_id:
|
| 32 |
+
self.service.cancel_session(session.session_id)
|
| 33 |
+
# 不需要回傳任何東西,這只是一個副作用 (Side Effect) 函數
|
| 34 |
+
|
| 35 |
def _get_dashboard_html(self, active_agent: str = None, status: str = "idle", message: str = "Waiting") -> str:
|
| 36 |
agents = ['team', 'scout', 'optimizer', 'navigator', 'weatherman', 'presenter']
|
| 37 |
status_dict = {a: {'status': 'idle', 'message': 'Standby'} for a in agents}
|
|
|
|
| 64 |
evt_type = event.get("type")
|
| 65 |
current_session = event.get("session", session)
|
| 66 |
if evt_type == "error":
|
| 67 |
+
gr.Warning(event.get('message'))
|
| 68 |
yield (
|
| 69 |
+
gr.update(visible=True), # Step 1 Container: 保持顯示
|
| 70 |
+
gr.update(visible=False), # Step 2 Container: 保持隱藏
|
| 71 |
+
gr.update(visible=False), # Step 3 Container
|
| 72 |
gr.HTML(f"<div style='color:red'>{event.get('message')}</div>"), # s1_stream
|
| 73 |
gr.update(), # task_list
|
| 74 |
gr.update(), # task_summary
|
|
|
|
| 154 |
|
| 155 |
yield (init_dashboard, init_log, loading_html, tasks_html, session.to_dict())
|
| 156 |
|
| 157 |
+
try:
|
| 158 |
+
iterator = self.service.run_step3_team(session)
|
| 159 |
+
|
| 160 |
+
for event in iterator:
|
| 161 |
+
sess = event.get("session", session)
|
| 162 |
+
evt_type = event.get("type")
|
| 163 |
+
|
| 164 |
+
if evt_type == "error":
|
| 165 |
+
raise Exception(event.get("message"))
|
| 166 |
+
|
| 167 |
+
if evt_type == "report_stream":
|
| 168 |
+
report_content = event.get("content", "")
|
| 169 |
+
|
| 170 |
+
if evt_type == "reasoning_update":
|
| 171 |
+
agent, status, msg = event.get("agent_status")
|
| 172 |
+
time_str = datetime.now().strftime('%H:%M:%S')
|
| 173 |
+
log_entry = f"""
|
| 174 |
+
<div style="margin-bottom: 8px; border-left: 3px solid #6366f1; padding-left: 10px;">
|
| 175 |
+
<div style="font-size: 0.75rem; color: #94a3b8;">{time_str} • {agent.upper()}</div>
|
| 176 |
+
<div style="color: #334155; font-size: 0.9rem;">{msg}</div>
|
| 177 |
+
</div>
|
| 178 |
+
"""
|
| 179 |
+
log_content = log_entry + log_content
|
| 180 |
+
dashboard_html = self._get_dashboard_html(agent, status, msg)
|
| 181 |
+
log_html = f'<div style="height: 500px; overflow-y: auto; padding: 10px; background: #fff;">{log_content}</div>'
|
| 182 |
+
current_report = report_content + "\n\n" if report_content else loading_html
|
| 183 |
+
|
| 184 |
+
yield (dashboard_html, log_html, current_report, tasks_html, sess.to_dict())
|
| 185 |
+
|
| 186 |
+
if evt_type in ["report_stream", "reasoning_update"]:
|
| 187 |
+
yield (dashboard_html, log_html, current_report, tasks_html, sess.to_dict())
|
| 188 |
+
|
| 189 |
+
if evt_type == "complete":
|
| 190 |
+
final_report = event.get("report_html", report_content)
|
| 191 |
+
final_log = f"""
|
| 192 |
+
<div style="margin-bottom: 8px; border-left: 3px solid #10b981; padding-left: 10px; background: #ecfdf5; padding: 10px;">
|
| 193 |
+
<div style="font-weight: bold; color: #059669;">✅ Planning Completed</div>
|
| 194 |
+
</div>
|
| 195 |
+
{log_content}
|
| 196 |
+
"""
|
| 197 |
+
final_log_html = f'<div style="height: 500px; overflow-y: auto; padding: 10px; background: #fff;">{final_log}</div>'
|
| 198 |
+
|
| 199 |
+
yield (
|
| 200 |
+
self._get_dashboard_html('team', 'complete', 'Planning Finished'),
|
| 201 |
+
final_log_html,
|
| 202 |
+
final_report,
|
| 203 |
+
tasks_html,
|
| 204 |
+
sess.to_dict()
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
except Exception as e:
|
| 208 |
+
error_msg = str(e)
|
| 209 |
+
gr.Warning(f"⚠️ Planning Interrupted: {error_msg}")
|
| 210 |
+
|
| 211 |
+
# 這裡我們做一個特殊的 UI 操作:
|
| 212 |
+
# 雖然這個 Wrapper 的 outputs 綁定的是 Step 3 的內部元件
|
| 213 |
+
# 但我們可以透過修改 Step 3 的 "Log" 或 "Report" 區域來顯示「重試按鈕」
|
| 214 |
+
# 或者 (更進階做法) 在 app.py 綁定 outputs 時,就把 Step 2 container 也包進來。
|
| 215 |
+
|
| 216 |
+
# 💡 最簡單不改動 outputs 結構的做法:
|
| 217 |
+
# 在 Log 區域顯示紅色錯誤,並提示用戶手動點擊 "Back"
|
| 218 |
+
|
| 219 |
+
error_html = f"""
|
| 220 |
+
<div style="padding: 20px; background: #fee2e2; border: 1px solid #ef4444; border-radius: 8px; color: #b91c1c;">
|
| 221 |
+
<strong>❌ Error Occurred:</strong><br>{error_msg}<br><br>
|
| 222 |
+
Please check your API keys or modify tasks, then try again.
|
| 223 |
+
</div>
|
| 224 |
+
"""
|
| 225 |
+
|
| 226 |
+
# 回傳錯誤訊息到 UI,讓使用者知道發生了什麼
|
| 227 |
+
yield (
|
| 228 |
+
self._get_dashboard_html('team', 'error', 'Failed'), # Dashboard 變紅
|
| 229 |
+
error_html, # Log 顯示錯誤
|
| 230 |
+
"",
|
| 231 |
+
tasks_html,
|
| 232 |
+
session.to_dict()
|
| 233 |
+
)
|
| 234 |
|
| 235 |
def step4_wrapper(self, session_data):
|
| 236 |
session = UserSession.from_dict(session_data)
|
|
|
|
| 254 |
session.to_dict()
|
| 255 |
)
|
| 256 |
|
| 257 |
+
def save_settings(self, g, w, prov, m_key, m_sel, fast, g_key_in, s_data):
|
| 258 |
+
sess = UserSession.from_dict(s_data)
|
| 259 |
+
|
| 260 |
+
# 存入 Session
|
| 261 |
+
sess.custom_settings.update({
|
| 262 |
+
'google_maps_api_key': g,
|
| 263 |
+
'openweather_api_key': w,
|
| 264 |
+
'llm_provider': prov,
|
| 265 |
+
'model_api_key': m_key, # 主模型 Key
|
| 266 |
+
'model': m_sel, # 主模型 ID
|
| 267 |
+
'enable_fast_mode': fast, # 🔥 Fast Mode 開關
|
| 268 |
+
'groq_api_key': g_key_in # 🔥 獨立 Groq Key
|
| 269 |
+
})
|
| 270 |
+
return gr.update(visible=False), sess.to_dict(), "✅ Configuration Saved"
|
| 271 |
# ================= Main UI Builder =================
|
| 272 |
|
| 273 |
def build_interface(self):
|
|
|
|
| 286 |
session_state = gr.State(UserSession().to_dict())
|
| 287 |
|
| 288 |
with gr.Column(elem_classes="step-container"):
|
| 289 |
+
home_btn, settings_btn, doc_btn = create_header() #theme_btn
|
| 290 |
|
| 291 |
stepper = create_progress_stepper(1)
|
| 292 |
status_bar = gr.Markdown("Ready", visible=False)
|
|
|
|
| 318 |
# STEP 3
|
| 319 |
with gr.Group(visible=False, elem_classes="step-container") as step3_container:
|
| 320 |
gr.Markdown("### 🤖 AI Team Operations")
|
| 321 |
+
|
| 322 |
+
# 1. Agent Dashboard (保持不變)
|
| 323 |
with gr.Group(elem_classes="agent-dashboard-container"):
|
| 324 |
agent_dashboard = gr.HTML(value=self._get_dashboard_html())
|
| 325 |
|
| 326 |
+
# 2. 主要內容區 (左右分欄)
|
| 327 |
with gr.Row():
|
| 328 |
+
# 🔥 左側:主要報告顯示區 (佔 3/4 寬度)
|
| 329 |
+
with gr.Column(scale=3):
|
| 330 |
with gr.Tabs():
|
| 331 |
with gr.Tab("📝 Full Report"):
|
| 332 |
+
# 🔥 修正 2: 加入白底容器 (live-report-wrapper)
|
| 333 |
with gr.Group(elem_classes="live-report-wrapper"):
|
| 334 |
live_report_md = gr.Markdown()
|
| 335 |
+
|
| 336 |
with gr.Tab("📋 Task List"):
|
| 337 |
with gr.Group(elem_classes="panel-container"):
|
| 338 |
with gr.Group(elem_classes="scrollable-content"):
|
| 339 |
task_list_s3 = gr.HTML()
|
| 340 |
+
|
| 341 |
+
# 🔥 右側:控制區與日誌 (佔 1/4 寬度)
|
| 342 |
with gr.Column(scale=1):
|
| 343 |
+
# 🔥 修正 1: 將停止按鈕移到這裡 (右側欄位頂部)
|
| 344 |
+
# variant="stop" 會呈現紅色,更符合 "緊急停止" 的語意
|
| 345 |
+
cancel_plan_btn = gr.Button("🛑 Stop & Back to Edit", variant="stop")
|
| 346 |
+
|
| 347 |
gr.Markdown("### ⚡ Activity Log")
|
| 348 |
with gr.Group(elem_classes="panel-container"):
|
| 349 |
planning_log = gr.HTML(value="Waiting...")
|
|
|
|
| 358 |
summary_tab_output = gr.HTML()
|
| 359 |
|
| 360 |
with gr.Tab("📝 Full Report"):
|
| 361 |
+
with gr.Group(elem_classes="live-report-wrapper"):
|
| 362 |
+
report_tab_output = gr.Markdown()
|
| 363 |
|
| 364 |
with gr.Tab("📋 Task List"):
|
| 365 |
task_list_tab_output = gr.HTML()
|
| 366 |
|
| 367 |
# Right: Hero Map
|
| 368 |
with gr.Column(scale=2, elem_classes="split-right-panel"):
|
|
|
|
| 369 |
map_view = gr.HTML(label="Route Map")
|
| 370 |
|
| 371 |
# Modals & Events
|
| 372 |
+
(settings_modal,
|
| 373 |
+
g_key, g_stat,
|
| 374 |
+
w_key, w_stat,
|
| 375 |
+
llm_provider, main_key, main_stat, model_sel,
|
| 376 |
+
fast_mode_chk, groq_model_sel, groq_key, groq_stat,
|
| 377 |
+
close_set, save_set, set_stat) = create_settings_modal()
|
| 378 |
+
|
| 379 |
+
# 2. 綁定 Google Maps 測試
|
| 380 |
+
g_key.blur(
|
| 381 |
+
fn=lambda k: "⏳ Checking..." if k else "", # 先顯示 checking
|
| 382 |
+
inputs=[g_key], outputs=[g_stat]
|
| 383 |
+
).then(
|
| 384 |
+
fn=APIValidator.test_google_maps,
|
| 385 |
+
inputs=[g_key],
|
| 386 |
+
outputs=[g_stat]
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
# 3. OpenWeather 自動驗證
|
| 390 |
+
w_key.blur(
|
| 391 |
+
fn=lambda k: "⏳ Checking..." if k else "",
|
| 392 |
+
inputs=[w_key], outputs=[w_stat]
|
| 393 |
+
).then(
|
| 394 |
+
fn=APIValidator.test_openweather,
|
| 395 |
+
inputs=[w_key],
|
| 396 |
+
outputs=[w_stat]
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
# 4. 綁定 Main Brain 測試
|
| 400 |
+
main_key.blur(
|
| 401 |
+
fn=lambda k: "⏳ Checking..." if k else "",
|
| 402 |
+
inputs=[main_key], outputs=[main_stat]
|
| 403 |
+
).then(
|
| 404 |
+
fn=APIValidator.test_llm,
|
| 405 |
+
inputs=[llm_provider, main_key, model_sel],
|
| 406 |
+
outputs=[main_stat]
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
# 5. Groq 自動驗證
|
| 410 |
+
groq_key.blur(
|
| 411 |
+
fn=lambda k: "⏳ Checking..." if k else "",
|
| 412 |
+
inputs=[groq_key], outputs=[groq_stat]
|
| 413 |
+
).then(
|
| 414 |
+
fn=lambda k, m: APIValidator.test_llm("Groq", k, m),
|
| 415 |
+
inputs=[groq_key, groq_model_sel],
|
| 416 |
+
outputs=[groq_stat]
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
def resolve_groq_visibility(fast_mode, provider):
|
| 420 |
+
"""
|
| 421 |
+
綜合判斷 Groq 相關欄位是否該顯示
|
| 422 |
+
回傳: (Model選單狀態, Key輸入框狀態, 狀態訊息狀態)
|
| 423 |
+
"""
|
| 424 |
+
# 1. 如果沒開 Fast Mode -> 全部隱藏 + 清空狀態
|
| 425 |
+
if not fast_mode:
|
| 426 |
+
return (
|
| 427 |
+
gr.update(visible=False),
|
| 428 |
+
gr.update(visible=False),
|
| 429 |
+
gr.update(visible=False) # 🔥 清空狀態
|
| 430 |
+
)
|
| 431 |
+
|
| 432 |
+
# 2. 如果開了 Fast Mode
|
| 433 |
+
model_vis = gr.update(visible=True)
|
| 434 |
+
|
| 435 |
+
# 3. 判斷 Key 是否需要顯示
|
| 436 |
+
is_main_groq = (provider == "Groq")
|
| 437 |
+
|
| 438 |
+
if is_main_groq:
|
| 439 |
+
# 如果主 Provider 是 Groq,隱藏 Key 並清空狀態 (避免誤導)
|
| 440 |
+
return (
|
| 441 |
+
model_vis,
|
| 442 |
+
gr.update(visible=False),
|
| 443 |
+
gr.update(visible=False) # 🔥 清空狀態
|
| 444 |
+
)
|
| 445 |
+
else:
|
| 446 |
+
# 否則顯示 Key,狀態保持原樣 (不更動)
|
| 447 |
+
return (
|
| 448 |
+
model_vis,
|
| 449 |
+
gr.update(visible=True),
|
| 450 |
+
gr.update(visible=True) # 保持原樣
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
fast_mode_chk.change(
|
| 454 |
+
fn=resolve_groq_visibility,
|
| 455 |
+
inputs=[fast_mode_chk, llm_provider],
|
| 456 |
+
outputs=[groq_model_sel, groq_key, groq_stat] # 🔥 新增 groq_stat
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
# --- 事件 B: 當 "Main Provider" 改變時 ---
|
| 460 |
+
def on_provider_change(provider, fast_mode):
|
| 461 |
+
# 1. 更新 Model 下拉選單 (原有邏輯)
|
| 462 |
+
new_choices = MODEL_OPTIONS.get(provider, [])
|
| 463 |
+
default_val = new_choices[0][1] if new_choices else ""
|
| 464 |
+
model_update = gr.Dropdown(choices=new_choices, value=default_val)
|
| 465 |
+
|
| 466 |
+
# 2. 清空 Main API Key (原有邏輯)
|
| 467 |
+
key_clear = gr.Textbox(value="")
|
| 468 |
+
stat_clear = gr.Markdown(value="")
|
| 469 |
+
|
| 470 |
+
# 3. 重算 Groq 欄位可見性 (包含狀態清空)
|
| 471 |
+
g_model_vis, g_key_vis, g_stat_vis = resolve_groq_visibility(fast_mode, provider)
|
| 472 |
+
|
| 473 |
+
# 回傳順序要對應下面的 outputs
|
| 474 |
+
return (
|
| 475 |
+
model_update, # model_sel
|
| 476 |
+
key_clear, # main_key
|
| 477 |
+
stat_clear, # main_stat
|
| 478 |
+
g_model_vis, # groq_model_sel
|
| 479 |
+
g_key_vis, # groq_key
|
| 480 |
+
g_stat_vis # groq_stat (新)
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
llm_provider.change(
|
| 484 |
+
fn=on_provider_change,
|
| 485 |
+
inputs=[llm_provider, fast_mode_chk],
|
| 486 |
+
# 🔥 outputs 增加到 6 個,確保所有狀態都被重置
|
| 487 |
+
outputs=[model_sel, main_key, main_stat, groq_model_sel, groq_key, groq_stat]
|
| 488 |
+
)
|
| 489 |
|
| 490 |
doc_modal, close_doc_btn = create_doc_modal()
|
| 491 |
|
|
|
|
| 493 |
close_set.click(fn=lambda: gr.update(visible=False), outputs=[settings_modal])
|
| 494 |
doc_btn.click(fn=lambda: gr.update(visible=True), outputs=[doc_modal])
|
| 495 |
close_doc_btn.click(fn=lambda: gr.update(visible=False), outputs=[doc_modal])
|
| 496 |
+
#theme_btn.click(fn=None, js="() => { document.querySelector('.gradio-container').classList.toggle('theme-dark'); }")
|
| 497 |
|
| 498 |
analyze_btn.click(
|
| 499 |
fn=self.analyze_wrapper,
|
| 500 |
+
inputs=[user_input, auto_loc, lat_in, lon_in, session_state,],
|
| 501 |
outputs=[step1_container, step2_container, step3_container, s1_stream_output, task_list_box, task_summary_box, chatbot, session_state]
|
| 502 |
).then(fn=lambda: update_stepper(2), outputs=[stepper])
|
| 503 |
|
|
|
|
| 514 |
show_progress="hidden"
|
| 515 |
)
|
| 516 |
|
| 517 |
+
cancel_plan_btn.click(
|
| 518 |
+
fn=self.cancel_wrapper, # 1. 先執行取消
|
| 519 |
+
inputs=[session_state],
|
| 520 |
+
outputs=None
|
| 521 |
+
).then(
|
| 522 |
+
fn=lambda: (gr.update(visible=True), gr.update(visible=False), update_stepper(2)), # 2. 再切換 UI
|
| 523 |
+
inputs=None,
|
| 524 |
+
outputs=[step2_container, step3_container, stepper]
|
| 525 |
+
)
|
| 526 |
+
|
| 527 |
step3_start.then(
|
| 528 |
fn=self.step4_wrapper,
|
| 529 |
inputs=[session_state],
|
|
|
|
| 545 |
new_session.custom_settings = old_session.custom_settings
|
| 546 |
return (gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), update_stepper(1), new_session.to_dict(), "")
|
| 547 |
|
| 548 |
+
home_btn.click(
|
| 549 |
+
fn=self.cancel_wrapper, # 1. 先執行取消 (防止後台繼續跑)
|
| 550 |
+
inputs=[session_state],
|
| 551 |
+
outputs=None
|
| 552 |
+
).then(
|
| 553 |
+
fn=reset_all, # 2. 再重置 UI
|
| 554 |
+
inputs=[session_state],
|
| 555 |
+
outputs=[step1_container, step2_container, step3_container, step4_container, stepper, session_state,
|
| 556 |
+
user_input]
|
| 557 |
+
)
|
| 558 |
+
|
| 559 |
+
back_btn.click(
|
| 560 |
+
fn=self.cancel_wrapper,
|
| 561 |
+
inputs=[session_state],
|
| 562 |
+
outputs=None
|
| 563 |
+
).then(
|
| 564 |
+
fn=reset_all,
|
| 565 |
+
inputs=[session_state],
|
| 566 |
+
outputs=[step1_container, step2_container, step3_container, step4_container, stepper, session_state,
|
| 567 |
+
user_input]
|
| 568 |
+
)
|
| 569 |
+
|
| 570 |
+
save_set.click(
|
| 571 |
+
fn=self.save_settings,
|
| 572 |
+
# 輸入參數對應上面的 create_settings_modal 回傳順序
|
| 573 |
+
inputs=[g_key, w_key, llm_provider, main_key, model_sel, fast_mode_chk, groq_key, session_state],
|
| 574 |
+
outputs=[settings_modal, session_state, status_bar]
|
| 575 |
+
)
|
| 576 |
+
|
| 577 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
|
| 579 |
auto_loc.change(fn=toggle_location_inputs, inputs=auto_loc, outputs=loc_group)
|
| 580 |
demo.load(fn=self._check_api_status, inputs=[session_state], outputs=[status_bar])
|
| 581 |
|
| 582 |
return demo
|
| 583 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 584 |
def main():
|
| 585 |
app = LifeFlowAI()
|
| 586 |
demo = app.build_interface()
|
| 587 |
+
demo.launch(server_name="0.0.0.0", server_port=8080, share=True, show_error=True)
|
| 588 |
#7860
|
| 589 |
if __name__ == "__main__":
|
| 590 |
main()
|
|
@@ -6,9 +6,40 @@ LifeFlow AI - Configuration
|
|
| 6 |
import os
|
| 7 |
from pathlib import Path
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
# ===== 應用配置 =====
|
| 10 |
APP_TITLE = "LifeFlow AI - Intelligent Daily Trip Planner"
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
# ===== Agent 資訊配置 (前端顯示用) =====
|
| 13 |
AGENTS_INFO = {
|
| 14 |
'planner': {
|
|
|
|
| 6 |
import os
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
+
# ===== 系統預設值 =====
|
| 10 |
+
DEFAULT_PROVIDER = "Gemini"
|
| 11 |
+
DEFAULT_MODEL = "gemini-2.5-flash"
|
| 12 |
+
|
| 13 |
# ===== 應用配置 =====
|
| 14 |
APP_TITLE = "LifeFlow AI - Intelligent Daily Trip Planner"
|
| 15 |
|
| 16 |
+
# ===== 模型白名單配置 (Model Allowlist) =====
|
| 17 |
+
# 格式: Key 是 Provider 名稱, Value 是 (顯示名稱, API Model ID) 的列表
|
| 18 |
+
MODEL_OPTIONS = {
|
| 19 |
+
"Gemini": [
|
| 20 |
+
# 平衡型 (預設)
|
| 21 |
+
("Gemini 2.5 Flash (🔥 Best Value)", "gemini-2.5-flash"),
|
| 22 |
+
# 速度型 (舊版 Flash 其實不用列,除非它有特殊用途,不然建議隱藏)
|
| 23 |
+
("Gemini 2.0 Flash (⚡ High Speed)", "gemini-2.0-flash"),
|
| 24 |
+
# 智力型
|
| 25 |
+
("Gemini 2.5 Pro (🧠 Max Intelligence)", "gemini-2.5-pro"),
|
| 26 |
+
],
|
| 27 |
+
"OpenAI": [
|
| 28 |
+
("GPT-5 Mini (⚖️ Smart & Fast)", "gpt-5-mini"),
|
| 29 |
+
|
| 30 |
+
("GPT-4o Mini (💰 Cost Saver)", "gpt-4o-mini"),
|
| 31 |
+
# 旗艦型
|
| 32 |
+
("GPT-5 (🌟 Ultimate Quality)", "gpt-5"),
|
| 33 |
+
],
|
| 34 |
+
"Groq": [
|
| 35 |
+
("Llama 3.3 70B (🚀 Ultra Fast [Alpha])", "llama-3.3-70b-versatile"),
|
| 36 |
+
|
| 37 |
+
("GPT-OSS 120B (💪 Reasoning Powerhouse [Alpha])", "openai/gpt-oss-120b"),
|
| 38 |
+
|
| 39 |
+
("Kimi K2 (💪 Long Context Expert [Alpha])", "moonshotai/kimi-k2-instruct-0905"),
|
| 40 |
+
]
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
# ===== Agent 資訊配置 (前端顯示用) =====
|
| 44 |
AGENTS_INFO = {
|
| 45 |
'planner': {
|
|
@@ -5,7 +5,6 @@ from src.infra.logger import get_logger
|
|
| 5 |
|
| 6 |
logger = get_logger(__name__)
|
| 7 |
|
| 8 |
-
# --- CSS 樣式:讓 Popup 變成漂亮的資訊卡片 ---
|
| 9 |
CSS_STYLE = """
|
| 10 |
<style>
|
| 11 |
.map-card {
|
|
@@ -84,19 +83,17 @@ def create_popup_html(title, subtitle, color, metrics, is_alternative=False):
|
|
| 84 |
"""
|
| 85 |
產生漂亮的 HTML 卡片字串
|
| 86 |
"""
|
| 87 |
-
# 定義顏色代碼
|
| 88 |
bg_color = {
|
| 89 |
-
'green': '#2ecc71',
|
| 90 |
-
'blue': '#3498db',
|
| 91 |
-
'red': '#e74c3c',
|
| 92 |
-
'gray': '#7f8c8d',
|
| 93 |
-
'route': '#4285F4'
|
| 94 |
}.get(color, '#34495e')
|
| 95 |
|
| 96 |
-
# 構建數據網格
|
| 97 |
grid_html = ""
|
| 98 |
for label, value in metrics.items():
|
| 99 |
-
if value:
|
| 100 |
grid_html += f"""
|
| 101 |
<div class="info-item">
|
| 102 |
<div class="info-label">{label}</div>
|
|
@@ -104,11 +101,10 @@ def create_popup_html(title, subtitle, color, metrics, is_alternative=False):
|
|
| 104 |
</div>
|
| 105 |
"""
|
| 106 |
|
| 107 |
-
# 備用方案的額外標示
|
| 108 |
extra_html = ""
|
| 109 |
if is_alternative:
|
| 110 |
extra_html = """
|
| 111 |
-
<div class="cost-badge">⚠️
|
| 112 |
"""
|
| 113 |
|
| 114 |
html = f"""
|
|
@@ -132,16 +128,14 @@ def create_popup_html(title, subtitle, color, metrics, is_alternative=False):
|
|
| 132 |
def create_animated_map(structured_data=None):
|
| 133 |
"""
|
| 134 |
LifeFlow AI - Interactive Map Generator
|
| 135 |
-
產生包含完整資訊、圖層控制與美化 UI 的 HTML 地圖。
|
| 136 |
"""
|
| 137 |
-
# 1. 初始化地圖
|
| 138 |
center_lat, center_lon = 25.033, 121.565
|
| 139 |
m = folium.Map(location=[center_lat, center_lon], zoom_start=13, tiles="OpenStreetMap",
|
| 140 |
-
height=
|
| 141 |
width="100%"
|
| 142 |
)
|
| 143 |
|
| 144 |
-
# 注入 CSS 樣式
|
| 145 |
m.get_root().html.add_child(folium.Element(CSS_STYLE))
|
| 146 |
|
| 147 |
if not structured_data:
|
|
@@ -154,32 +148,36 @@ def create_animated_map(structured_data=None):
|
|
| 154 |
legs = precise_result.get("legs", [])
|
| 155 |
tasks_detail = structured_data.get("tasks_detail", [])
|
| 156 |
raw_tasks = structured_data.get("tasks", [])
|
|
|
|
| 157 |
|
| 158 |
-
# 建立查找表
|
| 159 |
-
# Stop Index -> Location Name (用於路線顯示)
|
| 160 |
index_to_name = {stop.get("stop_index"): stop.get("location") for stop in timeline}
|
| 161 |
|
| 162 |
-
# POI ID -> Name (用於替代點顯示,解決只有 ID 沒有名字的問題)
|
| 163 |
poi_id_to_name = {}
|
| 164 |
for t in raw_tasks:
|
| 165 |
for cand in t.get("candidates", []):
|
| 166 |
if cand.get("poi_id"):
|
| 167 |
poi_id_to_name[cand["poi_id"]] = cand.get("name")
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
bounds = []
|
| 170 |
|
| 171 |
-
# 定義主題色輪替
|
| 172 |
THEMES = [
|
| 173 |
-
('#2ecc71', 'green'),
|
| 174 |
-
('#3498db', 'blue'),
|
| 175 |
-
('#e74c3c', 'red'),
|
| 176 |
-
('#f39c12', 'orange')
|
| 177 |
]
|
| 178 |
|
| 179 |
-
# --- Layer 1: 路線
|
| 180 |
route_group = folium.FeatureGroup(name="🚗 Main Route", show=True)
|
| 181 |
|
| 182 |
-
# 1.1 背景特效 (AntPath)
|
| 183 |
all_coords = []
|
| 184 |
for leg in legs:
|
| 185 |
if leg.get("polyline"):
|
|
@@ -191,15 +189,11 @@ def create_animated_map(structured_data=None):
|
|
| 191 |
opacity=0.2, pulse_color='#FFFFFF', hardware_acceleration=True
|
| 192 |
).add_to(route_group)
|
| 193 |
|
| 194 |
-
# 1.2 前景互動線 (PolyLine)
|
| 195 |
for i, leg in enumerate(legs):
|
| 196 |
poly_str = leg.get("polyline")
|
| 197 |
if not poly_str: continue
|
| 198 |
-
|
| 199 |
decoded = decode_polyline(poly_str)
|
| 200 |
bounds.extend(decoded)
|
| 201 |
-
|
| 202 |
-
# 準備數據
|
| 203 |
dist = leg.get("distance_meters", 0)
|
| 204 |
dur = leg.get("duration_seconds", 0) // 60
|
| 205 |
from_idx = leg.get("from_index")
|
|
@@ -211,12 +205,8 @@ def create_animated_map(structured_data=None):
|
|
| 211 |
title=f"LEG {i + 1} ROUTE",
|
| 212 |
subtitle=f"{from_n} ➔ {to_n}",
|
| 213 |
color="route",
|
| 214 |
-
metrics={
|
| 215 |
-
"Duration": f"{dur} min",
|
| 216 |
-
"Distance": f"{dist / 1000:.1f} km"
|
| 217 |
-
}
|
| 218 |
)
|
| 219 |
-
|
| 220 |
folium.PolyLine(
|
| 221 |
locations=decoded, color="#4285F4", weight=6, opacity=0.9,
|
| 222 |
tooltip=f"Leg {i + 1}: {dur} min",
|
|
@@ -226,13 +216,20 @@ def create_animated_map(structured_data=None):
|
|
| 226 |
route_group.add_to(m)
|
| 227 |
|
| 228 |
# --- Layer 2: 備用方案 (Alternatives) ---
|
| 229 |
-
# 依據 Task 分組,讓使用者可以開關特定站點的備用方案
|
| 230 |
for idx, task in enumerate(tasks_detail):
|
| 231 |
-
# 決定顏色主題 (跳過起點,對應到中間站點)
|
| 232 |
theme_idx = (idx + 1) % len(THEMES)
|
| 233 |
theme_color, theme_name = THEMES[theme_idx]
|
| 234 |
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
alt_group = folium.FeatureGroup(name=group_name, show=True)
|
| 237 |
|
| 238 |
chosen = task.get("chosen_poi", {})
|
|
@@ -243,14 +240,11 @@ def create_animated_map(structured_data=None):
|
|
| 243 |
alat, alng = alt.get("lat"), alt.get("lng")
|
| 244 |
if alat and alng:
|
| 245 |
bounds.append([alat, alng])
|
| 246 |
-
|
| 247 |
-
# A. 虛線連接 (主站點 -> 備用點)
|
| 248 |
folium.PolyLine(
|
| 249 |
locations=[[center_lat, center_lng], [alat, alng]],
|
| 250 |
color=theme_color, weight=2, dash_array='5, 5', opacity=0.6
|
| 251 |
).add_to(alt_group)
|
| 252 |
|
| 253 |
-
# B. 備用點 Marker
|
| 254 |
poi_name = poi_id_to_name.get(alt.get("poi_id"), "Alternative Option")
|
| 255 |
extra_min = alt.get("delta_travel_time_min", 0)
|
| 256 |
extra_dist = alt.get("delta_travel_distance_m", 0)
|
|
@@ -275,41 +269,31 @@ def create_animated_map(structured_data=None):
|
|
| 275 |
|
| 276 |
alt_group.add_to(m)
|
| 277 |
|
| 278 |
-
# --- Layer 3: 主要站點
|
| 279 |
stops_group = folium.FeatureGroup(name="📍 Main travel stops", show=True)
|
| 280 |
-
|
| 281 |
for i, stop in enumerate(timeline):
|
| 282 |
coords = stop.get("coordinates", {})
|
| 283 |
lat, lng = coords.get("lat"), coords.get("lng")
|
| 284 |
-
|
| 285 |
if lat and lng:
|
| 286 |
bounds.append([lat, lng])
|
| 287 |
-
|
| 288 |
-
# 決定顏色
|
| 289 |
if i == 0:
|
| 290 |
-
color_code, theme_name = THEMES[0]
|
| 291 |
elif i == len(timeline) - 1:
|
| 292 |
-
color_code, theme_name = THEMES[2]
|
| 293 |
else:
|
| 294 |
-
color_code, theme_name = THEMES[1]
|
| 295 |
|
| 296 |
loc_name = stop.get("location", "")
|
| 297 |
-
|
| 298 |
popup_html = create_popup_html(
|
| 299 |
-
title=f"STOP {i + 1}",
|
| 300 |
-
subtitle=loc_name,
|
| 301 |
-
color=theme_name,
|
| 302 |
metrics={
|
| 303 |
"Arrival time": stop.get("time", ""),
|
| 304 |
"Weather": stop.get("weather", ""),
|
| 305 |
"Air quality": stop.get("aqi", {}).get("label", "")
|
| 306 |
}
|
| 307 |
)
|
| 308 |
-
|
| 309 |
-
# Icon 設定
|
| 310 |
-
icon_type = 'flag-checkered' if i == len(timeline) - 1 else ('play' if i == 0 else 'utensils')
|
| 311 |
icon = folium.Icon(color=theme_name, icon=icon_type, prefix='fa')
|
| 312 |
-
|
| 313 |
folium.Marker(
|
| 314 |
location=[lat, lng], icon=icon,
|
| 315 |
popup=folium.Popup(popup_html, max_width=320),
|
|
@@ -319,17 +303,19 @@ def create_animated_map(structured_data=None):
|
|
| 319 |
stops_group.add_to(m)
|
| 320 |
|
| 321 |
# --- 控制元件 ---
|
| 322 |
-
|
|
|
|
| 323 |
|
| 324 |
if bounds:
|
| 325 |
m.fit_bounds(bounds, padding=(50, 50))
|
| 326 |
|
|
|
|
| 327 |
except Exception as e:
|
| 328 |
logger.error(f"Folium map error: {e}", exc_info=True)
|
| 329 |
return m._repr_html_()
|
| 330 |
-
|
| 331 |
return m._repr_html_()
|
| 332 |
|
|
|
|
| 333 |
if __name__ == "__main__":
|
| 334 |
test_data = {'status': 'OK', 'total_travel_time_min': 19, 'total_travel_distance_m': 7567, 'metrics': {'total_tasks': 2, 'completed_tasks': 2, 'completion_rate_pct': 100.0, 'original_distance_m': 15478, 'optimized_distance_m': 7567, 'distance_saved_m': 7911, 'distance_improvement_pct': 51.1, 'original_duration_min': 249, 'optimized_duration_min': 229, 'time_saved_min': 20, 'time_improvement_pct': 8.4, 'route_efficiency_pct': 91.7}, 'route': [{'step': 0, 'node_index': 0, 'arrival_time': '2025-11-27T10:00:00+08:00', 'departure_time': '2025-11-27T10:00:00+08:00', 'type': 'depot', 'task_id': None, 'poi_id': None, 'service_duration_min': 0}, {'step': 1, 'node_index': 1, 'arrival_time': '2025-11-27T10:17:57+08:00', 'departure_time': '2025-11-27T12:17:57+08:00', 'type': 'task_poi', 'task_id': '1', 'poi_id': 'ChIJH56c2rarQjQRphD9gvC8BhI', 'service_duration_min': 120}, {'step': 2, 'node_index': 7, 'arrival_time': '2025-11-27T12:19:05+08:00', 'departure_time': '2025-11-27T13:49:05+08:00', 'type': 'task_poi', 'task_id': '2', 'poi_id': 'ChIJQXcl6LarQjQRGUMnQ18F0lE', 'service_duration_min': 90}], 'visited_tasks': ['1', '2'], 'skipped_tasks': [], 'tasks_detail': [{'task_id': '1', 'priority': 'HIGH', 'visited': True, 'chosen_poi': {'node_index': 1, 'poi_id': 'ChIJH56c2rarQjQRphD9gvC8BhI', 'lat': 25.033976, 'lng': 121.56453889999999, 'interval_idx': 0}, 'alternative_pois': []}, {'task_id': '2', 'priority': 'HIGH', 'visited': True, 'chosen_poi': {'node_index': 7, 'poi_id': 'ChIJQXcl6LarQjQRGUMnQ18F0lE', 'lat': 25.033337099999997, 'lng': 121.56465960000001, 'interval_idx': 0}, 'alternative_pois': [{'node_index': 10, 'poi_id': 'ChIJwQWwVe6rQjQRRGA4WzYdO2U', 'lat': 25.036873699999997, 'lng': 121.5679503, 'interval_idx': 0, 'delta_travel_time_min': 7, 'delta_travel_distance_m': 2003}, {'node_index': 6, 'poi_id': 'ChIJ01XRzrurQjQRnp5ZsHbAAuE', 'lat': 25.039739800000003, 'lng': 121.5665985, 'interval_idx': 0, 'delta_travel_time_min': 9, 'delta_travel_distance_m': 2145}, {'node_index': 5, 'poi_id': 'ChIJaeY0sNCrQjQRBmpF8-RmywQ', 'lat': 25.0409656, 'lng': 121.5429975, 'interval_idx': 0, 'delta_travel_time_min': 24, 'delta_travel_distance_m': 6549}]}], 'tasks': [{'task_id': '1', 'priority': 'HIGH', 'service_duration_min': 120, 'time_window': {'earliest_time': '2025-11-27T10:00:00+08:00', 'latest_time': '2025-11-27T22:00:00+08:00'}, 'candidates': [{'poi_id': 'ChIJH56c2rarQjQRphD9gvC8BhI', 'name': 'Taipei 101', 'lat': 25.033976, 'lng': 121.56453889999999, 'rating': None, 'time_window': None}]}, {'task_id': '2', 'priority': 'HIGH', 'service_duration_min': 90, 'time_window': {'earliest_time': '2025-11-27T11:30:00+08:00', 'latest_time': '2025-11-27T14:30:00+08:00'}, 'candidates': [{'poi_id': 'ChIJbbvUtW6pQjQRLvK71hSUXN8', 'name': 'Din Tai Fung Mitsukoshi Nanxi Restaurant', 'lat': 25.0523074, 'lng': 121.5211037, 'rating': 4.4, 'time_window': None}, {'poi_id': 'ChIJA-U6X-epQjQR-T9BLmEfUlc', 'name': 'Din Tai Fung Xinsheng Branch', 'lat': 25.033889, 'lng': 121.5321338, 'rating': 4.6, 'time_window': None}, {'poi_id': 'ChIJbTKSE4KpQjQRXDZZI57v-pM', 'name': 'Din Tai Fung Xinyi Branch', 'lat': 25.0335035, 'lng': 121.53011799999999, 'rating': 4.4, 'time_window': None}, {'poi_id': 'ChIJaeY0sNCrQjQRBmpF8-RmywQ', 'name': 'Din Tai Fung Fuxing Restaurant', 'lat': 25.0409656, 'lng': 121.5429975, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJ01XRzrurQjQRnp5ZsHbAAuE', 'name': 'Din Tai Fung A4 Branch', 'lat': 25.039739800000003, 'lng': 121.5665985, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJQXcl6LarQjQRGUMnQ18F0lE', 'name': 'Din Tai Fung 101', 'lat': 25.033337099999997, 'lng': 121.56465960000001, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJ7y2qTJeuQjQRbpDcQImxLO0', 'name': 'Din Tai Fung Tianmu Restaurant', 'lat': 25.105072000000003, 'lng': 121.52447500000001, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJxRJqSBmoQjQR3gtSHvJgkZk', 'name': 'Din Tai Fung Mega City Restaurant', 'lat': 25.0135467, 'lng': 121.46675080000001, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJwQWwVe6rQjQRRGA4WzYdO2U', 'name': 'Din Tai Fung A13 Branch', 'lat': 25.036873699999997, 'lng': 121.5679503, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJ34CbayyoQjQRDbCGQgDk_RY', 'name': 'Ding Tai Feng', 'lat': 25.0059716, 'lng': 121.48668440000002, 'rating': 3.6, 'time_window': None}]}], 'global_info': {'language': 'en-US', 'plan_type': 'TRIP', 'departure_time': '2025-11-27T10:00:00+08:00', 'start_location': {'name': 'Taipei Main Station', 'lat': 25.0474428, 'lng': 121.5170955}}, 'traffic_summary': {'total_distance_km': 7.567, 'total_duration_min': 15}, 'precise_traffic_result': {'total_distance_meters': 7567, 'total_duration_seconds': 925, 'total_residence_time_minutes': 210, 'total_time_seconds': 13525, 'start_time': '2025-11-27 10:00:00+08:00', 'end_time': '2025-11-27 05:45:25+00:00', 'stops': [{'lat': 25.0474428, 'lng': 121.5170955}, {'lat': 25.033976, 'lng': 121.56453889999999}, {'lat': 25.033337099999997, 'lng': 121.56465960000001}], 'legs': [{'from_index': 0, 'to_index': 1, 'travel_mode': 'DRIVE', 'distance_meters': 7366, 'duration_seconds': 857, 'departure_time': '2025-11-27T02:00:00+00:00', 'polyline': 'm_{wCqttdVQbAu@KwDe@ELMTKl@C\\@LC^@PLPRJdC\\LLe@bEIj@_@|@I\\?P}EGMERqAAyAf@iD\\sC`AqKTmDJYXoDn@oFH{A`@{PNoB\\wChAyFfCaLrAcHnAyH|BeMZcCRmCNoGVcJPoI@{CIwBc@mHw@aNGeCD}BpBm^Ak@LyDB_@KmD]qDu@iGNs@SuASsBdUPZ@FcNP{ODcItBfANBxClA^D`A@As@D{@HyGpFBtD?pADlKB?|@FD`@@lAE'}, {'from_index': 1, 'to_index': 2, 'travel_mode': 'DRIVE', 'distance_meters': 201, 'duration_seconds': 68, 'departure_time': '2025-11-27T04:14:17+00:00', 'polyline': 'mmxwC}d~dVfBAXGJON]l@?CrC'}]}, 'solved_waypoints': [{'lat': 25.0474428, 'lng': 121.5170955}, {'lat': 25.033976, 'lng': 121.56453889999999}, {'lat': 25.033337099999997, 'lng': 121.56465960000001}], 'timeline': [{'stop_index': 0, 'time': '10:00', 'location': 'start point', 'address': '', 'weather': 'Rain, 20.76°C', 'aqi': {'aqi': 2, 'label': 'AQI 2 🟡'}, 'travel_time_from_prev': '0 mins', 'coordinates': {'lat': 25.0474428, 'lng': 121.5170955}}, {'stop_index': 1, 'time': '10:14', 'location': 'Taipei 101', 'address': '', 'weather': 'Rain, 20.75°C', 'aqi': {'aqi': 2, 'label': 'AQI 2 🟡'}, 'travel_time_from_prev': '14 mins', 'coordinates': {'lat': 25.033976, 'lng': 121.56453889999999}}, {'stop_index': 2, 'time': '12:15', 'location': 'Din Tai Fung Mitsukoshi Nanxi Restaurant', 'address': '', 'weather': 'Rain, 20.75°C', 'aqi': {'aqi': 2, 'label': 'AQI 2 🟡'}, 'travel_time_from_prev': '1 mins', 'coordinates': {'lat': 25.033337099999997, 'lng': 121.56465960000001}}]}
|
| 335 |
|
|
|
|
| 5 |
|
| 6 |
logger = get_logger(__name__)
|
| 7 |
|
|
|
|
| 8 |
CSS_STYLE = """
|
| 9 |
<style>
|
| 10 |
.map-card {
|
|
|
|
| 83 |
"""
|
| 84 |
產生漂亮的 HTML 卡片字串
|
| 85 |
"""
|
|
|
|
| 86 |
bg_color = {
|
| 87 |
+
'green': '#2ecc71',
|
| 88 |
+
'blue': '#3498db',
|
| 89 |
+
'red': '#e74c3c',
|
| 90 |
+
'gray': '#7f8c8d',
|
| 91 |
+
'route': '#4285F4'
|
| 92 |
}.get(color, '#34495e')
|
| 93 |
|
|
|
|
| 94 |
grid_html = ""
|
| 95 |
for label, value in metrics.items():
|
| 96 |
+
if value:
|
| 97 |
grid_html += f"""
|
| 98 |
<div class="info-item">
|
| 99 |
<div class="info-label">{label}</div>
|
|
|
|
| 101 |
</div>
|
| 102 |
"""
|
| 103 |
|
|
|
|
| 104 |
extra_html = ""
|
| 105 |
if is_alternative:
|
| 106 |
extra_html = """
|
| 107 |
+
<div class="cost-badge">⚠️ Alternative Option</div>
|
| 108 |
"""
|
| 109 |
|
| 110 |
html = f"""
|
|
|
|
| 128 |
def create_animated_map(structured_data=None):
|
| 129 |
"""
|
| 130 |
LifeFlow AI - Interactive Map Generator
|
|
|
|
| 131 |
"""
|
| 132 |
+
# 1. 初始化地圖
|
| 133 |
center_lat, center_lon = 25.033, 121.565
|
| 134 |
m = folium.Map(location=[center_lat, center_lon], zoom_start=13, tiles="OpenStreetMap",
|
| 135 |
+
height=520,
|
| 136 |
width="100%"
|
| 137 |
)
|
| 138 |
|
|
|
|
| 139 |
m.get_root().html.add_child(folium.Element(CSS_STYLE))
|
| 140 |
|
| 141 |
if not structured_data:
|
|
|
|
| 148 |
legs = precise_result.get("legs", [])
|
| 149 |
tasks_detail = structured_data.get("tasks_detail", [])
|
| 150 |
raw_tasks = structured_data.get("tasks", [])
|
| 151 |
+
route_info = structured_data.get("route", [])
|
| 152 |
|
| 153 |
+
# 建立查找表
|
|
|
|
| 154 |
index_to_name = {stop.get("stop_index"): stop.get("location") for stop in timeline}
|
| 155 |
|
|
|
|
| 156 |
poi_id_to_name = {}
|
| 157 |
for t in raw_tasks:
|
| 158 |
for cand in t.get("candidates", []):
|
| 159 |
if cand.get("poi_id"):
|
| 160 |
poi_id_to_name[cand["poi_id"]] = cand.get("name")
|
| 161 |
|
| 162 |
+
# 🔥 新增:Task ID 對應到 Stop Sequence (第幾站)
|
| 163 |
+
# 用來顯示 "Stop 1", "Stop 2" 而不是 "Task 123"
|
| 164 |
+
task_id_to_seq = {}
|
| 165 |
+
for r in route_info:
|
| 166 |
+
if r.get("task_id"):
|
| 167 |
+
task_id_to_seq[r["task_id"]] = r.get("step", 0)
|
| 168 |
+
|
| 169 |
bounds = []
|
| 170 |
|
|
|
|
| 171 |
THEMES = [
|
| 172 |
+
('#2ecc71', 'green'),
|
| 173 |
+
('#3498db', 'blue'),
|
| 174 |
+
('#e74c3c', 'red'),
|
| 175 |
+
('#f39c12', 'orange')
|
| 176 |
]
|
| 177 |
|
| 178 |
+
# --- Layer 1: 路線 ---
|
| 179 |
route_group = folium.FeatureGroup(name="🚗 Main Route", show=True)
|
| 180 |
|
|
|
|
| 181 |
all_coords = []
|
| 182 |
for leg in legs:
|
| 183 |
if leg.get("polyline"):
|
|
|
|
| 189 |
opacity=0.2, pulse_color='#FFFFFF', hardware_acceleration=True
|
| 190 |
).add_to(route_group)
|
| 191 |
|
|
|
|
| 192 |
for i, leg in enumerate(legs):
|
| 193 |
poly_str = leg.get("polyline")
|
| 194 |
if not poly_str: continue
|
|
|
|
| 195 |
decoded = decode_polyline(poly_str)
|
| 196 |
bounds.extend(decoded)
|
|
|
|
|
|
|
| 197 |
dist = leg.get("distance_meters", 0)
|
| 198 |
dur = leg.get("duration_seconds", 0) // 60
|
| 199 |
from_idx = leg.get("from_index")
|
|
|
|
| 205 |
title=f"LEG {i + 1} ROUTE",
|
| 206 |
subtitle=f"{from_n} ➔ {to_n}",
|
| 207 |
color="route",
|
| 208 |
+
metrics={"Duration": f"{dur} min", "Distance": f"{dist / 1000:.1f} km"}
|
|
|
|
|
|
|
|
|
|
| 209 |
)
|
|
|
|
| 210 |
folium.PolyLine(
|
| 211 |
locations=decoded, color="#4285F4", weight=6, opacity=0.9,
|
| 212 |
tooltip=f"Leg {i + 1}: {dur} min",
|
|
|
|
| 216 |
route_group.add_to(m)
|
| 217 |
|
| 218 |
# --- Layer 2: 備用方案 (Alternatives) ---
|
|
|
|
| 219 |
for idx, task in enumerate(tasks_detail):
|
|
|
|
| 220 |
theme_idx = (idx + 1) % len(THEMES)
|
| 221 |
theme_color, theme_name = THEMES[theme_idx]
|
| 222 |
|
| 223 |
+
# 🔥 優化 Group Name: 使用 Stop 順序和地點名稱
|
| 224 |
+
tid = task.get("task_id")
|
| 225 |
+
seq_num = task_id_to_seq.get(tid, "?") # 獲取順序 (如: 1, 2)
|
| 226 |
+
|
| 227 |
+
chosen_pid = task.get("chosen_poi", {}).get("poi_id")
|
| 228 |
+
loc_name = poi_id_to_name.get(chosen_pid, f"Place") # 獲取地點名稱
|
| 229 |
+
|
| 230 |
+
# 顯示格式: "🔹 Stop 1 Alt: Taipei 101"
|
| 231 |
+
group_name = f"🔹 Stop {seq_num} Alt: {loc_name}"
|
| 232 |
+
|
| 233 |
alt_group = folium.FeatureGroup(name=group_name, show=True)
|
| 234 |
|
| 235 |
chosen = task.get("chosen_poi", {})
|
|
|
|
| 240 |
alat, alng = alt.get("lat"), alt.get("lng")
|
| 241 |
if alat and alng:
|
| 242 |
bounds.append([alat, alng])
|
|
|
|
|
|
|
| 243 |
folium.PolyLine(
|
| 244 |
locations=[[center_lat, center_lng], [alat, alng]],
|
| 245 |
color=theme_color, weight=2, dash_array='5, 5', opacity=0.6
|
| 246 |
).add_to(alt_group)
|
| 247 |
|
|
|
|
| 248 |
poi_name = poi_id_to_name.get(alt.get("poi_id"), "Alternative Option")
|
| 249 |
extra_min = alt.get("delta_travel_time_min", 0)
|
| 250 |
extra_dist = alt.get("delta_travel_distance_m", 0)
|
|
|
|
| 269 |
|
| 270 |
alt_group.add_to(m)
|
| 271 |
|
| 272 |
+
# --- Layer 3: 主要站點 ---
|
| 273 |
stops_group = folium.FeatureGroup(name="📍 Main travel stops", show=True)
|
|
|
|
| 274 |
for i, stop in enumerate(timeline):
|
| 275 |
coords = stop.get("coordinates", {})
|
| 276 |
lat, lng = coords.get("lat"), coords.get("lng")
|
|
|
|
| 277 |
if lat and lng:
|
| 278 |
bounds.append([lat, lng])
|
|
|
|
|
|
|
| 279 |
if i == 0:
|
| 280 |
+
color_code, theme_name = THEMES[0]
|
| 281 |
elif i == len(timeline) - 1:
|
| 282 |
+
color_code, theme_name = THEMES[2]
|
| 283 |
else:
|
| 284 |
+
color_code, theme_name = THEMES[1]
|
| 285 |
|
| 286 |
loc_name = stop.get("location", "")
|
|
|
|
| 287 |
popup_html = create_popup_html(
|
| 288 |
+
title=f"STOP {i + 1}", subtitle=loc_name, color=theme_name,
|
|
|
|
|
|
|
| 289 |
metrics={
|
| 290 |
"Arrival time": stop.get("time", ""),
|
| 291 |
"Weather": stop.get("weather", ""),
|
| 292 |
"Air quality": stop.get("aqi", {}).get("label", "")
|
| 293 |
}
|
| 294 |
)
|
| 295 |
+
icon_type = 'flag-checkered' if i == len(timeline) - 1 else ('play' if i == 0 else 'map-marker')
|
|
|
|
|
|
|
| 296 |
icon = folium.Icon(color=theme_name, icon=icon_type, prefix='fa')
|
|
|
|
| 297 |
folium.Marker(
|
| 298 |
location=[lat, lng], icon=icon,
|
| 299 |
popup=folium.Popup(popup_html, max_width=320),
|
|
|
|
| 303 |
stops_group.add_to(m)
|
| 304 |
|
| 305 |
# --- 控制元件 ---
|
| 306 |
+
# 🔥 修改: collapsed=True 預設收合
|
| 307 |
+
folium.LayerControl(collapsed=True).add_to(m)
|
| 308 |
|
| 309 |
if bounds:
|
| 310 |
m.fit_bounds(bounds, padding=(50, 50))
|
| 311 |
|
| 312 |
+
|
| 313 |
except Exception as e:
|
| 314 |
logger.error(f"Folium map error: {e}", exc_info=True)
|
| 315 |
return m._repr_html_()
|
|
|
|
| 316 |
return m._repr_html_()
|
| 317 |
|
| 318 |
+
|
| 319 |
if __name__ == "__main__":
|
| 320 |
test_data = {'status': 'OK', 'total_travel_time_min': 19, 'total_travel_distance_m': 7567, 'metrics': {'total_tasks': 2, 'completed_tasks': 2, 'completion_rate_pct': 100.0, 'original_distance_m': 15478, 'optimized_distance_m': 7567, 'distance_saved_m': 7911, 'distance_improvement_pct': 51.1, 'original_duration_min': 249, 'optimized_duration_min': 229, 'time_saved_min': 20, 'time_improvement_pct': 8.4, 'route_efficiency_pct': 91.7}, 'route': [{'step': 0, 'node_index': 0, 'arrival_time': '2025-11-27T10:00:00+08:00', 'departure_time': '2025-11-27T10:00:00+08:00', 'type': 'depot', 'task_id': None, 'poi_id': None, 'service_duration_min': 0}, {'step': 1, 'node_index': 1, 'arrival_time': '2025-11-27T10:17:57+08:00', 'departure_time': '2025-11-27T12:17:57+08:00', 'type': 'task_poi', 'task_id': '1', 'poi_id': 'ChIJH56c2rarQjQRphD9gvC8BhI', 'service_duration_min': 120}, {'step': 2, 'node_index': 7, 'arrival_time': '2025-11-27T12:19:05+08:00', 'departure_time': '2025-11-27T13:49:05+08:00', 'type': 'task_poi', 'task_id': '2', 'poi_id': 'ChIJQXcl6LarQjQRGUMnQ18F0lE', 'service_duration_min': 90}], 'visited_tasks': ['1', '2'], 'skipped_tasks': [], 'tasks_detail': [{'task_id': '1', 'priority': 'HIGH', 'visited': True, 'chosen_poi': {'node_index': 1, 'poi_id': 'ChIJH56c2rarQjQRphD9gvC8BhI', 'lat': 25.033976, 'lng': 121.56453889999999, 'interval_idx': 0}, 'alternative_pois': []}, {'task_id': '2', 'priority': 'HIGH', 'visited': True, 'chosen_poi': {'node_index': 7, 'poi_id': 'ChIJQXcl6LarQjQRGUMnQ18F0lE', 'lat': 25.033337099999997, 'lng': 121.56465960000001, 'interval_idx': 0}, 'alternative_pois': [{'node_index': 10, 'poi_id': 'ChIJwQWwVe6rQjQRRGA4WzYdO2U', 'lat': 25.036873699999997, 'lng': 121.5679503, 'interval_idx': 0, 'delta_travel_time_min': 7, 'delta_travel_distance_m': 2003}, {'node_index': 6, 'poi_id': 'ChIJ01XRzrurQjQRnp5ZsHbAAuE', 'lat': 25.039739800000003, 'lng': 121.5665985, 'interval_idx': 0, 'delta_travel_time_min': 9, 'delta_travel_distance_m': 2145}, {'node_index': 5, 'poi_id': 'ChIJaeY0sNCrQjQRBmpF8-RmywQ', 'lat': 25.0409656, 'lng': 121.5429975, 'interval_idx': 0, 'delta_travel_time_min': 24, 'delta_travel_distance_m': 6549}]}], 'tasks': [{'task_id': '1', 'priority': 'HIGH', 'service_duration_min': 120, 'time_window': {'earliest_time': '2025-11-27T10:00:00+08:00', 'latest_time': '2025-11-27T22:00:00+08:00'}, 'candidates': [{'poi_id': 'ChIJH56c2rarQjQRphD9gvC8BhI', 'name': 'Taipei 101', 'lat': 25.033976, 'lng': 121.56453889999999, 'rating': None, 'time_window': None}]}, {'task_id': '2', 'priority': 'HIGH', 'service_duration_min': 90, 'time_window': {'earliest_time': '2025-11-27T11:30:00+08:00', 'latest_time': '2025-11-27T14:30:00+08:00'}, 'candidates': [{'poi_id': 'ChIJbbvUtW6pQjQRLvK71hSUXN8', 'name': 'Din Tai Fung Mitsukoshi Nanxi Restaurant', 'lat': 25.0523074, 'lng': 121.5211037, 'rating': 4.4, 'time_window': None}, {'poi_id': 'ChIJA-U6X-epQjQR-T9BLmEfUlc', 'name': 'Din Tai Fung Xinsheng Branch', 'lat': 25.033889, 'lng': 121.5321338, 'rating': 4.6, 'time_window': None}, {'poi_id': 'ChIJbTKSE4KpQjQRXDZZI57v-pM', 'name': 'Din Tai Fung Xinyi Branch', 'lat': 25.0335035, 'lng': 121.53011799999999, 'rating': 4.4, 'time_window': None}, {'poi_id': 'ChIJaeY0sNCrQjQRBmpF8-RmywQ', 'name': 'Din Tai Fung Fuxing Restaurant', 'lat': 25.0409656, 'lng': 121.5429975, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJ01XRzrurQjQRnp5ZsHbAAuE', 'name': 'Din Tai Fung A4 Branch', 'lat': 25.039739800000003, 'lng': 121.5665985, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJQXcl6LarQjQRGUMnQ18F0lE', 'name': 'Din Tai Fung 101', 'lat': 25.033337099999997, 'lng': 121.56465960000001, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJ7y2qTJeuQjQRbpDcQImxLO0', 'name': 'Din Tai Fung Tianmu Restaurant', 'lat': 25.105072000000003, 'lng': 121.52447500000001, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJxRJqSBmoQjQR3gtSHvJgkZk', 'name': 'Din Tai Fung Mega City Restaurant', 'lat': 25.0135467, 'lng': 121.46675080000001, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJwQWwVe6rQjQRRGA4WzYdO2U', 'name': 'Din Tai Fung A13 Branch', 'lat': 25.036873699999997, 'lng': 121.5679503, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJ34CbayyoQjQRDbCGQgDk_RY', 'name': 'Ding Tai Feng', 'lat': 25.0059716, 'lng': 121.48668440000002, 'rating': 3.6, 'time_window': None}]}], 'global_info': {'language': 'en-US', 'plan_type': 'TRIP', 'departure_time': '2025-11-27T10:00:00+08:00', 'start_location': {'name': 'Taipei Main Station', 'lat': 25.0474428, 'lng': 121.5170955}}, 'traffic_summary': {'total_distance_km': 7.567, 'total_duration_min': 15}, 'precise_traffic_result': {'total_distance_meters': 7567, 'total_duration_seconds': 925, 'total_residence_time_minutes': 210, 'total_time_seconds': 13525, 'start_time': '2025-11-27 10:00:00+08:00', 'end_time': '2025-11-27 05:45:25+00:00', 'stops': [{'lat': 25.0474428, 'lng': 121.5170955}, {'lat': 25.033976, 'lng': 121.56453889999999}, {'lat': 25.033337099999997, 'lng': 121.56465960000001}], 'legs': [{'from_index': 0, 'to_index': 1, 'travel_mode': 'DRIVE', 'distance_meters': 7366, 'duration_seconds': 857, 'departure_time': '2025-11-27T02:00:00+00:00', 'polyline': 'm_{wCqttdVQbAu@KwDe@ELMTKl@C\\@LC^@PLPRJdC\\LLe@bEIj@_@|@I\\?P}EGMERqAAyAf@iD\\sC`AqKTmDJYXoDn@oFH{A`@{PNoB\\wChAyFfCaLrAcHnAyH|BeMZcCRmCNoGVcJPoI@{CIwBc@mHw@aNGeCD}BpBm^Ak@LyDB_@KmD]qDu@iGNs@SuASsBdUPZ@FcNP{ODcItBfANBxClA^D`A@As@D{@HyGpFBtD?pADlKB?|@FD`@@lAE'}, {'from_index': 1, 'to_index': 2, 'travel_mode': 'DRIVE', 'distance_meters': 201, 'duration_seconds': 68, 'departure_time': '2025-11-27T04:14:17+00:00', 'polyline': 'mmxwC}d~dVfBAXGJON]l@?CrC'}]}, 'solved_waypoints': [{'lat': 25.0474428, 'lng': 121.5170955}, {'lat': 25.033976, 'lng': 121.56453889999999}, {'lat': 25.033337099999997, 'lng': 121.56465960000001}], 'timeline': [{'stop_index': 0, 'time': '10:00', 'location': 'start point', 'address': '', 'weather': 'Rain, 20.76°C', 'aqi': {'aqi': 2, 'label': 'AQI 2 🟡'}, 'travel_time_from_prev': '0 mins', 'coordinates': {'lat': 25.0474428, 'lng': 121.5170955}}, {'stop_index': 1, 'time': '10:14', 'location': 'Taipei 101', 'address': '', 'weather': 'Rain, 20.75°C', 'aqi': {'aqi': 2, 'label': 'AQI 2 🟡'}, 'travel_time_from_prev': '14 mins', 'coordinates': {'lat': 25.033976, 'lng': 121.56453889999999}}, {'stop_index': 2, 'time': '12:15', 'location': 'Din Tai Fung Mitsukoshi Nanxi Restaurant', 'address': '', 'weather': 'Rain, 20.75°C', 'aqi': {'aqi': 2, 'label': 'AQI 2 🟡'}, 'travel_time_from_prev': '1 mins', 'coordinates': {'lat': 25.033337099999997, 'lng': 121.56465960000001}}]}
|
| 321 |
|
|
@@ -22,8 +22,13 @@ from core.visualizers import create_animated_map
|
|
| 22 |
# 導入 Config
|
| 23 |
from config import AGENTS_INFO
|
| 24 |
|
| 25 |
-
# 導入
|
| 26 |
from agno.models.google import Gemini
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
from agno.agent import RunEvent
|
| 28 |
from agno.run.team import TeamRunEvent
|
| 29 |
from src.agent.base import UserState, Location, get_context
|
|
@@ -38,6 +43,7 @@ from src.tools import (
|
|
| 38 |
from src.infra.logger import get_logger
|
| 39 |
|
| 40 |
logger = get_logger(__name__)
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
@contextmanager
|
|
@@ -85,6 +91,13 @@ class PlannerService:
|
|
| 85 |
|
| 86 |
# 🌟 [Fix] In-Memory Store: 保存包含 Agent 實例的完整 Session 物件
|
| 87 |
_active_sessions: Dict[str, UserSession] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
def _get_live_session(self, incoming_session: UserSession) -> UserSession:
|
| 90 |
"""
|
|
@@ -148,8 +161,6 @@ class PlannerService:
|
|
| 148 |
|
| 149 |
setattr(tool_instance, attr_name, create_wrapper(attr, session_id))
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
| 153 |
def initialize_agents(self, session: UserSession, lat: float, lng: float) -> UserSession:
|
| 154 |
if not session.session_id:
|
| 155 |
session.session_id = str(uuid.uuid4())
|
|
@@ -169,37 +180,76 @@ class PlannerService:
|
|
| 169 |
logger.info(f"♻️ Agents already initialized for {session.session_id}")
|
| 170 |
return session
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
| 178 |
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
if not weather_key:
|
| 183 |
-
weather_key = os.getenv("OPENWEATHER_API_KEY", "")
|
| 184 |
|
| 185 |
-
|
| 186 |
-
|
| 187 |
|
|
|
|
| 188 |
if provider.lower() == "gemini":
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
models_dict = {
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
}
|
| 197 |
|
|
|
|
|
|
|
| 198 |
# 3. 準備 Tools (先實例化,還不要給 Agent)
|
| 199 |
-
scout_tool = ScoutToolkit(
|
| 200 |
-
optimizer_tool = OptimizationToolkit(
|
| 201 |
-
navigator_tool = NavigationToolkit(
|
| 202 |
-
weather_tool = WeatherToolkit(
|
| 203 |
reader_tool = ReaderToolkit()
|
| 204 |
|
| 205 |
# 4. 🔥 執行注入!確保所有 Agent 的 Tools 都帶有 Session ID
|
|
@@ -481,8 +531,15 @@ class PlannerService:
|
|
| 481 |
# ================= Step 3: Run Core Team =================
|
| 482 |
|
| 483 |
def run_step3_team(self, session: UserSession) -> Generator[Dict[str, Any], None, None]:
|
|
|
|
|
|
|
|
|
|
| 484 |
try:
|
| 485 |
session = self._get_live_session(session)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
|
| 487 |
if session.session_id:
|
| 488 |
set_session_id(session.session_id)
|
|
@@ -493,7 +550,7 @@ class PlannerService:
|
|
| 493 |
return
|
| 494 |
|
| 495 |
# 準備 Task List String
|
| 496 |
-
task_list_input = session.planner_agent.get_session_state()
|
| 497 |
task_list_str = json.dumps(task_list_input, indent=2, ensure_ascii=False) if isinstance(task_list_input, (
|
| 498 |
dict, list)) else str(task_list_input)
|
| 499 |
|
|
@@ -506,76 +563,175 @@ class PlannerService:
|
|
| 506 |
"agent_status": ("team", "working", "Analyzing tasks...")
|
| 507 |
}
|
| 508 |
|
| 509 |
-
#
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
"session"
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
|
|
|
|
|
|
| 579 |
|
| 580 |
# 迴圈結束
|
| 581 |
session.final_report = report_html = f"## 🎯 Planning Complete\n\n{report_content}"
|
|
@@ -587,6 +743,12 @@ class PlannerService:
|
|
| 587 |
"agent_status": ("team", "complete", "Finished")
|
| 588 |
}
|
| 589 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 590 |
except Exception as e:
|
| 591 |
logger.error(f"Team run error: {e}", exc_info=True)
|
| 592 |
yield {"type": "error", "message": str(e), "session": session}
|
|
|
|
| 22 |
# 導入 Config
|
| 23 |
from config import AGENTS_INFO
|
| 24 |
|
| 25 |
+
# 導入 Model API
|
| 26 |
from agno.models.google import Gemini
|
| 27 |
+
from agno.models.openai import OpenAIChat
|
| 28 |
+
from agno.models.groq import Groq
|
| 29 |
+
|
| 30 |
+
# 導入 Agent 系統
|
| 31 |
+
|
| 32 |
from agno.agent import RunEvent
|
| 33 |
from agno.run.team import TeamRunEvent
|
| 34 |
from src.agent.base import UserState, Location, get_context
|
|
|
|
| 43 |
from src.infra.logger import get_logger
|
| 44 |
|
| 45 |
logger = get_logger(__name__)
|
| 46 |
+
max_retries = 3
|
| 47 |
|
| 48 |
|
| 49 |
@contextmanager
|
|
|
|
| 91 |
|
| 92 |
# 🌟 [Fix] In-Memory Store: 保存包含 Agent 實例的完整 Session 物件
|
| 93 |
_active_sessions: Dict[str, UserSession] = {}
|
| 94 |
+
_cancelled_sessions: set = set()
|
| 95 |
+
|
| 96 |
+
def cancel_session(self, session_id: str):
|
| 97 |
+
"""標記 Session 為取消狀態"""
|
| 98 |
+
if session_id:
|
| 99 |
+
logger.info(f"🛑 Requesting cancellation for session: {session_id}")
|
| 100 |
+
self._cancelled_sessions.add(session_id)
|
| 101 |
|
| 102 |
def _get_live_session(self, incoming_session: UserSession) -> UserSession:
|
| 103 |
"""
|
|
|
|
| 161 |
|
| 162 |
setattr(tool_instance, attr_name, create_wrapper(attr, session_id))
|
| 163 |
|
|
|
|
|
|
|
| 164 |
def initialize_agents(self, session: UserSession, lat: float, lng: float) -> UserSession:
|
| 165 |
if not session.session_id:
|
| 166 |
session.session_id = str(uuid.uuid4())
|
|
|
|
| 180 |
logger.info(f"♻️ Agents already initialized for {session.session_id}")
|
| 181 |
return session
|
| 182 |
|
| 183 |
+
# 1. 讀取設定
|
| 184 |
+
settings = session.custom_settings
|
| 185 |
+
provider = settings.get("llm_provider", "Gemini")
|
| 186 |
+
main_api_key = settings.get("model_api_key")
|
| 187 |
+
selected_model_id = settings.get("model", "gemini-2.5-flash")
|
| 188 |
+
google_map_key = settings.get("google_maps_api_key")
|
| 189 |
+
weather_map_key = settings.get("openweather_api_key")
|
| 190 |
|
| 191 |
+
# 🔥 讀取 Fast Mode 設定
|
| 192 |
+
enable_fast_mode = settings.get("enable_fast_mode", False)
|
| 193 |
+
groq_api_key = settings.get("groq_api_key", "")
|
|
|
|
|
|
|
| 194 |
|
| 195 |
+
model_logger = {"main_provider": provider, "main_model": selected_model_id, "sub_model": None,
|
| 196 |
+
"fast_mode": enable_fast_mode}
|
| 197 |
|
| 198 |
+
# 2. 初始化 "主模型 (Brain)" - 負責 Planner, Leader, Presenter
|
| 199 |
if provider.lower() == "gemini":
|
| 200 |
+
main_brain = Gemini(id=selected_model_id, api_key=main_api_key, thinking_budget=1024)
|
| 201 |
+
elif provider.lower() == "openai":
|
| 202 |
+
main_brain = OpenAIChat(id=selected_model_id, api_key=main_api_key, reasoning_effort="low")
|
| 203 |
+
elif provider.lower() == "groq":
|
| 204 |
+
main_brain = Groq(id=selected_model_id, api_key=main_api_key, temperature=0.1)
|
| 205 |
+
else:
|
| 206 |
+
main_brain = Gemini(id='gemini-2.5-flash', api_key=main_api_key, thinking_budget=1024)
|
| 207 |
+
|
| 208 |
+
# 3. 初始化 "輔助模型 (Muscle)" - 負責 Scout, Optimizer, Navigator
|
| 209 |
+
helper_model = None
|
| 210 |
+
|
| 211 |
+
# 🔥 判斷是否啟用 Fast Mode
|
| 212 |
+
if enable_fast_mode and groq_api_key:
|
| 213 |
+
model_logger["sub_model"] = "llama-3.1-70b-versatile"
|
| 214 |
+
logger.info("⚡ Fast Mode ENABLED: Using Groq (Llama-3) for helpers.")
|
| 215 |
+
# 強制使用 Llama 3 70B,並壓低 Temperature
|
| 216 |
+
helper_model = Groq(
|
| 217 |
+
id="llama-3.1-8b-instant",
|
| 218 |
+
api_key=groq_api_key,
|
| 219 |
+
temperature=0.1
|
| 220 |
+
)
|
| 221 |
+
else:
|
| 222 |
+
# 如果沒開 Fast Mode,或者沒填 Groq Key,就使用主模型 (或其 Lite 版本)
|
| 223 |
+
logger.info("🐢 Fast Mode DISABLED: Helpers using Main Provider.")
|
| 224 |
+
if provider.lower() == "gemini":
|
| 225 |
+
model_logger["sub_model"] = "gemini-2.5-flash-lite"
|
| 226 |
+
helper_model = Gemini(id="gemini-2.5-flash-lite", api_key=main_api_key)
|
| 227 |
+
elif provider.lower() == "openai":
|
| 228 |
+
model_logger["sub_model"] = "gpt-4o-mini"
|
| 229 |
+
helper_model = OpenAIChat(id="gpt-4o-mini", api_key=main_api_key)
|
| 230 |
+
else:
|
| 231 |
+
model_logger["sub_model"] = model_logger["main_model"]
|
| 232 |
+
helper_model = main_brain
|
| 233 |
+
|
| 234 |
+
# 4. 分配模型給 Agents
|
| 235 |
+
# 🧠 大腦組:需要高智商
|
| 236 |
models_dict = {
|
| 237 |
+
"team": main_brain, # Leader: 指揮需要聰明
|
| 238 |
+
"presenter": main_brain, # Presenter: 寫作需要文筆
|
| 239 |
+
# 💪 肌肉組:需要速度 (若 Fast Mode 開啟,這裡就是 Groq)
|
| 240 |
+
"scout": helper_model,
|
| 241 |
+
"optimizer": helper_model,
|
| 242 |
+
"navigator": helper_model,
|
| 243 |
+
"weatherman": helper_model
|
| 244 |
}
|
| 245 |
|
| 246 |
+
planner_model = main_brain
|
| 247 |
+
|
| 248 |
# 3. 準備 Tools (先實例化,還不要給 Agent)
|
| 249 |
+
scout_tool = ScoutToolkit(google_map_key)
|
| 250 |
+
optimizer_tool = OptimizationToolkit()
|
| 251 |
+
navigator_tool = NavigationToolkit(google_map_key)
|
| 252 |
+
weather_tool = WeatherToolkit(weather_map_key)
|
| 253 |
reader_tool = ReaderToolkit()
|
| 254 |
|
| 255 |
# 4. 🔥 執行注入!確保所有 Agent 的 Tools 都帶有 Session ID
|
|
|
|
| 531 |
# ================= Step 3: Run Core Team =================
|
| 532 |
|
| 533 |
def run_step3_team(self, session: UserSession) -> Generator[Dict[str, Any], None, None]:
|
| 534 |
+
|
| 535 |
+
attempt = 0
|
| 536 |
+
success = False
|
| 537 |
try:
|
| 538 |
session = self._get_live_session(session)
|
| 539 |
+
sid = session.session_id
|
| 540 |
+
|
| 541 |
+
if sid in self._cancelled_sessions:
|
| 542 |
+
self._cancelled_sessions.remove(sid)
|
| 543 |
|
| 544 |
if session.session_id:
|
| 545 |
set_session_id(session.session_id)
|
|
|
|
| 550 |
return
|
| 551 |
|
| 552 |
# 準備 Task List String
|
| 553 |
+
task_list_input = session.planner_agent.get_session_state()["task_list"]
|
| 554 |
task_list_str = json.dumps(task_list_input, indent=2, ensure_ascii=False) if isinstance(task_list_input, (
|
| 555 |
dict, list)) else str(task_list_input)
|
| 556 |
|
|
|
|
| 563 |
"agent_status": ("team", "working", "Analyzing tasks...")
|
| 564 |
}
|
| 565 |
|
| 566 |
+
#print(f"task_list_input: {task_list_str}")
|
| 567 |
+
|
| 568 |
+
message = f"Plan this trip: {task_list_str}"
|
| 569 |
+
|
| 570 |
+
while attempt < max_retries and not success:
|
| 571 |
+
attempt += 1
|
| 572 |
+
|
| 573 |
+
# 如果是第 2 次以上嘗試,發個 log
|
| 574 |
+
if attempt > 1:
|
| 575 |
+
logger.warning(f"🔄 Retry attempt {attempt}/{max_retries} for Session {session.session_id}")
|
| 576 |
+
# 可以選擇在這裡 yield 一個 "Retrying..." 的狀態給 UI (選用)
|
| 577 |
+
|
| 578 |
+
try:
|
| 579 |
+
# 🔥 [CRITICAL FIX] 使用 Patch Context 包裹執行區塊
|
| 580 |
+
with patch_repo_context(session.session_id):
|
| 581 |
+
active_agents = set()
|
| 582 |
+
team_stream = session.core_team.run(
|
| 583 |
+
message,
|
| 584 |
+
stream=True, stream_events=True, session_id=session.session_id
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
|
| 588 |
+
start_time = time.perf_counter()
|
| 589 |
+
report_content = ""
|
| 590 |
+
|
| 591 |
+
has_content = False
|
| 592 |
+
# 🔥 Event Loop: 捕捉事件並 yield 給 UI
|
| 593 |
+
for event in team_stream:
|
| 594 |
+
if event.event in [RunEvent.run_content, RunEvent.tool_call_started]:
|
| 595 |
+
has_content = True
|
| 596 |
+
success = True # 標記成功
|
| 597 |
+
|
| 598 |
+
if sid in self._cancelled_sessions:
|
| 599 |
+
logger.warning(f"🛑 Execution terminated by user for session {sid}")
|
| 600 |
+
self._cancelled_sessions.remove(sid) # 清理標記
|
| 601 |
+
yield {"type": "error", "message": "Plan cancelled by user."}
|
| 602 |
+
return
|
| 603 |
+
|
| 604 |
+
# 1. 捕捉 Agent "開始工作"
|
| 605 |
+
if event.event == RunEvent.run_started:
|
| 606 |
+
agent_id = event.agent_id or "team"
|
| 607 |
+
if not event.agent_id: agent_id = "team"
|
| 608 |
+
|
| 609 |
+
active_agents.add(agent_id)
|
| 610 |
+
yield {
|
| 611 |
+
"type": "reasoning_update",
|
| 612 |
+
"session": session,
|
| 613 |
+
"agent_status": (agent_id, "working", "Thinking...")
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
# 2. 捕捉 Agent "完成工作" (⭐⭐ 修正重點 ⭐⭐)
|
| 617 |
+
elif event.event == RunEvent.run_completed:
|
| 618 |
+
agent_id = event.agent_id or "team"
|
| 619 |
+
if not event.agent_id: agent_id = "team"
|
| 620 |
+
|
| 621 |
+
# A. 如果是 Leader:絕對不准休息!
|
| 622 |
+
if agent_id == "team":
|
| 623 |
+
yield {
|
| 624 |
+
"type": "reasoning_update",
|
| 625 |
+
"session": session,
|
| 626 |
+
"agent_status": ("team", "working", "Processing...")
|
| 627 |
+
}
|
| 628 |
+
continue # 跳過設為 Idle 的步驟
|
| 629 |
+
|
| 630 |
+
# B. 如果是 Member:做完工作了
|
| 631 |
+
if agent_id in active_agents:
|
| 632 |
+
active_agents.remove(agent_id)
|
| 633 |
+
|
| 634 |
+
# B-1. 成員變灰 (下班)
|
| 635 |
+
yield {
|
| 636 |
+
"type": "reasoning_update",
|
| 637 |
+
"session": session,
|
| 638 |
+
"agent_status": (agent_id, "idle", "Standby")
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
# 🔥🔥 B-2. (關鍵新增) Leader 立刻接手 (上班) 🔥🔥
|
| 642 |
+
# 這消除了成員做完 -> Leader 分派下一個任務之間的空窗期
|
| 643 |
+
yield {
|
| 644 |
+
"type": "reasoning_update",
|
| 645 |
+
"session": session,
|
| 646 |
+
"agent_status": ("team", "working", "Reviewing results...")
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
# 3. 捕捉 Report 內容
|
| 650 |
+
elif event.event == RunEvent.run_content and event.agent_id == "presenter":
|
| 651 |
+
report_content += event.content
|
| 652 |
+
yield {
|
| 653 |
+
"type": "report_stream",
|
| 654 |
+
"content": report_content,
|
| 655 |
+
"session": session
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
# 4. Team Delegate (分派任務)
|
| 659 |
+
elif event.event == TeamRunEvent.tool_call_started:
|
| 660 |
+
tool_name = event.tool.tool_name
|
| 661 |
+
|
| 662 |
+
# Leader 顯示正在指揮
|
| 663 |
+
yield {
|
| 664 |
+
"type": "reasoning_update",
|
| 665 |
+
"session": session,
|
| 666 |
+
"agent_status": ("team", "working", "Orchestrating...")
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
if "delegate_task_to_member" in tool_name:
|
| 670 |
+
member_id = event.tool.tool_args.get("member_id", "unknown")
|
| 671 |
+
msg = f"Delegating to {member_id}..."
|
| 672 |
+
self._add_reasoning(session, "team", f"👉 {msg}")
|
| 673 |
+
|
| 674 |
+
# 成員亮燈 (接單)
|
| 675 |
+
yield {
|
| 676 |
+
"type": "reasoning_update",
|
| 677 |
+
"session": session,
|
| 678 |
+
"agent_status": (member_id, "working", "Receiving Task...")
|
| 679 |
+
}
|
| 680 |
+
else:
|
| 681 |
+
self._add_reasoning(session, "team", f"🔧 Tool: {tool_name}")
|
| 682 |
+
|
| 683 |
+
# 5. Member Tool Start
|
| 684 |
+
elif event.event == RunEvent.tool_call_started:
|
| 685 |
+
member_id = event.agent_id
|
| 686 |
+
tool_name = event.tool.tool_name
|
| 687 |
+
|
| 688 |
+
# 雙重保險:Member 在用工具時,Leader 也是亮著的 (Monitoring)
|
| 689 |
+
yield {
|
| 690 |
+
"type": "reasoning_update",
|
| 691 |
+
"session": session,
|
| 692 |
+
"agent_status": ("team", "working", f"Monitoring {member_id}...")
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
self._add_reasoning(session, member_id, f"Using tool: {tool_name}...")
|
| 696 |
+
yield {
|
| 697 |
+
"type": "reasoning_update",
|
| 698 |
+
"session": session,
|
| 699 |
+
"agent_status": (member_id, "working", f"Running Tool...")
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
# 6. Team Complete
|
| 703 |
+
elif event.event == TeamRunEvent.run_completed:
|
| 704 |
+
self._add_reasoning(session, "team", "🎉 Planning process finished")
|
| 705 |
+
|
| 706 |
+
|
| 707 |
+
if not has_content:
|
| 708 |
+
logger.error(f"⚠️ Attempt {attempt}: Agent returned NO content (Silent Failure).")
|
| 709 |
+
if attempt < max_retries:
|
| 710 |
+
continue # 重試 while loop
|
| 711 |
+
else:
|
| 712 |
+
raise ValueError("Agent failed to generate output after retries.")
|
| 713 |
+
|
| 714 |
+
break
|
| 715 |
+
|
| 716 |
+
finally:
|
| 717 |
+
logger.info(f"Total tokens: {event.metrics.total_tokens}")
|
| 718 |
+
logger.info(f"Input tokens: {event.metrics.input_tokens}")
|
| 719 |
+
logger.info(f"Output tokens: {event.metrics.output_tokens}")
|
| 720 |
+
logger.info(f"Run time (s): {time.perf_counter() - start_time}")
|
| 721 |
+
|
| 722 |
+
for agent in ["scout", "optimizer", "navigator", "weatherman", "presenter"]:
|
| 723 |
+
yield {
|
| 724 |
+
"type": "reasoning_update",
|
| 725 |
+
"session": session,
|
| 726 |
+
"agent_status": (agent, "idle", "Standby")
|
| 727 |
+
}
|
| 728 |
|
| 729 |
+
# Leader 顯示完成
|
| 730 |
+
yield {
|
| 731 |
+
"type": "reasoning_update",
|
| 732 |
+
"session": session,
|
| 733 |
+
"agent_status": ("team", "complete", "All Done!")
|
| 734 |
+
}
|
| 735 |
|
| 736 |
# 迴圈結束
|
| 737 |
session.final_report = report_html = f"## 🎯 Planning Complete\n\n{report_content}"
|
|
|
|
| 743 |
"agent_status": ("team", "complete", "Finished")
|
| 744 |
}
|
| 745 |
|
| 746 |
+
except Exception as e:
|
| 747 |
+
logger.error(f"Error in attempt {attempt}: {e}")
|
| 748 |
+
if attempt >= max_retries:
|
| 749 |
+
yield {"type": "error", "message": str(e), "session": session}
|
| 750 |
+
return
|
| 751 |
+
|
| 752 |
except Exception as e:
|
| 753 |
logger.error(f"Team run error: {e}", exc_info=True)
|
| 754 |
yield {"type": "error", "message": str(e), "session": session}
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
__version__ = "1.0.0"
|
|
@@ -32,7 +32,7 @@ instructions = """
|
|
| 32 |
|
| 33 |
### 2. Global Info Strategy (CRITICAL: Start Location)
|
| 34 |
- **language**: Match user's language (e.g., `en-US`, `zh-TW`).
|
| 35 |
-
- **plan_type**: Detect intent. Returns `TRIP` (Fun) or `SCHEDULE` (Errands).
|
| 36 |
- **departure_time**: See "Smart Start Logic" above.
|
| 37 |
- **start_location Logic**:
|
| 38 |
1. **User Specified**: If user explicitly names a start point (e.g., "From Grand Hyatt"), use that.
|
|
@@ -42,16 +42,26 @@ instructions = """
|
|
| 42 |
|
| 43 |
---
|
| 44 |
|
| 45 |
-
### 3. Task Generation &
|
| 46 |
- **task_id**: Unique integer starting from 1.
|
| 47 |
- **description**: Short, clear task name.
|
| 48 |
- **priority**: `HIGH`, `MEDIUM`, `LOW`.
|
| 49 |
-
- **
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
- **service_duration_min**:
|
| 51 |
- Sightseeing: **90-120m**
|
| 52 |
- Meal: **60-90m**
|
| 53 |
- Errands: **15-45m**
|
| 54 |
-
- **category**: `MEDICAL`, `SHOPPING`, `MEAL`, `LEISURE`, `ERRAND`.
|
| 55 |
|
| 56 |
---
|
| 57 |
|
|
@@ -69,6 +79,28 @@ instructions = """
|
|
| 69 |
- `time_window`: `{"earliest_time": "...", "latest_time": "..."}`
|
| 70 |
- Use `null` if absolutely no time constraint exists.
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
---
|
| 73 |
|
| 74 |
### 5. JSON Output Format
|
|
|
|
| 32 |
|
| 33 |
### 2. Global Info Strategy (CRITICAL: Start Location)
|
| 34 |
- **language**: Match user's language (e.g., `en-US`, `zh-TW`).
|
| 35 |
+
- **plan_type**: Detect intent. Returns `TRIP` (Fun/Inter-city) or `SCHEDULE` (Errands/Local).
|
| 36 |
- **departure_time**: See "Smart Start Logic" above.
|
| 37 |
- **start_location Logic**:
|
| 38 |
1. **User Specified**: If user explicitly names a start point (e.g., "From Grand Hyatt"), use that.
|
|
|
|
| 42 |
|
| 43 |
---
|
| 44 |
|
| 45 |
+
### 3. Task Generation & Geographical Consistency (CRITICAL)
|
| 46 |
- **task_id**: Unique integer starting from 1.
|
| 47 |
- **description**: Short, clear task name.
|
| 48 |
- **priority**: `HIGH`, `MEDIUM`, `LOW`.
|
| 49 |
+
- **category**: `MEDICAL`, `SHOPPING`, `MEAL`, `LEISURE`, `ERRAND`.
|
| 50 |
+
|
| 51 |
+
🔥🔥 **location_hint RULES (Prevent Hallucinations)** 🔥🔥:
|
| 52 |
+
1. **Specific POI**: If user says "Taipei 101", use "Taipei 101".
|
| 53 |
+
2. **Generic POI (e.g., "Post Office", "7-11", "Park")**:
|
| 54 |
+
- **IF plan_type == TRIP**: Append the **Destination City** name (e.g., "Post Office Kyoto").
|
| 55 |
+
- **IF plan_type == SCHEDULE (Local)**:
|
| 56 |
+
- You MUST NOT append a random city name.
|
| 57 |
+
- Keep it generic (e.g., just "Post Office") OR append the **User's Current City** from context.
|
| 58 |
+
- **FORBIDDEN**: Do not generate "Kaohsiung Post Office" if the user is in "Tainan".
|
| 59 |
+
3. **Searchability**: The hint must be a valid Google Maps search query.
|
| 60 |
+
|
| 61 |
- **service_duration_min**:
|
| 62 |
- Sightseeing: **90-120m**
|
| 63 |
- Meal: **60-90m**
|
| 64 |
- Errands: **15-45m**
|
|
|
|
| 65 |
|
| 66 |
---
|
| 67 |
|
|
|
|
| 79 |
- `time_window`: `{"earliest_time": "...", "latest_time": "..."}`
|
| 80 |
- Use `null` if absolutely no time constraint exists.
|
| 81 |
|
| 82 |
+
---
|
| 83 |
+
|
| 84 |
+
### 5. JSON Output Format
|
| 85 |
+
Output ONLY valid JSON inside `@@@@@` delimiters.
|
| 86 |
+
Example:
|
| 87 |
+
@@@@@
|
| 88 |
+
{
|
| 89 |
+
"global_info": { ... },
|
| 90 |
+
"tasks": [ ... ]
|
| 91 |
+
}
|
| 92 |
+
- **General Tasks (Max Flexibility)**:
|
| 93 |
+
- Unless specified, use the POI's full operating hours.
|
| 94 |
+
- Make the window as **wide** as possible to allow the solver to optimize the route.
|
| 95 |
+
- **Meal Tasks (Fixed Logic)**:
|
| 96 |
+
- Meals act as anchors. Do NOT use infinite windows. Use logical meal times:
|
| 97 |
+
- **Breakfast**: ~ `07:00` to `10:00`
|
| 98 |
+
- **Lunch**: ~ `11:30` to `14:30`
|
| 99 |
+
- **Dinner**: ~ `17:30` to `20:30`
|
| 100 |
+
- **Format**:
|
| 101 |
+
- `time_window`: `{"earliest_time": "...", "latest_time": "..."}`
|
| 102 |
+
- Use `null` if absolutely no time constraint exists.
|
| 103 |
+
|
| 104 |
---
|
| 105 |
|
| 106 |
### 5. JSON Output Format
|
|
@@ -76,7 +76,7 @@ class TSPTWSolver:
|
|
| 76 |
deadline: datetime,
|
| 77 |
tasks: List[Dict[str, Any]] = None,
|
| 78 |
travel_mode="DRIVE",
|
| 79 |
-
max_wait_time_min: int =
|
| 80 |
alt_k: int = 3,
|
| 81 |
return_to_start: bool = True,
|
| 82 |
) -> Dict[str, Any]:
|
|
|
|
| 76 |
deadline: datetime,
|
| 77 |
tasks: List[Dict[str, Any]] = None,
|
| 78 |
travel_mode="DRIVE",
|
| 79 |
+
max_wait_time_min: int = 10,
|
| 80 |
alt_k: int = 3,
|
| 81 |
return_to_start: bool = True,
|
| 82 |
) -> Dict[str, Any]:
|
|
@@ -18,6 +18,7 @@ class OptimizationToolkit(Toolkit):
|
|
| 18 |
|
| 19 |
def optimize_from_ref(self, ref_id: str) -> str:
|
| 20 |
"""
|
|
|
|
| 21 |
從 Ref ID 載入資料並執行路徑優化。
|
| 22 |
"""
|
| 23 |
print(f"🧮 Optimizer: Fetching data for {ref_id}...")
|
|
@@ -74,6 +75,7 @@ class OptimizationToolkit(Toolkit):
|
|
| 74 |
start_time=start_time,
|
| 75 |
deadline=deadline,
|
| 76 |
tasks=tasks,
|
|
|
|
| 77 |
return_to_start=False
|
| 78 |
)
|
| 79 |
except Exception as e:
|
|
|
|
| 18 |
|
| 19 |
def optimize_from_ref(self, ref_id: str) -> str:
|
| 20 |
"""
|
| 21 |
+
|
| 22 |
從 Ref ID 載入資料並執行路徑優化。
|
| 23 |
"""
|
| 24 |
print(f"🧮 Optimizer: Fetching data for {ref_id}...")
|
|
|
|
| 75 |
start_time=start_time,
|
| 76 |
deadline=deadline,
|
| 77 |
tasks=tasks,
|
| 78 |
+
max_wait_time_min=0,
|
| 79 |
return_to_start=False
|
| 80 |
)
|
| 81 |
except Exception as e:
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
__version__ = "1.0.0"
|
|
@@ -8,12 +8,11 @@ import gradio as gr
|
|
| 8 |
def create_header():
|
| 9 |
"""
|
| 10 |
創建優化後的 Header:
|
| 11 |
-
- Logo
|
| 12 |
-
-
|
| 13 |
"""
|
| 14 |
# 1. Header 容器 (只放 Logo)
|
| 15 |
with gr.Row(elem_classes="app-header-container"):
|
| 16 |
-
# 左側:Logo 和標題 (佔滿寬度,因為按鈕已經飛出去了)
|
| 17 |
with gr.Column(scale=1):
|
| 18 |
gr.HTML("""
|
| 19 |
<div class="app-header-left">
|
|
@@ -29,12 +28,13 @@ def create_header():
|
|
| 29 |
</div>
|
| 30 |
""")
|
| 31 |
|
| 32 |
-
# 2. 功能按鈕 (
|
| 33 |
-
#
|
| 34 |
-
with gr.
|
| 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 |
-
|
|
|
|
|
|
| 8 |
def create_header():
|
| 9 |
"""
|
| 10 |
創建優化後的 Header:
|
| 11 |
+
- Logo 保留
|
| 12 |
+
- 按鈕改為垂直排列 (使用 gr.Column)
|
| 13 |
"""
|
| 14 |
# 1. Header 容器 (只放 Logo)
|
| 15 |
with gr.Row(elem_classes="app-header-container"):
|
|
|
|
| 16 |
with gr.Column(scale=1):
|
| 17 |
gr.HTML("""
|
| 18 |
<div class="app-header-left">
|
|
|
|
| 28 |
</div>
|
| 29 |
""")
|
| 30 |
|
| 31 |
+
# 2. 功能按鈕 (垂直懸浮)
|
| 32 |
+
# 🔥 修改:改用 gr.Column 讓它在結構上就是垂直的
|
| 33 |
+
with gr.Column(elem_classes="header-controls"):
|
| 34 |
home_btn = gr.Button("🏠", size="sm", elem_classes="header-btn")
|
| 35 |
+
# theme_btn = gr.Button("🌓", size="sm", elem_classes="header-btn") # 如果不需要可註解
|
| 36 |
settings_btn = gr.Button("⚙️", size="sm", elem_classes="header-btn")
|
| 37 |
doc_btn = gr.Button("📖", size="sm", elem_classes="header-btn")
|
| 38 |
|
| 39 |
+
# 注意 return 的數量要跟上面定義的一致
|
| 40 |
+
return home_btn, settings_btn, doc_btn #, theme_btn
|
|
@@ -1,69 +1,108 @@
|
|
|
|
|
| 1 |
import gradio as gr
|
| 2 |
-
|
| 3 |
-
import gradio as gr
|
| 4 |
|
| 5 |
|
| 6 |
-
def
|
| 7 |
"""
|
| 8 |
-
|
| 9 |
-
|
| 10 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
with gr.Group(visible=False, elem_classes="modal-overlay", elem_id="settings-modal") as modal:
|
| 12 |
with gr.Group(elem_classes="modal-box"):
|
| 13 |
with gr.Row(elem_classes="modal-header"):
|
| 14 |
-
gr.Markdown("### ⚙️ System Configuration", elem_classes="modal-title")
|
| 15 |
|
| 16 |
with gr.Column(elem_classes="modal-content"):
|
| 17 |
with gr.Tabs():
|
| 18 |
-
# ===
|
| 19 |
-
with gr.TabItem("
|
| 20 |
-
gr.Markdown("
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
placeholder="sk-...",
|
| 43 |
-
type="password"
|
| 44 |
-
)
|
| 45 |
-
# ⭐ 修改結束 ⭐
|
| 46 |
-
|
| 47 |
-
# === 分頁 2: Model Settings ===
|
| 48 |
-
with gr.TabItem("🤖 Model Settings"):
|
| 49 |
-
gr.Markdown("Select the AI model engine for trip planning.", elem_classes="tab-desc")
|
| 50 |
model_sel = gr.Dropdown(
|
| 51 |
-
choices=[
|
| 52 |
-
value=
|
| 53 |
-
label="
|
| 54 |
interactive=True,
|
| 55 |
-
|
| 56 |
-
# "Only models supported by your selected provider will be shown."
|
| 57 |
)
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
set_stat = gr.Markdown(value="", visible=True)
|
| 60 |
|
| 61 |
with gr.Row(elem_classes="modal-footer"):
|
| 62 |
-
close_btn = gr.Button("Cancel", variant="secondary")
|
| 63 |
-
save_btn = gr.Button("💾 Save
|
| 64 |
-
|
| 65 |
-
# 🔥
|
| 66 |
-
return modal,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
def create_doc_modal():
|
|
|
|
| 1 |
+
# ui/components/modals.py
|
| 2 |
import gradio as gr
|
| 3 |
+
from config import MODEL_OPTIONS, DEFAULT_PROVIDER, DEFAULT_MODEL
|
|
|
|
| 4 |
|
| 5 |
|
| 6 |
+
def create_validated_input(label, placeholder, type="password"):
|
| 7 |
"""
|
| 8 |
+
Helper: 創建自動驗證的輸入框組件 (無按鈕版)
|
| 9 |
+
回傳: (textbox, status_markdown)
|
| 10 |
"""
|
| 11 |
+
with gr.Group():
|
| 12 |
+
key_input = gr.Textbox(
|
| 13 |
+
label=label,
|
| 14 |
+
placeholder=placeholder,
|
| 15 |
+
type=type,
|
| 16 |
+
elem_classes="modern-input"
|
| 17 |
+
)
|
| 18 |
+
# 狀態訊息預設為空,驗證後顯示
|
| 19 |
+
status_output = gr.Markdown(value="", elem_classes="api-status-msg")
|
| 20 |
+
|
| 21 |
+
return key_input, status_output
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def create_settings_modal():
|
| 25 |
with gr.Group(visible=False, elem_classes="modal-overlay", elem_id="settings-modal") as modal:
|
| 26 |
with gr.Group(elem_classes="modal-box"):
|
| 27 |
with gr.Row(elem_classes="modal-header"):
|
| 28 |
+
gr.Markdown("### ⚙️ Hybrid System Configuration", elem_classes="modal-title")
|
| 29 |
|
| 30 |
with gr.Column(elem_classes="modal-content"):
|
| 31 |
with gr.Tabs():
|
| 32 |
+
# === 1. Services ===
|
| 33 |
+
with gr.TabItem("🌍 Services"):
|
| 34 |
+
gr.Markdown("External services for maps and weather.", elem_classes="tab-desc")
|
| 35 |
+
|
| 36 |
+
# 🔥 使用新 Helper (沒有按鈕了)
|
| 37 |
+
g_key, g_stat = create_validated_input("Google Maps Key", "AIza...")
|
| 38 |
+
w_key, w_stat = create_validated_input("OpenWeather Key", "Enter key...")
|
| 39 |
+
|
| 40 |
+
# === 2. Primary Brain ===
|
| 41 |
+
with gr.TabItem("🧠 Primary Brain"):
|
| 42 |
+
gr.Markdown("Select the main intelligence.", elem_classes="tab-desc")
|
| 43 |
+
|
| 44 |
+
# 這裡佈局稍微調整,把 Key 單獨放一行,比較乾淨
|
| 45 |
+
llm_provider = gr.Dropdown(
|
| 46 |
+
choices=list(MODEL_OPTIONS.keys()),
|
| 47 |
+
value=DEFAULT_PROVIDER,
|
| 48 |
+
label="Main Provider",
|
| 49 |
+
interactive=True,
|
| 50 |
+
elem_classes="modern-dropdown"
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# 主模型 Key
|
| 54 |
+
main_key, main_stat = create_validated_input("Main Model API Key", "sk-...")
|
| 55 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
model_sel = gr.Dropdown(
|
| 57 |
+
choices=MODEL_OPTIONS[DEFAULT_PROVIDER],
|
| 58 |
+
value=DEFAULT_MODEL,
|
| 59 |
+
label="Main Model",
|
| 60 |
interactive=True,
|
| 61 |
+
elem_classes="modern-dropdown"
|
|
|
|
| 62 |
)
|
| 63 |
|
| 64 |
+
# === 3. Acceleration ===
|
| 65 |
+
with gr.TabItem("⚡ Acceleration"):
|
| 66 |
+
gr.Markdown("Configure Groq for speed.", elem_classes="tab-desc")
|
| 67 |
+
|
| 68 |
+
fast_mode_chk = gr.Checkbox(
|
| 69 |
+
label="Enable Fast Mode",
|
| 70 |
+
value=False,
|
| 71 |
+
elem_classes="modern-checkbox"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
groq_model_sel = gr.Dropdown(
|
| 75 |
+
choices=[
|
| 76 |
+
("Llama 3.1 8B", "llama-3.1-8b-instant"),
|
| 77 |
+
("GPT-OSS 20B", "openai/gpt-oss-20b"),
|
| 78 |
+
("Llama 4 scout", "llama-4-scout-17b-16e-instructe")
|
| 79 |
+
],
|
| 80 |
+
value="llama-3.1-8b-instant",
|
| 81 |
+
label="Model",
|
| 82 |
+
elem_classes="modern-dropdown",
|
| 83 |
+
visible = False # <--- 預設隱藏
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# Groq Key
|
| 87 |
+
groq_key, groq_stat = create_validated_input("Groq API Key", "gsk_...")
|
| 88 |
+
|
| 89 |
+
# 手動設定初始狀態為隱藏 (Gradio 小技巧: 定義後再 update)
|
| 90 |
+
groq_key.visible = False
|
| 91 |
+
|
| 92 |
+
# 全局狀態
|
| 93 |
set_stat = gr.Markdown(value="", visible=True)
|
| 94 |
|
| 95 |
with gr.Row(elem_classes="modal-footer"):
|
| 96 |
+
close_btn = gr.Button("Cancel", variant="secondary", elem_classes="btn-cancel")
|
| 97 |
+
save_btn = gr.Button("💾 Save", variant="primary", elem_id="btn-save-config")
|
| 98 |
+
|
| 99 |
+
# 🔥 回傳清單變短了 (少了 4 個按鈕)
|
| 100 |
+
return (modal,
|
| 101 |
+
g_key, g_stat,
|
| 102 |
+
w_key, w_stat,
|
| 103 |
+
llm_provider, main_key, main_stat, model_sel,
|
| 104 |
+
fast_mode_chk, groq_model_sel, groq_key, groq_stat,
|
| 105 |
+
close_btn, save_btn, set_stat)
|
| 106 |
|
| 107 |
|
| 108 |
def create_doc_modal():
|
|
@@ -56,44 +56,36 @@ def get_enhanced_css() -> str:
|
|
| 56 |
}
|
| 57 |
|
| 58 |
.header-controls {
|
| 59 |
-
position: fixed !important; /*
|
| 60 |
-
top:
|
| 61 |
-
right:
|
| 62 |
-
z-index: 99999 !important; /*
|
| 63 |
|
| 64 |
display: flex !important;
|
| 65 |
-
flex-direction:
|
| 66 |
-
gap:
|
| 67 |
-
align-items: center !important;
|
| 68 |
|
| 69 |
-
/*
|
| 70 |
-
background: rgba(255, 255, 255, 0.
|
| 71 |
-
backdrop-filter: blur(12px) !important;
|
| 72 |
-
padding: 8px
|
| 73 |
-
border-radius:
|
| 74 |
border: 1px solid rgba(255, 255, 255, 0.6) !important;
|
| 75 |
-
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.
|
| 76 |
|
| 77 |
-
width: auto !important;
|
| 78 |
min-width: 0 !important;
|
|
|
|
| 79 |
}
|
| 80 |
|
|
|
|
| 81 |
.header-btn {
|
| 82 |
-
min-width:
|
| 83 |
-
max-width:
|
| 84 |
-
height:
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
background: transparent !important;
|
| 88 |
-
border: 1px solid #e2e8f0 !important;
|
| 89 |
-
box-shadow: none !important;
|
| 90 |
-
font-size: 1.25rem !important;
|
| 91 |
-
color: #64748b !important;
|
| 92 |
-
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
| 93 |
-
cursor: pointer !important;
|
| 94 |
-
display: flex !important;
|
| 95 |
-
align-items: center !important;
|
| 96 |
-
justify-content: center !important;
|
| 97 |
}
|
| 98 |
|
| 99 |
.header-btn:hover {
|
|
@@ -420,42 +412,148 @@ def get_enhanced_css() -> str:
|
|
| 420 |
/* 2. 彈窗本體 (Modal Box) */
|
| 421 |
.modal-box {
|
| 422 |
background: white !important;
|
| 423 |
-
|
| 424 |
-
max-width: 500px !important; /* 限制最大寬度 */
|
| 425 |
-
border-radius: 16px !important;
|
| 426 |
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25) !important;
|
| 427 |
-
border: 1px solid rgba(255, 255, 255, 0.
|
| 428 |
overflow: hidden !important;
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
}
|
| 433 |
|
| 434 |
-
/*
|
| 435 |
-
.modal-
|
| 436 |
-
padding:
|
| 437 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
}
|
| 439 |
|
| 440 |
.modal-title h3 {
|
| 441 |
-
margin: 0 !important;
|
| 442 |
font-size: 1.5rem !important;
|
| 443 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
}
|
| 445 |
|
|
|
|
| 446 |
.modal-content {
|
| 447 |
padding: 0 24px 10px 24px !important;
|
| 448 |
-
|
| 449 |
-
|
|
|
|
| 450 |
}
|
| 451 |
|
|
|
|
| 452 |
.modal-footer {
|
| 453 |
-
padding: 16px 24px 24px 24px !important;
|
| 454 |
background: #f8fafc !important;
|
| 455 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
display: flex !important;
|
| 457 |
-
|
| 458 |
-
gap: 10px !important;
|
| 459 |
}
|
| 460 |
|
| 461 |
/* 彈出動畫 */
|
|
@@ -546,5 +644,33 @@ def get_enhanced_css() -> str:
|
|
| 546 |
background: #94a3b8 !important; /* 加深灰色 */
|
| 547 |
opacity: 0.6 !important;
|
| 548 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
</style>
|
| 550 |
"""
|
|
|
|
| 56 |
}
|
| 57 |
|
| 58 |
.header-controls {
|
| 59 |
+
position: fixed !important; /* 固定定位 */
|
| 60 |
+
top: 30px !important; /* 距離頂部 */
|
| 61 |
+
right: 30px !important; /* 距離右側 */
|
| 62 |
+
z-index: 99999 !important; /* 最上層 */
|
| 63 |
|
| 64 |
display: flex !important;
|
| 65 |
+
flex-direction: column !important; /* 🔥 關鍵:改為垂直排列 */
|
| 66 |
+
gap: 12px !important; /* 按鈕間距 */
|
| 67 |
+
align-items: center !important; /* 水平置中 */
|
| 68 |
|
| 69 |
+
/* 視覺優化:垂直膠囊 */
|
| 70 |
+
background: rgba(255, 255, 255, 0.9) !important;
|
| 71 |
+
backdrop-filter: blur(12px) !important;
|
| 72 |
+
padding: 12px 8px !important; /* 上下寬鬆,左右緊湊 */
|
| 73 |
+
border-radius: 50px !important; /* 大圓角 */
|
| 74 |
border: 1px solid rgba(255, 255, 255, 0.6) !important;
|
| 75 |
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08) !important;
|
| 76 |
|
| 77 |
+
width: auto !important;
|
| 78 |
min-width: 0 !important;
|
| 79 |
+
height: auto !important;
|
| 80 |
}
|
| 81 |
|
| 82 |
+
/* 按鈕微調 (確保按鈕是圓的) */
|
| 83 |
.header-btn {
|
| 84 |
+
min-width: 40px !important;
|
| 85 |
+
max-width: 40px !important;
|
| 86 |
+
height: 40px !important;
|
| 87 |
+
font-size: 1.1rem !important;
|
| 88 |
+
margin: 0 !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
}
|
| 90 |
|
| 91 |
.header-btn:hover {
|
|
|
|
| 412 |
/* 2. 彈窗本體 (Modal Box) */
|
| 413 |
.modal-box {
|
| 414 |
background: white !important;
|
| 415 |
+
border-radius: 24px !important;
|
|
|
|
|
|
|
| 416 |
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25) !important;
|
| 417 |
+
border: 1px solid rgba(255, 255, 255, 0.6) !important;
|
| 418 |
overflow: hidden !important;
|
| 419 |
+
|
| 420 |
+
/* 🔥 寬度加大,讓橫向空間更舒服 */
|
| 421 |
+
max-width: 600px !important;
|
| 422 |
+
width: 100% !important;
|
| 423 |
+
|
| 424 |
+
display: flex !important;
|
| 425 |
+
flex-direction: column !important;
|
| 426 |
+
max-height: 90vh !important; /* 最大高度放寬到 90% 視窗高度 */
|
| 427 |
}
|
| 428 |
|
| 429 |
+
/* 2. 內容區域 */
|
| 430 |
+
.modal-content {
|
| 431 |
+
padding: 0 24px 10px 24px !important;
|
| 432 |
+
overflow-y: auto !important;
|
| 433 |
+
flex-grow: 1 !important;
|
| 434 |
+
|
| 435 |
+
/* 🔥🔥🔥 關鍵修正:最小高度加大 🔥🔥🔥 */
|
| 436 |
+
/* 原本 300px -> 改為 500px,確保所有欄位都能直接顯示不用捲動 */
|
| 437 |
+
min-height: 500px !important;
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
/* 3. Footer (保持不變) */
|
| 441 |
+
.modal-footer {
|
| 442 |
+
background: #f8fafc !important;
|
| 443 |
+
padding: 20px 24px !important;
|
| 444 |
+
border-top: 1px solid #f1f5f9 !important;
|
| 445 |
+
gap: 12px !important;
|
| 446 |
+
flex-shrink: 0 !important;
|
| 447 |
+
height: auto !important;
|
| 448 |
+
display: flex !important;
|
| 449 |
+
align-items: center !important;
|
| 450 |
}
|
| 451 |
|
| 452 |
.modal-title h3 {
|
|
|
|
| 453 |
font-size: 1.5rem !important;
|
| 454 |
+
font-weight: 800 !important;
|
| 455 |
+
background: linear-gradient(135deg, #4f46e5, #9333ea); /* 藍紫漸層 */
|
| 456 |
+
-webkit-background-clip: text;
|
| 457 |
+
-webkit-text-fill-color: transparent;
|
| 458 |
+
margin-bottom: 4px !important;
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
.tab-desc p {
|
| 462 |
+
font-size: 0.9rem !important;
|
| 463 |
+
color: #64748b !important;
|
| 464 |
+
margin-bottom: 16px !important;
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
/* 3. 輸入框美化 (移除預設的生硬邊框) */
|
| 468 |
+
.modern-input textarea, .modern-input input {
|
| 469 |
+
background-color: #f8fafc !important;
|
| 470 |
+
border: 1px solid #e2e8f0 !important;
|
| 471 |
+
border-radius: 12px !important;
|
| 472 |
+
padding: 10px 14px !important;
|
| 473 |
+
transition: all 0.2s ease;
|
| 474 |
+
font-size: 0.95rem !important;
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
.modern-input textarea:focus, .modern-input input:focus {
|
| 478 |
+
background-color: white !important;
|
| 479 |
+
border-color: #6366f1 !important;
|
| 480 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1) !important;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
/* 4. Dropdown 美化 & 修復寬度問題 */
|
| 484 |
+
.modern-dropdown .wrap-inner {
|
| 485 |
+
background-color: #f8fafc !important;
|
| 486 |
+
border-radius: 12px !important;
|
| 487 |
+
border: 1px solid #e2e8f0 !important;
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
/* 修復文字被切斷的問題 */
|
| 491 |
+
#provider-dropdown {
|
| 492 |
+
min-width: 150px !important; /* 強制最小寬度 */
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
/* 5. 按鈕美化 */
|
| 496 |
+
.modal-footer {
|
| 497 |
+
background: #f8fafc !important;
|
| 498 |
+
padding: 20px 24px !important;
|
| 499 |
+
border-top: 1px solid #f1f5f9 !important;
|
| 500 |
+
gap: 12px !important;
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
/* Cancel 按鈕:柔和灰 */
|
| 504 |
+
.btn-cancel {
|
| 505 |
+
background: white !important;
|
| 506 |
+
border: 1px solid #e2e8f0 !important;
|
| 507 |
+
color: #64748b !important;
|
| 508 |
+
border-radius: 10px !important;
|
| 509 |
+
font-weight: 500 !important;
|
| 510 |
+
}
|
| 511 |
+
.btn-cancel:hover {
|
| 512 |
+
background: #f1f5f9 !important;
|
| 513 |
+
color: #334155 !important;
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
/* Save 按鈕:品牌漸層紫 (取代橘色) */
|
| 517 |
+
#btn-save-config {
|
| 518 |
+
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%) !important;
|
| 519 |
+
border: none !important;
|
| 520 |
+
color: white !important;
|
| 521 |
+
border-radius: 10px !important;
|
| 522 |
+
font-weight: 600 !important;
|
| 523 |
+
box-shadow: 0 4px 6px -1px rgba(99, 102, 241, 0.3) !important;
|
| 524 |
+
transition: transform 0.1s !important;
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
#btn-save-config:hover {
|
| 528 |
+
opacity: 0.95 !important;
|
| 529 |
+
box-shadow: 0 6px 10px -1px rgba(99, 102, 241, 0.4) !important;
|
| 530 |
+
transform: translateY(-1px) !important;
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
#btn-save-config:active {
|
| 534 |
+
transform: translateY(0) !important;
|
| 535 |
}
|
| 536 |
|
| 537 |
+
/* 2. 內容區域:設定最小高度,並讓它吃掉剩餘空間 */
|
| 538 |
.modal-content {
|
| 539 |
padding: 0 24px 10px 24px !important;
|
| 540 |
+
overflow-y: auto !important;
|
| 541 |
+
flex-grow: 1 !important; /* 🔥 關鍵:佔據剩餘空間 */
|
| 542 |
+
min-height: 300px !important; /* 🔥 關鍵:設定最小高度,防止內容太少時視窗縮成一團 */
|
| 543 |
}
|
| 544 |
|
| 545 |
+
/* 3. Footer:固定高度,不要拉伸 */
|
| 546 |
.modal-footer {
|
|
|
|
| 547 |
background: #f8fafc !important;
|
| 548 |
+
padding: 20px 24px !important;
|
| 549 |
+
border-top: 1px solid #f1f5f9 !important;
|
| 550 |
+
gap: 12px !important;
|
| 551 |
+
|
| 552 |
+
/* 🔥 關鍵:防止 Footer 被拉高 */
|
| 553 |
+
flex-shrink: 0 !important;
|
| 554 |
+
height: auto !important;
|
| 555 |
display: flex !important;
|
| 556 |
+
align-items: center !important; /* 垂直置中,防止按鈕變形 */
|
|
|
|
| 557 |
}
|
| 558 |
|
| 559 |
/* 彈出動畫 */
|
|
|
|
| 644 |
background: #94a3b8 !important; /* 加深灰色 */
|
| 645 |
opacity: 0.6 !important;
|
| 646 |
}
|
| 647 |
+
|
| 648 |
+
.tabs > .tab-nav > button {
|
| 649 |
+
color: #64748b !important;
|
| 650 |
+
font-weight: 500 !important;
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
/* 選中的 Tab:品牌紫色 + 紫色底線 */
|
| 654 |
+
.tabs > .tab-nav > button.selected {
|
| 655 |
+
color: #6366f1 !important; /* 文字變紫 */
|
| 656 |
+
border-bottom-color: #6366f1 !important; /* 底線變紫 */
|
| 657 |
+
border-bottom-width: 2px !important;
|
| 658 |
+
font-weight: 700 !important;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
.api-status-msg p {
|
| 662 |
+
font-size: 0.8rem !important;
|
| 663 |
+
margin-top: 6px !important;
|
| 664 |
+
margin-left: 8px !important;
|
| 665 |
+
font-weight: 600 !important;
|
| 666 |
+
font-family: 'Inter', sans-serif !important;
|
| 667 |
+
color: #64748b; /* 預設灰色 */
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
/* 讓輸入框和狀態訊息之間更緊湊 */
|
| 671 |
+
.api-status-msg {
|
| 672 |
+
min-height: 20px !important; /* 預留高度防止跳動 */
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
</style>
|
| 676 |
"""
|