Marco310 commited on
Commit
1a2b0fa
·
1 Parent(s): 7034378

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 CHANGED
@@ -19,12 +19,19 @@ from ui.renderers import (
19
  )
20
  from core.session import UserSession
21
  from services.planner_service import PlannerService
22
- from core.visualizers import create_animated_map
 
23
 
24
  class LifeFlowAI:
25
  def __init__(self):
26
  self.service = PlannerService()
27
 
 
 
 
 
 
 
28
  def _get_dashboard_html(self, active_agent: str = None, status: str = "idle", message: str = "Waiting") -> str:
29
  agents = ['team', 'scout', 'optimizer', 'navigator', 'weatherman', 'presenter']
30
  status_dict = {a: {'status': 'idle', 'message': 'Standby'} for a in agents}
@@ -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(), # step1
62
- gr.update(), # step2
63
- gr.update(), # step3
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
- iterator = self.service.run_step3_team(session)
150
-
151
- for event in iterator:
152
- sess = event.get("session", session)
153
- evt_type = event.get("type")
154
-
155
- if evt_type == "report_stream":
156
- report_content = event.get("content", "")
157
-
158
- if evt_type == "reasoning_update":
159
- agent, status, msg = event.get("agent_status")
160
- time_str = datetime.now().strftime('%H:%M:%S')
161
- log_entry = f"""
162
- <div style="margin-bottom: 8px; border-left: 3px solid #6366f1; padding-left: 10px;">
163
- <div style="font-size: 0.75rem; color: #94a3b8;">{time_str} • {agent.upper()}</div>
164
- <div style="color: #334155; font-size: 0.9rem;">{msg}</div>
165
- </div>
166
- """
167
- log_content = log_entry + log_content
168
- dashboard_html = self._get_dashboard_html(agent, status, msg)
169
- log_html = f'<div style="height: 500px; overflow-y: auto; padding: 10px; background: #fff;">{log_content}</div>'
170
- current_report = report_content + "\n\n" if report_content else loading_html
171
-
172
- yield (dashboard_html, log_html, current_report, tasks_html, sess.to_dict())
173
-
174
- if evt_type == "complete":
175
- final_report = event.get("report_html", report_content)
176
- final_log = f"""
177
- <div style="margin-bottom: 8px; border-left: 3px solid #10b981; padding-left: 10px; background: #ecfdf5; padding: 10px;">
178
- <div style="font-weight: bold; color: #059669;">✅ Planning Completed</div>
179
- </div>
180
- {log_content}
181
- """
182
- final_log_html = f'<div style="height: 500px; overflow-y: auto; padding: 10px; background: #fff;">{final_log}</div>'
183
-
184
- yield (
185
- self._get_dashboard_html('team', 'complete', 'Planning Finished'),
186
- final_log_html,
187
- final_report,
188
- tasks_html,
189
- sess.to_dict()
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, theme_btn, settings_btn, doc_btn = create_header()
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
- with gr.Column(scale=2):
 
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
- report_tab_output = gr.Markdown()
 
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, g_key, w_key, llm_provider,
304
- model_key, model_sel, close_set, save_set, set_stat) = create_settings_modal()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(fn=reset_all, inputs=[session_state], outputs=[step1_container, step2_container, step3_container, step4_container, stepper, session_state, user_input])
355
- back_btn.click(fn=reset_all, inputs=[session_state], outputs=[step1_container, step2_container, step3_container, step4_container, stepper, session_state, user_input])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=7860, share=True, show_error=True)
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()
config.py CHANGED
@@ -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': {
core/visualizers.py CHANGED
@@ -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', # Stop 1 (Start)
90
- 'blue': '#3498db', # Waypoints
91
- 'red': '#e74c3c', # Stop N (End)
92
- 'gray': '#7f8c8d', # Alternative
93
- 'route': '#4285F4' # Route Leg
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">⚠️ 替代方案 (Alternative Option)</div>
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="100%", # 這裡!設定高度 (單位是像素 px,也可以用 "100%" 但在 Gradio 有時會無效)
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
- # 建立查找表 (Lookup Maps)
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'), # Stop 1
174
- ('#3498db', 'blue'), # Stop 2
175
- ('#e74c3c', 'red'), # Stop 3
176
- ('#f39c12', 'orange') # Others
177
  ]
178
 
179
- # --- Layer 1: 路線 (Route) ---
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
- group_name = f"🔹 Task {idx + 1} Alternatives ({theme_name.upper()})"
 
 
 
 
 
 
 
 
 
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: 主要站點 (Main Stops) ---
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] # Green
291
  elif i == len(timeline) - 1:
292
- color_code, theme_name = THEMES[2] # Red
293
  else:
294
- color_code, theme_name = THEMES[1] # Blue
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
- folium.LayerControl(collapsed=False).add_to(m)
 
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
 
services/planner_service.py CHANGED
@@ -22,8 +22,13 @@ from core.visualizers import create_animated_map
22
  # 導入 Config
23
  from config import AGENTS_INFO
24
 
25
- # 導入 Agent 系統
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
- # 1. API Key 檢查
173
- google_key = session.custom_settings.get("google_maps_api_key")
174
- weather_key = session.custom_settings.get("openweather_api_key")
175
-
176
- provider = session.custom_settings.get("llm_provider")
177
- model_api_key = session.custom_settings.get("model_api_key")
 
178
 
179
- import os
180
- if not google_key:
181
- google_key = os.getenv("GOOGLE_MAPS_API_KEY", "")
182
- if not weather_key:
183
- weather_key = os.getenv("OPENWEATHER_API_KEY", "")
184
 
185
- if not model_api_key:
186
- raise ValueError("🔑 Missing Gemini API Key. Please configure Settings.")
187
 
 
188
  if provider.lower() == "gemini":
189
- planner_model = Gemini(id='gemini-2.5-flash', thinking_budget=1024, api_key=model_api_key)
190
- main_model = Gemini(id='gemini-2.5-flash', thinking_budget=1024, api_key=model_api_key)
191
- lite_model = Gemini(id="gemini-2.5-flash-lite", api_key=model_api_key)
192
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  models_dict = {
194
- "team": main_model, "scout": main_model, "optimizer": lite_model,
195
- "navigator": lite_model, "weatherman": lite_model, "presenter": main_model,
 
 
 
 
 
196
  }
197
 
 
 
198
  # 3. 準備 Tools (先實例化,還不要給 Agent)
199
- scout_tool = ScoutToolkit(google_key)
200
- optimizer_tool = OptimizationToolkit(google_key)
201
- navigator_tool = NavigationToolkit(google_key)
202
- weather_tool = WeatherToolkit(weather_key)
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().get("task_list")
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
- # 🔥 [CRITICAL FIX] 使用 Patch Context 包裹執行區塊
510
- with patch_repo_context(session.session_id):
511
-
512
- team_stream = session.core_team.run(
513
- f"Plan this trip: {task_list_str}",
514
- stream=True, stream_events=True, session_id=session.session_id
515
- )
516
-
517
-
518
- start_time = time.perf_counter()
519
- report_content = ""
520
- # 🔥 Event Loop: 捕捉事件並 yield 給 UI
521
- for event in team_stream:
522
-
523
- # 1. 報告內容 stream
524
- if event.event == RunEvent.run_content and event.agent_id == "presenter":
525
- report_content += event.content
526
- yield {
527
- "type": "report_stream",
528
- "content": report_content,
529
- "session": session
530
- }
531
-
532
- # 2. Team Delegate (分派任務)
533
- if event.event == TeamRunEvent.tool_call_started:
534
- tool_name = event.tool.tool_name
535
- if "delegate_task_to_member" in tool_name:
536
- member_id = event.tool.tool_args.get("member_id", "unknown")
537
- msg = f"Delegating to {member_id}..."
538
- self._add_reasoning(session, "team", f"👉 {msg}")
539
-
540
- yield {
541
- "type": "reasoning_update",
542
- "session": session,
543
- "agent_status": ("team", "working", msg)
544
- }
545
- else:
546
- self._add_reasoning(session, "team", f"🔧 Tool: {tool_name}")
547
-
548
- # 3. Member Tool Start (成員開始工作)
549
- elif event.event == RunEvent.tool_call_started:
550
- member_id = event.agent_id
551
- tool_name = event.tool.tool_name
552
- self._add_reasoning(session, member_id, f"Using tool: {tool_name}...")
553
-
554
- yield {
555
- "type": "reasoning_update",
556
- "session": session,
557
- "agent_status": (member_id, "working", f"Running {tool_name}...")
558
- }
559
-
560
- # 4. Member Tool End (成員工作結束)
561
- elif event.event == RunEvent.tool_call_completed:
562
- member_id = event.agent_id
563
- self._add_reasoning(session, member_id, "✅ Task completed")
564
-
565
- yield {
566
- "type": "reasoning_update",
567
- "session": session,
568
- "agent_status": (member_id, "idle", "Done")
569
- }
570
-
571
- # 5. Team Complete
572
- elif event.event == TeamRunEvent.run_completed:
573
- self._add_reasoning(session, "team", "🎉 Planning process finished")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
574
 
575
- logger.info(f"Total tokens: {event.metrics.total_tokens}")
576
- logger.info(f"Input tokens: {event.metrics.input_tokens}")
577
- logger.info(f"Output tokens: {event.metrics.output_tokens}")
578
- logger.info(f"Run time (s): {time.perf_counter() - start_time}")
 
 
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}
src/__init__.py CHANGED
@@ -0,0 +1 @@
 
 
1
+ __version__ = "1.0.0"
src/agent/setting/planner.py CHANGED
@@ -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 & Duration Rules
46
  - **task_id**: Unique integer starting from 1.
47
  - **description**: Short, clear task name.
48
  - **priority**: `HIGH`, `MEDIUM`, `LOW`.
49
- - **location_hint**: Specific POI name searchable on Google Maps.
 
 
 
 
 
 
 
 
 
 
 
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
src/optimization/tsptw_solver.py CHANGED
@@ -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 = 30,
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]:
src/tools/optimizer_toolkit.py CHANGED
@@ -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:
ui/__init__.py CHANGED
@@ -0,0 +1 @@
 
 
1
+ __version__ = "1.0.0"
ui/components/header.py CHANGED
@@ -8,12 +8,11 @@ import gradio as gr
8
  def create_header():
9
  """
10
  創建優化後的 Header:
11
- - Logo 區塊保留在原有容器中
12
- - 按鈕區塊移出 app-header-container,避免被父級的 backdrop-filter 捕獲
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. 功能按鈕 (移出上面的 Row,獨立存在)
33
- # 因為 CSS 設定了 position: fixed,它們會自動飛到右上角,不受文檔流影響
34
- with gr.Row(elem_classes="header-controls"):
35
  home_btn = gr.Button("🏠", size="sm", elem_classes="header-btn")
36
- theme_btn = gr.Button("🌓", size="sm", elem_classes="header-btn")
37
  settings_btn = gr.Button("⚙️", size="sm", elem_classes="header-btn")
38
  doc_btn = gr.Button("📖", size="sm", elem_classes="header-btn")
39
 
40
- return home_btn, theme_btn, settings_btn, doc_btn
 
 
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
ui/components/modals.py CHANGED
@@ -1,69 +1,108 @@
 
1
  import gradio as gr
2
-
3
- import gradio as gr
4
 
5
 
6
- def create_settings_modal():
7
  """
8
- 創建設定彈窗 (Modal) - 雙欄佈局優化版
9
- 回傳 9 個元件 (新增了 llm_provider)
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
- # === 分頁 1: API Keys ===
19
- with gr.TabItem("🔑 API Keys"):
20
- gr.Markdown("Configure your service credentials below.", elem_classes="tab-desc")
21
-
22
- g_key = gr.Textbox(label="Google Maps Platform Key", placeholder="AIza...", type="password")
23
- w_key = gr.Textbox(label="OpenWeatherMap Key", placeholder="Enter key...",
24
- type="password")
25
-
26
- # 修改開始:將原本單獨的 gem_key 改為左右佈局
27
- with gr.Row(equal_height=True): # 確保高度對齊
28
- # 左側:供應商選擇 (短)
29
- with gr.Column(scale=1, min_width=100):
30
- llm_provider = gr.Dropdown(
31
- choices=["Gemini"], #, "OpenAI", "Anthropic"
32
- value="Gemini",
33
- label="Provider",
34
- interactive=True,
35
- elem_id="provider-dropdown" # 方便 CSS 微調
36
- )
37
-
38
- # 右側:API Key 輸入 (長)
39
- with gr.Column(scale=3):
40
- gem_key = gr.Textbox(
41
- label="Model API Key",
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=["gemini-2.5-flash"], #, "gemini-1.5-flash", "gpt-4o"
52
- value="gemini-2.5-flash",
53
- label="AI Model",
54
  interactive=True,
55
- info="Only support 2.5-flash"
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 Configuration", variant="primary")
64
-
65
- # 🔥 重要:這裡現在回傳 9 個變數 (新增了 llm_provider)
66
- return modal, g_key, w_key, llm_provider, gem_key, model_sel, close_btn, save_btn, set_stat
 
 
 
 
 
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():
ui/theme.py CHANGED
@@ -56,44 +56,36 @@ def get_enhanced_css() -> str:
56
  }
57
 
58
  .header-controls {
59
- position: fixed !important; /* 關鍵:脫離文檔流,固定在視窗 */
60
- top: 25px !important; /* 距離視窗頂部 25px */
61
- right: 25px !important; /* 距離視窗右側 25px */
62
- z-index: 99999 !important; /* 關鍵:設得夠大,確保浮在所有內容最上面 */
63
 
64
  display: flex !important;
65
- flex-direction: row !important;
66
- gap: 10px !important;
67
- align-items: center !important;
68
 
69
- /* 視覺優化:半透明毛玻璃膠囊背景 */
70
- background: rgba(255, 255, 255, 0.85) !important;
71
- backdrop-filter: blur(12px) !important; /* 背景模糊效果 */
72
- padding: 8px 16px !important;
73
- border-radius: 99px !important; /* 圓形膠囊狀 */
74
  border: 1px solid rgba(255, 255, 255, 0.6) !important;
75
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1) !important;
76
 
77
- width: auto !important; /* 讓寬度隨按鈕數量自動縮放,不要佔滿整行 */
78
  min-width: 0 !important;
 
79
  }
80
 
 
81
  .header-btn {
82
- min-width: 44px !important;
83
- max-width: 44px !important;
84
- height: 44px !important;
85
- padding: 0 !important;
86
- border-radius: 50% !important;
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
- width: 100% !important;
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.3) !important;
428
  overflow: hidden !important;
429
- padding: 0 !important; /* 移除 Gradio 預設內距 */
430
- position: relative !important;
431
- animation: modal-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) !important;
 
 
 
 
 
432
  }
433
 
434
- /* 3. 彈窗內部佈局微調 */
435
- .modal-header {
436
- padding: 20px 24px 0 24px !important;
437
- background: white !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  }
439
 
440
  .modal-title h3 {
441
- margin: 0 !important;
442
  font-size: 1.5rem !important;
443
- color: #1e293b !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  }
445
 
 
446
  .modal-content {
447
  padding: 0 24px 10px 24px !important;
448
- max-height: 60vh !important;
449
- overflow-y: auto !important; /* 內容太長時可捲動 */
 
450
  }
451
 
 
452
  .modal-footer {
453
- padding: 16px 24px 24px 24px !important;
454
  background: #f8fafc !important;
455
- border-top: 1px solid #e2e8f0 !important;
 
 
 
 
 
 
456
  display: flex !important;
457
- justify-content: flex-end !important;
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
  """