Marco310 commited on
Commit
529a8bd
·
1 Parent(s): 9066e49

feat: 🚀 Evolve to Stateful MCP Architecture with Context Injection Middleware

Browse files

Major architectural overhaul to decouple Agent Logic (Service) from Tool Execution (Server) using the Model Context Protocol (MCP).

Key Highlights:
- **Micro-Architecture**: Migrated local toolkits to a standalone `FastMCP` server process (`mcp_server_lifeflow.py`).
- **Context Engineering**: Implemented an AOP-style interceptor using Python `ContextVars`. This automatically injects `session_id` and `api_key` into MCP tool calls, keeping Agent prompts clean and reducing token usage.
- **Multi-Tenant Safety**: Achieved strict session isolation. User-provided API keys dynamically override system environment variables via the injection layer.
- **Runtime Metaprogramming**: Solved Agno/Phidata internal naming conflicts (`_` prefix) by dynamically patching tool entrypoints at startup.
- **Exclusive Channels**: Configured dedicated MCP pipes for each Agent (`Scout`, `Navigator`, etc.) to prevent tool hallucination.

This commit satisfies all requirements for Hackathon Track 2: MCP in Action.

app.py CHANGED
@@ -1,35 +1,190 @@
1
  """
2
- LifeFlow AI - Main Application (Fixed Output Mismatch)
3
- 修復 Step 4 回傳值數量不匹配導致的 AttributeError
4
- 確保 outputs 清單包含所有 7 個元件
5
  """
6
 
7
  import gradio as gr
8
- import inspect
 
 
 
 
9
  from datetime import datetime
 
 
 
 
 
10
  from ui.theme import get_enhanced_css
11
  from ui.components.header import create_header
12
  from ui.components.progress_stepper import create_progress_stepper, update_stepper
13
  from ui.components.input_form import create_input_form, toggle_location_inputs
14
  from ui.components.modals import create_settings_modal, create_doc_modal
15
- from ui.renderers import (
16
- create_agent_dashboard,
17
- create_summary_card,
18
- )
19
  from core.session import UserSession
20
  from services.planner_service import PlannerService
21
  from services.validator import APIValidator
22
  from config import MODEL_OPTIONS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  class LifeFlowAI:
25
  def __init__(self):
26
  self.service = PlannerService()
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  def cancel_wrapper(self, session_data):
29
  session = UserSession.from_dict(session_data)
30
- if session.session_id:
31
- self.service.cancel_session(session.session_id)
32
- # 不需要回傳任何東西,這只是一個副作用 (Side Effect) 函數
33
 
34
  def _get_dashboard_html(self, active_agent: str = None, status: str = "idle", message: str = "Waiting") -> str:
35
  agents = ['team', 'scout', 'optimizer', 'navigator', 'weatherman', 'presenter']
@@ -42,19 +197,13 @@ class LifeFlowAI:
42
 
43
  def _check_api_status(self, session_data):
44
  session = UserSession.from_dict(session_data)
45
- has_model_api = bool(session.custom_settings.get("model_api"))
46
- has_google = bool(session.custom_settings.get("google_maps_api_key"))
47
- msg = "✅ System Ready" if (has_model_api and has_google) else "⚠️ Missing API Keys"
48
- return msg
49
 
50
  def _get_gradio_chat_history(self, session):
51
- history = []
52
- for msg in session.chat_history:
53
- history.append({"role": msg["role"], "content": msg["message"]})
54
- return history
55
-
56
- # ================= Event Wrappers =================
57
 
 
58
  def analyze_wrapper(self, user_input, auto_loc, lat, lon, session_data):
59
  session = UserSession.from_dict(session_data)
60
  iterator = self.service.run_step1_analysis(user_input, auto_loc, lat, lon, session)
@@ -63,61 +212,19 @@ class LifeFlowAI:
63
  current_session = event.get("session", session)
64
  if evt_type == "error":
65
  gr.Warning(event.get('message'))
66
- yield (
67
- gr.update(visible=True), # Step 1 Container: 保持顯示
68
- gr.update(visible=False), # Step 2 Container: 保持隱藏
69
- gr.update(visible=False), # Step 3 Container
70
- gr.HTML(f"<div style='color:red'>{event.get('message')}</div>"), # s1_stream
71
- gr.update(), # task_list
72
- gr.update(), # task_summary
73
- gr.update(), # chatbot
74
- current_session.to_dict() # state
75
- )
76
  return
77
-
78
  if evt_type == "stream":
79
- yield (
80
- gr.update(visible=True),
81
- gr.update(visible=False),
82
- gr.update(visible=False),
83
- event.get("stream_text", ""),
84
- gr.update(),
85
- gr.update(),
86
- gr.update(),
87
- current_session.to_dict()
88
- )
89
-
90
  elif evt_type == "complete":
91
  tasks_html = self.service.generate_task_list_html(current_session)
92
-
93
- print(event)
94
  date_str = event.get("start_time", "N/A")
95
- high_priority = event.get("high_priority", "N/A")
96
- total_time = event.get("total_time", "N/A")
97
- loc_str = event.get("start_location", "N/A")
98
-
99
- summary_html = create_summary_card(len(current_session.task_list),
100
- high_priority,
101
- total_time,
102
- location=loc_str, date=date_str)
103
-
104
  chat_history = self._get_gradio_chat_history(current_session)
105
  if not chat_history:
106
- chat_history = [
107
- {"role": "assistant", "content": event.get('stream_text', "")},
108
- {"role": "assistant", "content": "Hi! I'm LifeFlow. Tell me if you want to change priorities, add stops, or adjust times."}]
109
  current_session.chat_history.append({"role": "assistant", "message": "Hi! I'm LifeFlow...", "time": ""})
110
-
111
- yield (
112
- gr.update(visible=False), # Hide S1
113
- gr.update(visible=True), # Show S2
114
- gr.update(visible=False), # Hide S3
115
- "", # Clear Stream
116
- gr.HTML(tasks_html),
117
- gr.HTML(summary_html),
118
- chat_history,
119
- current_session.to_dict()
120
- )
121
 
122
  def chat_wrapper(self, msg, session_data):
123
  session = UserSession.from_dict(session_data)
@@ -129,175 +236,81 @@ class LifeFlowAI:
129
  gradio_history = self._get_gradio_chat_history(sess)
130
  yield (gradio_history, tasks_html, summary_html, sess.to_dict())
131
 
132
- def step3_wrapper(self, session_data):
 
133
  session = UserSession.from_dict(session_data)
134
-
135
- # Init variables
136
- log_content = ""
137
- report_content = ""
138
-
139
  tasks_html = self.service.generate_task_list_html(session)
140
  init_dashboard = self._get_dashboard_html('team', 'working', 'Initializing...')
141
-
142
- # HTML Content (No Indentation)
143
- loading_html = inspect.cleandoc("""
144
- <div style="text-align: center; padding: 60px 20px; color: #64748b;">
145
- <div style="font-size: 48px; margin-bottom: 20px; animation: pulse 1.5s infinite;">🧠</div>
146
- <div style="font-size: 1.2rem; font-weight: 600; color: #334155;">AI Team is analyzing your request...</div>
147
- <div style="font-size: 0.9rem; margin-top: 8px;">Checking routes, weather, and optimizing schedule.</div>
148
- </div>
149
- <style>@keyframes pulse { 0% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.1); opacity: 0.7; } 100% { transform: scale(1); opacity: 1; } }</style>
150
- """)
151
-
152
- init_log = '<div style="padding: 10px; color: #94a3b8; font-style: italic;">Waiting for agents...</div>'
153
 
154
  yield (init_dashboard, init_log, loading_html, tasks_html, session.to_dict())
155
 
156
  try:
157
- iterator = self.service.run_step3_team(session)
158
-
159
- for event in iterator:
160
  sess = event.get("session", session)
161
  evt_type = event.get("type")
162
 
163
- if evt_type == "error":
164
- raise Exception(event.get("message"))
165
-
166
- if evt_type == "report_stream":
167
- report_content = event.get("content", "")
168
 
169
  if evt_type == "reasoning_update":
170
  agent, status, msg = event.get("agent_status")
171
  time_str = datetime.now().strftime('%H:%M:%S')
172
- log_entry = f"""
173
- <div style="margin-bottom: 8px; border-left: 3px solid #6366f1; padding-left: 10px;">
174
- <div style="font-size: 0.75rem; color: #94a3b8;">{time_str} • {agent.upper()}</div>
175
- <div style="color: #334155; font-size: 0.9rem;">{msg}</div>
176
- </div>
177
- """
178
  log_content = log_entry + log_content
179
  dashboard_html = self._get_dashboard_html(agent, status, msg)
180
  log_html = f'<div style="height: 500px; overflow-y: auto; padding: 10px; background: #fff;">{log_content}</div>'
181
  current_report = report_content + "\n\n" if report_content else loading_html
182
-
183
- yield (dashboard_html, log_html, current_report, tasks_html, sess.to_dict())
184
-
185
- if evt_type in ["report_stream", "reasoning_update"]:
186
  yield (dashboard_html, log_html, current_report, tasks_html, sess.to_dict())
187
 
188
  if evt_type == "complete":
189
  final_report = event.get("report_html", report_content)
190
- final_log = f"""
191
- <div style="margin-bottom: 8px; border-left: 3px solid #10b981; padding-left: 10px; background: #ecfdf5; padding: 10px;">
192
- <div style="font-weight: bold; color: #059669;">✅ Planning Completed</div>
193
- </div>
194
- {log_content}
195
- """
196
  final_log_html = f'<div style="height: 500px; overflow-y: auto; padding: 10px; background: #fff;">{final_log}</div>'
197
-
198
- yield (
199
- self._get_dashboard_html('team', 'complete', 'Planning Finished'),
200
- final_log_html,
201
- final_report,
202
- tasks_html,
203
- sess.to_dict()
204
- )
205
 
206
  except Exception as e:
207
- error_msg = str(e)
208
- gr.Warning(f"⚠️ Planning Interrupted: {error_msg}")
209
-
210
- # 這裡我們做一個特殊的 UI 操作:
211
- # 雖然這個 Wrapper 的 outputs 綁定的是 Step 3 的內部元件
212
- # 但我們可以透過修改 Step 3 的 "Log" 或 "Report" 區域來顯示「重試按鈕」
213
- # 或者 (更進階做法) 在 app.py 綁定 outputs 時,就把 Step 2 container 也包進來。
214
-
215
- # 💡 最簡單不改動 outputs 結構的做法:
216
- # 在 Log 區域顯示紅色錯誤,並提示用戶手動點擊 "Back"
217
-
218
- error_html = f"""
219
- <div style="padding: 20px; background: #fee2e2; border: 1px solid #ef4444; border-radius: 8px; color: #b91c1c;">
220
- <strong>❌ Error Occurred:</strong><br>{error_msg}<br><br>
221
- Please check your API keys or modify tasks, then try again.
222
- </div>
223
- """
224
-
225
- # 回傳錯誤訊息到 UI,讓使用者知道發生了什麼
226
- yield (
227
- self._get_dashboard_html('team', 'error', 'Failed'), # Dashboard 變紅
228
- error_html, # Log 顯示錯誤
229
- "",
230
- tasks_html,
231
- session.to_dict()
232
- )
233
 
234
  def step4_wrapper(self, session_data):
235
  session = UserSession.from_dict(session_data)
236
  result = self.service.run_step4_finalize(session)
237
-
238
- # 🔥 Wrapper 回傳 7 個值
239
  if result['type'] == 'success':
240
- return (
241
- gr.update(visible=False), # 1. Step 3 Hide
242
- gr.update(visible=True), # 2. Step 4 Show
243
- result['summary_tab_html'], # 3. Summary Tab HTML
244
- result['report_md'], # 4. Report Tab Markdown
245
- result['task_list_html'], # 5. Task List HTML
246
- result['map_fig'], # 6. Map Plot
247
- session.to_dict() # 7. Session State
248
- )
249
  else:
250
- return (
251
- gr.update(visible=True), gr.update(visible=False),
252
- "", "", "", None,
253
- session.to_dict()
254
- )
255
 
256
  def save_settings(self, g, w, prov, m_key, m_sel, fast, g_key_in, f_sel, s_data):
257
  sess = UserSession.from_dict(s_data)
258
-
259
- # 存入 Session
260
- sess.custom_settings.update({
261
- 'google_maps_api_key': g,
262
- 'openweather_api_key': w,
263
- 'llm_provider': prov,
264
- 'model_api_key': m_key, # 主模型 Key
265
- 'model': m_sel, # 主模型 ID
266
- 'enable_fast_mode': fast, # 🔥 Fast Mode 開關
267
- 'groq_fast_model': f_sel,
268
- 'groq_api_key': g_key_in # 🔥 獨立 Groq Key
269
- })
270
- print("Settings saved:", sess.custom_settings)
271
-
272
  return gr.update(visible=False), sess.to_dict(), "✅ Configuration Saved"
273
- # ================= Main UI Builder =================
 
274
 
275
  def build_interface(self):
276
  container_css = """
277
- .gradio-container {
278
- max-width: 100% !important;
279
- padding: 0;
280
- height: 100vh !important; /* 1. 關鍵:鎖定高度為視窗大小 */
281
- overflow-y: auto !important; /* 2. 關鍵:內容過長時,在內部產生捲軸 */
282
- }
283
  """
284
-
285
- with gr.Blocks(title="LifeFlow AI", css=container_css) as demo:
286
  gr.HTML(get_enhanced_css())
287
-
288
  session_state = gr.State(UserSession().to_dict())
289
 
290
  with gr.Column(elem_classes="step-container"):
291
- home_btn, settings_btn, doc_btn = create_header() #theme_btn
292
-
293
  stepper = create_progress_stepper(1)
294
- status_bar = gr.Markdown("Ready", visible=False)
295
 
296
- # STEP 1
 
 
 
297
  with gr.Group(visible=True, elem_classes="step-container centered-input-container") as step1_container:
298
  (input_area, s1_stream_output, user_input, auto_loc, loc_group, lat_in, lon_in, analyze_btn) = create_input_form("")
299
 
300
- # STEP 2
301
  with gr.Group(visible=False, elem_classes="step-container") as step2_container:
302
  gr.Markdown("### ✅ Review & Refine Tasks")
303
  with gr.Row(elem_classes="step2-split-view"):
@@ -317,276 +330,132 @@ class LifeFlowAI:
317
  back_btn = gr.Button("← Back", variant="secondary", scale=1)
318
  plan_btn = gr.Button("🚀 Start Planning", variant="primary", scale=2)
319
 
320
- # STEP 3
321
  with gr.Group(visible=False, elem_classes="step-container") as step3_container:
322
  gr.Markdown("### 🤖 AI Team Operations")
323
-
324
- # 1. Agent Dashboard (保持不變)
325
  with gr.Group(elem_classes="agent-dashboard-container"):
326
  agent_dashboard = gr.HTML(value=self._get_dashboard_html())
327
-
328
- # 2. 主要內容區 (左右分欄)
329
  with gr.Row():
330
- # 🔥 左側:主要報告顯示區 (佔 3/4 寬度)
331
  with gr.Column(scale=3):
332
  with gr.Tabs():
333
  with gr.Tab("📝 Full Report"):
334
- # 🔥 修正 2: 加入白底容器 (live-report-wrapper)
335
  with gr.Group(elem_classes="live-report-wrapper"):
336
  live_report_md = gr.Markdown()
337
-
338
  with gr.Tab("📋 Task List"):
339
  with gr.Group(elem_classes="panel-container"):
340
  with gr.Group(elem_classes="scrollable-content"):
341
  task_list_s3 = gr.HTML()
342
-
343
- # 🔥 右側:控制區與日誌 (佔 1/4 寬度)
344
  with gr.Column(scale=1):
345
- # 🔥 修正 1: 將停止按鈕移到這裡 (右側欄位頂部)
346
- # variant="stop" 會呈現紅色,更符合 "緊急停止" 的語意
347
  cancel_plan_btn = gr.Button("🛑 Stop & Back to Edit", variant="stop")
348
-
349
  gr.Markdown("### ⚡ Activity Log")
350
  with gr.Group(elem_classes="panel-container"):
351
  planning_log = gr.HTML(value="Waiting...")
352
 
353
- # STEP 4
354
  with gr.Group(visible=False, elem_classes="step-container") as step4_container:
355
  with gr.Row():
356
- # Left: Tabs (Summary / Report / Tasks)
357
  with gr.Column(scale=1, elem_classes="split-left-panel"):
358
  with gr.Tabs():
359
  with gr.Tab("📊 Summary"):
360
  summary_tab_output = gr.HTML()
361
-
362
  with gr.Tab("📝 Full Report"):
363
  with gr.Group(elem_classes="live-report-wrapper"):
364
  report_tab_output = gr.Markdown()
365
-
366
  with gr.Tab("📋 Task List"):
367
  task_list_tab_output = gr.HTML()
368
-
369
- # Right: Hero Map
370
  with gr.Column(scale=2, elem_classes="split-right-panel"):
371
  map_view = gr.HTML(label="Route Map")
372
 
373
- # Modals & Events
374
- (settings_modal,
375
- g_key, g_stat,
376
- w_key, w_stat,
377
- llm_provider, main_key, main_stat, model_sel,
378
- fast_mode_chk, groq_model_sel, groq_key, groq_stat,
379
- close_set, save_set, set_stat) = create_settings_modal()
380
-
381
- # 2. 綁定 Google Maps 測試
382
- g_key.blur(
383
- fn=lambda k: "⏳ Checking..." if k else "", # 先顯示 checking
384
- inputs=[g_key], outputs=[g_stat]
385
- ).then(
386
- fn=APIValidator.test_google_maps,
387
- inputs=[g_key],
388
- outputs=[g_stat]
389
- )
390
-
391
- # 3. OpenWeather 自動驗證
392
- w_key.blur(
393
- fn=lambda k: "⏳ Checking..." if k else "",
394
- inputs=[w_key], outputs=[w_stat]
395
- ).then(
396
- fn=APIValidator.test_openweather,
397
- inputs=[w_key],
398
- outputs=[w_stat]
399
- )
400
 
401
- # 4. 綁定 Main Brain 測試
402
- main_key.blur(
403
- fn=lambda k: "⏳ Checking..." if k else "",
404
- inputs=[main_key], outputs=[main_stat]
405
- ).then(
406
- fn=APIValidator.test_llm,
407
- inputs=[llm_provider, main_key, model_sel],
408
- outputs=[main_stat]
409
- )
410
-
411
- # 5. Groq 自動驗證
412
- groq_key.blur(
413
- fn=lambda k: "⏳ Checking..." if k else "",
414
- inputs=[groq_key], outputs=[groq_stat]
415
- ).then(
416
- fn=lambda k, m: APIValidator.test_llm("Groq", k, m),
417
- inputs=[groq_key, groq_model_sel],
418
- outputs=[groq_stat]
419
- )
420
 
421
  def resolve_groq_visibility(fast_mode, provider):
422
- """
423
- 綜合判斷 Groq 相關欄位是否該顯示
424
- 回傳: (Model選單狀態, Key輸入框狀態, 狀態訊息狀態)
425
- """
426
- # 1. 如果沒開 Fast Mode -> 全部隱藏 + 清空狀態
427
- if not fast_mode:
428
- return (
429
- gr.update(visible=False),
430
- gr.update(visible=False),
431
- gr.update(visible=False) # 🔥 清空狀態
432
- )
433
-
434
- # 2. 如果開了 Fast Mode
435
  model_vis = gr.update(visible=True)
 
 
 
436
 
437
- # 3. 判斷 Key 是否需要顯示
438
- is_main_groq = (provider == "Groq")
439
-
440
- if is_main_groq:
441
- # 如果主 Provider 是 Groq,隱藏 Key 並清空狀態 (避免誤導)
442
- return (
443
- model_vis,
444
- gr.update(visible=False),
445
- gr.update(visible=False) # 🔥 清空狀態
446
- )
447
- else:
448
- # 否則顯示 Key,狀態保持原樣 (不更動)
449
- return (
450
- model_vis,
451
- gr.update(visible=True),
452
- gr.update(visible=True) # 保持原樣
453
- )
454
-
455
- fast_mode_chk.change(
456
- fn=resolve_groq_visibility,
457
- inputs=[fast_mode_chk, llm_provider],
458
- outputs=[groq_model_sel, groq_key, groq_stat] # 🔥 新增 groq_stat
459
- )
460
-
461
- # --- 事件 B: 當 "Main Provider" 改變時 ---
462
  def on_provider_change(provider, fast_mode):
463
- # 1. 更新 Model 下拉選單 (原有邏輯)
464
  new_choices = MODEL_OPTIONS.get(provider, [])
465
  default_val = new_choices[0][1] if new_choices else ""
466
  model_update = gr.Dropdown(choices=new_choices, value=default_val)
467
-
468
- # 2. 清空 Main API Key (原有邏輯)
469
- key_clear = gr.Textbox(value="")
470
- stat_clear = gr.Markdown(value="")
471
-
472
- # 3. 重算 Groq 欄位可見性 (包含狀態清空)
473
  g_model_vis, g_key_vis, g_stat_vis = resolve_groq_visibility(fast_mode, provider)
474
-
475
- # 回傳順序要對應下面的 outputs
476
- return (
477
- model_update, # model_sel
478
- key_clear, # main_key
479
- stat_clear, # main_stat
480
- g_model_vis, # groq_model_sel
481
- g_key_vis, # groq_key
482
- g_stat_vis # groq_stat (新)
483
- )
484
-
485
- llm_provider.change(
486
- fn=on_provider_change,
487
- inputs=[llm_provider, fast_mode_chk],
488
- # 🔥 outputs 增加到 6 個,確保所有狀態都被重置
489
- outputs=[model_sel, main_key, main_stat, groq_model_sel, groq_key, groq_stat]
490
- )
491
 
492
  doc_modal, close_doc_btn = create_doc_modal()
493
-
494
  settings_btn.click(fn=lambda: gr.update(visible=True), outputs=[settings_modal])
495
  close_set.click(fn=lambda: gr.update(visible=False), outputs=[settings_modal])
496
  doc_btn.click(fn=lambda: gr.update(visible=True), outputs=[doc_modal])
497
  close_doc_btn.click(fn=lambda: gr.update(visible=False), outputs=[doc_modal])
498
- #theme_btn.click(fn=None, js="() => { document.querySelector('.gradio-container').classList.toggle('theme-dark'); }")
499
-
500
- analyze_btn.click(
501
- fn=self.analyze_wrapper,
502
- inputs=[user_input, auto_loc, lat_in, lon_in, session_state,],
503
- outputs=[step1_container, step2_container, step3_container, s1_stream_output, task_list_box, task_summary_box, chatbot, session_state]
504
- ).then(fn=lambda: update_stepper(2), outputs=[stepper])
505
 
 
 
506
  chat_send.click(fn=self.chat_wrapper, inputs=[chat_input, session_state], outputs=[chatbot, task_list_box, task_summary_box, session_state]).then(fn=lambda: "", outputs=[chat_input])
507
  chat_input.submit(fn=self.chat_wrapper, inputs=[chat_input, session_state], outputs=[chatbot, task_list_box, task_summary_box, session_state]).then(fn=lambda: "", outputs=[chat_input])
508
 
509
- step3_start = plan_btn.click(
510
- fn=lambda: (gr.update(visible=False), gr.update(visible=True), update_stepper(3)),
511
- outputs=[step2_container, step3_container, stepper]
512
- ).then(
513
- fn=self.step3_wrapper,
514
- inputs=[session_state],
515
- outputs=[agent_dashboard, planning_log, live_report_md, task_list_s3, session_state],
516
- show_progress="hidden"
517
- )
518
-
519
- cancel_plan_btn.click(
520
- fn=self.cancel_wrapper, # 1. 先執行取消
521
- inputs=[session_state],
522
- outputs=None
523
- ).then(
524
- fn=lambda: (gr.update(visible=True), gr.update(visible=False), update_stepper(2)), # 2. 再切換 UI
525
- inputs=None,
526
- outputs=[step2_container, step3_container, stepper]
527
- )
528
 
529
- step3_start.then(
530
- fn=self.step4_wrapper,
531
- inputs=[session_state],
532
- # 🔥🔥🔥 關鍵修正:這裡列出了 7 個 Outputs,必須對應 Wrapper 回傳的 7 個值
533
- outputs=[
534
- step3_container, # 1. Hide Step 3
535
- step4_container, # 2. Show Step 4
536
- summary_tab_output, # 3. Summary Tab
537
- report_tab_output, # 4. Report Tab
538
- task_list_tab_output, # 5. Task List Tab
539
- map_view, # 6. Map
540
- session_state # 7. State
541
- ]
542
- ).then(fn=lambda: update_stepper(4), outputs=[stepper])
543
 
544
  def reset_all(session_data):
545
- old_session = UserSession.from_dict(session_data)
546
- new_session = UserSession()
547
- new_session.custom_settings = old_session.custom_settings
548
- return (gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), update_stepper(1), new_session.to_dict(), "")
549
-
550
- home_btn.click(
551
- fn=self.cancel_wrapper, # 1. 先執行取消 (防止後台繼續跑)
552
- inputs=[session_state],
553
- outputs=None
554
- ).then(
555
- fn=reset_all, # 2. 再重置 UI
556
- inputs=[session_state],
557
- outputs=[step1_container, step2_container, step3_container, step4_container, stepper, session_state,
558
- user_input]
559
- )
560
-
561
- back_btn.click(
562
- fn=self.cancel_wrapper,
563
- inputs=[session_state],
564
- outputs=None
565
- ).then(
566
- fn=reset_all,
567
- inputs=[session_state],
568
- outputs=[step1_container, step2_container, step3_container, step4_container, stepper, session_state,
569
- user_input]
570
- )
571
-
572
- save_set.click(
573
- fn=self.save_settings,
574
- # 輸入參數對應上面的 create_settings_modal 回傳順序
575
- inputs=[g_key, w_key, llm_provider, main_key, model_sel, fast_mode_chk, groq_key, groq_model_sel, session_state],
576
- outputs=[settings_modal, session_state, status_bar]
577
- )
578
-
579
-
580
-
581
  auto_loc.change(fn=toggle_location_inputs, inputs=auto_loc, outputs=loc_group)
582
  demo.load(fn=self._check_api_status, inputs=[session_state], outputs=[status_bar])
583
 
584
  return demo
585
 
 
 
586
  def main():
587
- app = LifeFlowAI()
588
- demo = app.build_interface()
589
- demo.launch(server_name="0.0.0.0", server_port=7860, share=True, show_error=True)
590
- #7860
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
591
  if __name__ == "__main__":
592
  main()
 
1
  """
2
+ LifeFlow AI - Main Application (Fixed for Exclusive Tools Dict)
3
+ 修正錯誤: 確保傳遞給 Service的是 Dictionary 而不是單一 Object
4
+ 實現多通道隔離 (Scout 只能用 Scout 的工具)
5
  """
6
 
7
  import gradio as gr
8
+ import asyncio
9
+ import os
10
+ import sys
11
+ import traceback
12
+ from contextlib import asynccontextmanager
13
  from datetime import datetime
14
+
15
+ # Agno / MCP Imports
16
+ from agno.tools.mcp import MCPTools
17
+
18
+ # UI Imports
19
  from ui.theme import get_enhanced_css
20
  from ui.components.header import create_header
21
  from ui.components.progress_stepper import create_progress_stepper, update_stepper
22
  from ui.components.input_form import create_input_form, toggle_location_inputs
23
  from ui.components.modals import create_settings_modal, create_doc_modal
24
+ from ui.renderers import create_agent_dashboard, create_summary_card
25
+
26
+ # Core Imports
 
27
  from core.session import UserSession
28
  from services.planner_service import PlannerService
29
  from services.validator import APIValidator
30
  from config import MODEL_OPTIONS
31
+ from src.infra.client_context import client_session_ctx
32
+
33
+ # ==================== 1. Tool Isolation Config ====================
34
+
35
+ # 定義每個 Agent 專屬的工具名稱 (對應 mcp_server_lifeflow.py 裡的 @mcp.tool 名稱)
36
+ AGENT_TOOL_MAP = {
37
+ "scout": "search_and_offload",
38
+ "optimizer": "optimize_from_ref",
39
+ "navigator": "calculate_traffic_and_timing",
40
+ "weatherman": "check_weather_for_timeline",
41
+ "presenter": "read_final_itinerary",
42
+ }
43
+
44
+ # 全域變數:存放 "Agent Name" -> "MCPToolkit Instance" 的對照表
45
+ GLOBAL_TOOLKITS_DICT = {}
46
+
47
+ # ==================== 2. MCP Lifecycle Manager ====================
48
+
49
+ def inject_context_into_tools(toolkit):
50
+ """
51
+ 手術刀函式:
52
+ 1. [Context] 為每個工具安裝攔截器,自動注入 session_id。
53
+ 2. [Rename] (新增) 移除 Agno 自動加上的底線前綴,讓 Agent 能找到工具。
54
+ """
55
+ # 嘗試取得工具列表
56
+ funcs = getattr(toolkit, "functions", {})
57
+
58
+ # 如果是 List,轉成 Dict 方便我們操作名稱
59
+ # (Agno 的 MCPToolkit 通常是用 Dict 存的: {name: function})
60
+ if isinstance(funcs, list):
61
+ print("⚠️ Warning: toolkit.functions is a list. Renaming might be tricky.")
62
+ tool_iterator = funcs
63
+ elif isinstance(funcs, dict):
64
+ tool_iterator = list(funcs.values()) # 轉成 List 以便安全迭代
65
+ else:
66
+ print(f"⚠️ Warning: Unknown functions type: {type(funcs)}")
67
+ return 0
68
+
69
+ count = 0
70
+ renamed_count = 0
71
+
72
+ for tool in tool_iterator:
73
+ # --- A. 執行 Context 注入 (原本的邏輯) ---
74
+ original_entrypoint = tool.entrypoint
75
+
76
+ async def context_aware_wrapper(*args, **kwargs):
77
+ current_sid = client_session_ctx.get()
78
+ if current_sid:
79
+ kwargs['session_id'] = current_sid
80
+
81
+ if asyncio.iscoroutinefunction(original_entrypoint):
82
+ return await original_entrypoint(*args, **kwargs)
83
+ else:
84
+ return original_entrypoint(*args, **kwargs)
85
+
86
+ tool.entrypoint = context_aware_wrapper
87
+ count += 1
88
+
89
+ # --- B. 執行更名手術 (新增邏輯) ---
90
+ # 如果名字是以 "_" 開頭 (例如 _search_and_offload)
91
+ if tool.name.startswith("_"):
92
+ old_name = tool.name
93
+ new_name = old_name[1:] # 去掉第一個字元
94
+
95
+ # 1. 改工具物件本身的名字
96
+ tool.name = new_name
97
+
98
+ # 2. 如果 funcs 是字典,也要更新 Key,不然 Agent 查表會查不到
99
+ if isinstance(funcs, dict):
100
+ # 建立新 Key,移除舊 Key
101
+ funcs[new_name] = tool
102
+ if old_name in funcs:
103
+ del funcs[old_name]
104
+
105
+ renamed_count += 1
106
+ # print(f" 🔧 Renamed '{old_name}' -> '{new_name}'")
107
+
108
+ print(f" 🛡️ Injection: {count} tools patched, {renamed_count} tools renamed.")
109
+ return count
110
+
111
+
112
+
113
+ @asynccontextmanager
114
+ async def lifespan_manager():
115
+ """
116
+ MCP 生命週期管理器 (多通道模式):
117
+ 迴圈建立 5 個獨立的 MCP Client,每個只包含該 Agent 需要的工具。
118
+ """
119
+ global GLOBAL_TOOLKITS_DICT
120
+ print("🚀 [System] Initializing Exclusive MCP Connections...")
121
+
122
+ server_script = "mcp_server_lifeflow.py"
123
+ env_vars = os.environ.copy()
124
+ env_vars["PYTHONUNBUFFERED"] = "1"
125
+
126
+ started_toolkits = []
127
+
128
+ try:
129
+ # 🔥 迴圈建立隔離的工具箱
130
+ for agent_name, tool_name in AGENT_TOOL_MAP.items():
131
+ print(f" ⚙️ Connecting {agent_name} -> tool: {tool_name}...")
132
+
133
+ # 建立專屬連線
134
+ tool = MCPTools(
135
+ command=f"{sys.executable} {server_script}",
136
+ env=env_vars,
137
+ # 🔥 關鍵:只允許看見這個工具
138
+ include_tools=[tool_name]
139
+ )
140
+
141
+ # 啟動連線
142
+ await tool.connect()
143
+
144
+ inject_context_into_tools(tool)
145
+ GLOBAL_TOOLKITS_DICT[agent_name] = tool
146
+ # 存入字典
147
+ started_toolkits.append(tool)
148
+
149
+ print(f"✅ [System] All {len(started_toolkits)} MCP Channels Ready!")
150
+ yield
151
+
152
+ except Exception as e:
153
+ print(f"❌ [System] MCP Connection Failed: {e}")
154
+ traceback.print_exc()
155
+ yield
156
+
157
+ finally:
158
+ print("🔻 [System] Closing All MCP Connections...")
159
+ for name, tool in GLOBAL_TOOLKITS_DICT.items():
160
+ try:
161
+ await tool.close()
162
+ except Exception as e:
163
+ print(f"⚠️ Error closing {name}: {e}")
164
+ print("🏁 [System] Shutdown Complete.")
165
+
166
+ # ==================== 3. Main Application Class ====================
167
 
168
  class LifeFlowAI:
169
  def __init__(self):
170
  self.service = PlannerService()
171
 
172
+ def inject_tools(self, toolkits_dict):
173
+ """
174
+ 依賴注入:接收工具字典
175
+ """
176
+ # 這裡會檢查傳進來的是不是字典,避免你剛才遇到的錯誤
177
+ if isinstance(toolkits_dict, dict) and toolkits_dict:
178
+ self.service.set_global_tools(toolkits_dict)
179
+ print("💉 [App] Tools Dictionary injected into Service.")
180
+ else:
181
+ print(f"⚠️ [App] Warning: Invalid toolkits format. Expected dict, got {type(toolkits_dict)}")
182
+
183
+ # ... (Event Wrappers 保持不變,因為它們只呼叫 self.service) ...
184
+
185
  def cancel_wrapper(self, session_data):
186
  session = UserSession.from_dict(session_data)
187
+ if session.session_id: self.service.cancel_session(session.session_id)
 
 
188
 
189
  def _get_dashboard_html(self, active_agent: str = None, status: str = "idle", message: str = "Waiting") -> str:
190
  agents = ['team', 'scout', 'optimizer', 'navigator', 'weatherman', 'presenter']
 
197
 
198
  def _check_api_status(self, session_data):
199
  session = UserSession.from_dict(session_data)
200
+ has_key = bool(session.custom_settings.get("model_api_key")) or bool(os.environ.get("OPENAI_API_KEY")) or bool(os.environ.get("GOOGLE_API_KEY"))
201
+ return "✅ System Ready (Exclusive Mode)" if has_key else "⚠️ Check API Keys"
 
 
202
 
203
  def _get_gradio_chat_history(self, session):
204
+ return [{"role": msg["role"], "content": msg["message"]} for msg in session.chat_history]
 
 
 
 
 
205
 
206
+ # --- Wrappers ---
207
  def analyze_wrapper(self, user_input, auto_loc, lat, lon, session_data):
208
  session = UserSession.from_dict(session_data)
209
  iterator = self.service.run_step1_analysis(user_input, auto_loc, lat, lon, session)
 
212
  current_session = event.get("session", session)
213
  if evt_type == "error":
214
  gr.Warning(event.get('message'))
215
+ yield (gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), gr.HTML(f"<div style='color:red'>{event.get('message')}</div>"), gr.update(), gr.update(), gr.update(), current_session.to_dict())
 
 
 
 
 
 
 
 
 
216
  return
 
217
  if evt_type == "stream":
218
+ yield (gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), event.get("stream_text", ""), gr.update(), gr.update(), gr.update(), current_session.to_dict())
 
 
 
 
 
 
 
 
 
 
219
  elif evt_type == "complete":
220
  tasks_html = self.service.generate_task_list_html(current_session)
 
 
221
  date_str = event.get("start_time", "N/A")
222
+ summary_html = create_summary_card(len(current_session.task_list), event.get("high_priority", 0), event.get("total_time", 0), location=event.get("start_location", "N/A"), date=date_str)
 
 
 
 
 
 
 
 
223
  chat_history = self._get_gradio_chat_history(current_session)
224
  if not chat_history:
225
+ chat_history = [{"role": "assistant", "content": event.get('stream_text', "")}]
 
 
226
  current_session.chat_history.append({"role": "assistant", "message": "Hi! I'm LifeFlow...", "time": ""})
227
+ yield (gr.update(visible=False), gr.update(visible=True), gr.update(visible=False), "", gr.HTML(tasks_html), gr.HTML(summary_html), chat_history, current_session.to_dict())
 
 
 
 
 
 
 
 
 
 
228
 
229
  def chat_wrapper(self, msg, session_data):
230
  session = UserSession.from_dict(session_data)
 
236
  gradio_history = self._get_gradio_chat_history(sess)
237
  yield (gradio_history, tasks_html, summary_html, sess.to_dict())
238
 
239
+ # 🔥 記得改成 async def
240
+ async def step3_wrapper(self, session_data):
241
  session = UserSession.from_dict(session_data)
242
+ log_content, report_content = "", ""
 
 
 
 
243
  tasks_html = self.service.generate_task_list_html(session)
244
  init_dashboard = self._get_dashboard_html('team', 'working', 'Initializing...')
245
+ loading_html = '<div style="text-align: center; padding: 60px;">🧠 Analyzing...</div>'
246
+ init_log = '<div style="padding: 10px; color: #94a3b8;">Waiting for agents...</div>'
 
 
 
 
 
 
 
 
 
 
247
 
248
  yield (init_dashboard, init_log, loading_html, tasks_html, session.to_dict())
249
 
250
  try:
251
+ # 🔥 使用 async for
252
+ async for event in self.service.run_step3_team(session):
 
253
  sess = event.get("session", session)
254
  evt_type = event.get("type")
255
 
256
+ if evt_type == "error": raise Exception(event.get("message"))
257
+ if evt_type == "report_stream": report_content = event.get("content", "")
 
 
 
258
 
259
  if evt_type == "reasoning_update":
260
  agent, status, msg = event.get("agent_status")
261
  time_str = datetime.now().strftime('%H:%M:%S')
262
+ log_entry = f'<div style="margin-bottom: 8px; border-left: 3px solid #6366f1; padding-left: 10px;"><div style="font-size: 0.75rem; color: #94a3b8;">{time_str} • {agent.upper()}</div><div style="color: #334155; font-size: 0.9rem;">{msg}</div></div>'
 
 
 
 
 
263
  log_content = log_entry + log_content
264
  dashboard_html = self._get_dashboard_html(agent, status, msg)
265
  log_html = f'<div style="height: 500px; overflow-y: auto; padding: 10px; background: #fff;">{log_content}</div>'
266
  current_report = report_content + "\n\n" if report_content else loading_html
 
 
 
 
267
  yield (dashboard_html, log_html, current_report, tasks_html, sess.to_dict())
268
 
269
  if evt_type == "complete":
270
  final_report = event.get("report_html", report_content)
271
+ final_log = f'<div style="margin-bottom: 8px; border-left: 3px solid #10b981; padding-left: 10px; background: #ecfdf5; padding: 10px;"><div style="font-weight: bold; color: #059669;">✅ Planning Completed</div></div>{log_content}'
 
 
 
 
 
272
  final_log_html = f'<div style="height: 500px; overflow-y: auto; padding: 10px; background: #fff;">{final_log}</div>'
273
+ yield (self._get_dashboard_html('team', 'complete', 'Planning Finished'), final_log_html, final_report, tasks_html, sess.to_dict())
 
 
 
 
 
 
 
274
 
275
  except Exception as e:
276
+ error_html = f"<div style='color:red'>Error: {str(e)}</div>"
277
+ yield (self._get_dashboard_html('team', 'error', 'Failed'), error_html, "", tasks_html, session.to_dict())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
 
279
  def step4_wrapper(self, session_data):
280
  session = UserSession.from_dict(session_data)
281
  result = self.service.run_step4_finalize(session)
 
 
282
  if result['type'] == 'success':
283
+ return (gr.update(visible=False), gr.update(visible=True), result['summary_tab_html'], result['report_md'], result['task_list_html'], result['map_fig'], session.to_dict())
 
 
 
 
 
 
 
 
284
  else:
285
+ return (gr.update(visible=True), gr.update(visible=False), "", "", "", None, session.to_dict())
 
 
 
 
286
 
287
  def save_settings(self, g, w, prov, m_key, m_sel, fast, g_key_in, f_sel, s_data):
288
  sess = UserSession.from_dict(s_data)
289
+ sess.custom_settings.update({'google_maps_api_key': g, 'openweather_api_key': w, 'llm_provider': prov, 'model_api_key': m_key, 'model': m_sel, 'enable_fast_mode': fast, 'groq_fast_model': f_sel, 'groq_api_key': g_key_in})
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  return gr.update(visible=False), sess.to_dict(), "✅ Configuration Saved"
291
+
292
+ # ==================== 4. UI Builder ====================
293
 
294
  def build_interface(self):
295
  container_css = """
296
+ .gradio-container { max-width: 100% !important; padding: 0; height: 100vh !important; overflow-y: auto !important; }
 
 
 
 
 
297
  """
298
+ with gr.Blocks(title="LifeFlow AI (MCP)", css=container_css) as demo:
 
299
  gr.HTML(get_enhanced_css())
 
300
  session_state = gr.State(UserSession().to_dict())
301
 
302
  with gr.Column(elem_classes="step-container"):
303
+ home_btn, settings_btn, doc_btn = create_header()
 
304
  stepper = create_progress_stepper(1)
305
+ status_bar = gr.Markdown("Initializing MCP...", visible=False)
306
 
307
+ # --- Steps UI (Copy from previous) ---
308
+ # 為節省篇幅,這裡省略中間 UI 宣告代碼,它們與之前完全相同
309
+ # 請確保這裡是完整的 create_input_form, step2, step3, step4 定義
310
+ # ...
311
  with gr.Group(visible=True, elem_classes="step-container centered-input-container") as step1_container:
312
  (input_area, s1_stream_output, user_input, auto_loc, loc_group, lat_in, lon_in, analyze_btn) = create_input_form("")
313
 
 
314
  with gr.Group(visible=False, elem_classes="step-container") as step2_container:
315
  gr.Markdown("### ✅ Review & Refine Tasks")
316
  with gr.Row(elem_classes="step2-split-view"):
 
330
  back_btn = gr.Button("← Back", variant="secondary", scale=1)
331
  plan_btn = gr.Button("🚀 Start Planning", variant="primary", scale=2)
332
 
 
333
  with gr.Group(visible=False, elem_classes="step-container") as step3_container:
334
  gr.Markdown("### 🤖 AI Team Operations")
 
 
335
  with gr.Group(elem_classes="agent-dashboard-container"):
336
  agent_dashboard = gr.HTML(value=self._get_dashboard_html())
 
 
337
  with gr.Row():
 
338
  with gr.Column(scale=3):
339
  with gr.Tabs():
340
  with gr.Tab("📝 Full Report"):
 
341
  with gr.Group(elem_classes="live-report-wrapper"):
342
  live_report_md = gr.Markdown()
 
343
  with gr.Tab("📋 Task List"):
344
  with gr.Group(elem_classes="panel-container"):
345
  with gr.Group(elem_classes="scrollable-content"):
346
  task_list_s3 = gr.HTML()
 
 
347
  with gr.Column(scale=1):
 
 
348
  cancel_plan_btn = gr.Button("🛑 Stop & Back to Edit", variant="stop")
 
349
  gr.Markdown("### ⚡ Activity Log")
350
  with gr.Group(elem_classes="panel-container"):
351
  planning_log = gr.HTML(value="Waiting...")
352
 
 
353
  with gr.Group(visible=False, elem_classes="step-container") as step4_container:
354
  with gr.Row():
 
355
  with gr.Column(scale=1, elem_classes="split-left-panel"):
356
  with gr.Tabs():
357
  with gr.Tab("📊 Summary"):
358
  summary_tab_output = gr.HTML()
 
359
  with gr.Tab("📝 Full Report"):
360
  with gr.Group(elem_classes="live-report-wrapper"):
361
  report_tab_output = gr.Markdown()
 
362
  with gr.Tab("📋 Task List"):
363
  task_list_tab_output = gr.HTML()
 
 
364
  with gr.Column(scale=2, elem_classes="split-right-panel"):
365
  map_view = gr.HTML(label="Route Map")
366
 
367
+ # --- Modals & Events ---
368
+ (settings_modal, g_key, g_stat, w_key, w_stat, llm_provider, main_key, main_stat, model_sel,
369
+ fast_mode_chk, groq_model_sel, groq_key, groq_stat, close_set, save_set, set_stat) = create_settings_modal()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
 
371
+ # Validators
372
+ g_key.blur(fn=lambda k: "⏳ Checking..." if k else "", inputs=[g_key], outputs=[g_stat]).then(fn=APIValidator.test_google_maps, inputs=[g_key], outputs=[g_stat])
373
+ w_key.blur(fn=lambda k: "⏳ Checking..." if k else "", inputs=[w_key], outputs=[w_stat]).then(fn=APIValidator.test_openweather, inputs=[w_key], outputs=[w_stat])
374
+ main_key.blur(fn=lambda k: "⏳ Checking..." if k else "", inputs=[main_key], outputs=[main_stat]).then(fn=APIValidator.test_llm, inputs=[llm_provider, main_key, model_sel], outputs=[main_stat])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
 
376
  def resolve_groq_visibility(fast_mode, provider):
377
+ if not fast_mode: return (gr.update(visible=False), gr.update(visible=False), gr.update(visible=False))
 
 
 
 
 
 
 
 
 
 
 
 
378
  model_vis = gr.update(visible=True)
379
+ if provider == "Groq": return (model_vis, gr.update(visible=False), gr.update(visible=False))
380
+ else: return (model_vis, gr.update(visible=True), gr.update(visible=True))
381
+ fast_mode_chk.change(fn=resolve_groq_visibility, inputs=[fast_mode_chk, llm_provider], outputs=[groq_model_sel, groq_key, groq_stat])
382
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  def on_provider_change(provider, fast_mode):
 
384
  new_choices = MODEL_OPTIONS.get(provider, [])
385
  default_val = new_choices[0][1] if new_choices else ""
386
  model_update = gr.Dropdown(choices=new_choices, value=default_val)
 
 
 
 
 
 
387
  g_model_vis, g_key_vis, g_stat_vis = resolve_groq_visibility(fast_mode, provider)
388
+ return (model_update, gr.Textbox(value=""), gr.Markdown(value=""), g_model_vis, g_key_vis, g_stat_vis)
389
+ llm_provider.change(fn=on_provider_change, inputs=[llm_provider, fast_mode_chk], outputs=[model_sel, main_key, main_stat, groq_model_sel, groq_key, groq_stat])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
 
391
  doc_modal, close_doc_btn = create_doc_modal()
 
392
  settings_btn.click(fn=lambda: gr.update(visible=True), outputs=[settings_modal])
393
  close_set.click(fn=lambda: gr.update(visible=False), outputs=[settings_modal])
394
  doc_btn.click(fn=lambda: gr.update(visible=True), outputs=[doc_modal])
395
  close_doc_btn.click(fn=lambda: gr.update(visible=False), outputs=[doc_modal])
 
 
 
 
 
 
 
396
 
397
+ # Actions
398
+ analyze_btn.click(fn=self.analyze_wrapper, inputs=[user_input, auto_loc, lat_in, lon_in, session_state], outputs=[step1_container, step2_container, step3_container, s1_stream_output, task_list_box, task_summary_box, chatbot, session_state]).then(fn=lambda: update_stepper(2), outputs=[stepper])
399
  chat_send.click(fn=self.chat_wrapper, inputs=[chat_input, session_state], outputs=[chatbot, task_list_box, task_summary_box, session_state]).then(fn=lambda: "", outputs=[chat_input])
400
  chat_input.submit(fn=self.chat_wrapper, inputs=[chat_input, session_state], outputs=[chatbot, task_list_box, task_summary_box, session_state]).then(fn=lambda: "", outputs=[chat_input])
401
 
402
+ # 🔥 Step 3 Start
403
+ step3_start = plan_btn.click(fn=lambda: (gr.update(visible=False), gr.update(visible=True), update_stepper(3)), outputs=[step2_container, step3_container, stepper]).then(fn=self.step3_wrapper, inputs=[session_state], outputs=[agent_dashboard, planning_log, live_report_md, task_list_s3, session_state], show_progress="hidden")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
 
405
+ cancel_plan_btn.click(fn=self.cancel_wrapper, inputs=[session_state], outputs=None).then(fn=lambda: (gr.update(visible=True), gr.update(visible=False), update_stepper(2)), inputs=None, outputs=[step2_container, step3_container, stepper])
406
+ step3_start.then(fn=self.step4_wrapper, inputs=[session_state], outputs=[step3_container, step4_container, summary_tab_output, report_tab_output, task_list_tab_output, map_view, session_state]).then(fn=lambda: update_stepper(4), outputs=[stepper])
 
 
 
 
 
 
 
 
 
 
 
 
407
 
408
  def reset_all(session_data):
409
+ old_s = UserSession.from_dict(session_data)
410
+ new_s = UserSession()
411
+ new_s.custom_settings = old_s.custom_settings
412
+ return (gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), update_stepper(1), new_s.to_dict(), "")
413
+
414
+ home_btn.click(fn=self.cancel_wrapper, inputs=[session_state], outputs=None).then(fn=reset_all, inputs=[session_state], outputs=[step1_container, step2_container, step3_container, step4_container, stepper, session_state, user_input])
415
+ back_btn.click(fn=self.cancel_wrapper, inputs=[session_state], outputs=None).then(fn=reset_all, inputs=[session_state], outputs=[step1_container, step2_container, step3_container, step4_container, stepper, session_state, user_input])
416
+ save_set.click(fn=self.save_settings, inputs=[g_key, w_key, llm_provider, main_key, model_sel, fast_mode_chk, groq_key, groq_model_sel, session_state], outputs=[settings_modal, session_state, status_bar])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  auto_loc.change(fn=toggle_location_inputs, inputs=auto_loc, outputs=loc_group)
418
  demo.load(fn=self._check_api_status, inputs=[session_state], outputs=[status_bar])
419
 
420
  return demo
421
 
422
+ # ==================== 5. Main ====================
423
+
424
  def main():
425
+ loop = asyncio.new_event_loop()
426
+ asyncio.set_event_loop(loop)
427
+
428
+ async def run_app_lifecycle():
429
+ async with lifespan_manager():
430
+ app = LifeFlowAI()
431
+ # 🔥 注入工具字典 (Dictionary)
432
+ app.inject_tools(GLOBAL_TOOLKITS_DICT)
433
+
434
+ demo = app.build_interface()
435
+ demo.launch(
436
+ server_name="0.0.0.0",
437
+ server_port=8100,
438
+ share=True,
439
+ show_error=True,
440
+ prevent_thread_lock=True
441
+ )
442
+ print("✨ App is running. Press Ctrl+C to stop.")
443
+ try:
444
+ while True: await asyncio.sleep(1)
445
+ except asyncio.CancelledError:
446
+ pass
447
+
448
+ try:
449
+ loop.run_until_complete(run_app_lifecycle())
450
+ except KeyboardInterrupt:
451
+ pass
452
+ except Exception as e:
453
+ print("\n❌ CRITICAL ERROR STARTUP FAILED ❌")
454
+ traceback.print_exc()
455
+ finally:
456
+ print("🛑 Shutting down process...")
457
+ import os
458
+ os._exit(0)
459
+
460
  if __name__ == "__main__":
461
  main()
config.py CHANGED
@@ -8,6 +8,7 @@ from pathlib import Path
8
 
9
  # ===== 系統預設值 =====
10
  BASE_DIR = Path(__file__).parent
 
11
 
12
  LOG_LEVEL = "DEBUG"
13
 
 
8
 
9
  # ===== 系統預設值 =====
10
  BASE_DIR = Path(__file__).parent
11
+ MCP_SERVER_PATH = "mcp_server_lifeflow.py"
12
 
13
  LOG_LEVEL = "DEBUG"
14
 
requirements.txt CHANGED
@@ -29,6 +29,7 @@ matplotlib==3.9.2
29
  folium
30
 
31
  # Utilities
 
32
  timezonefinder
33
  pytz==2024.2
34
  typing-extensions==4.12.2
 
29
  folium
30
 
31
  # Utilities
32
+ toolbox-core
33
  timezonefinder
34
  pytz==2024.2
35
  typing-extensions==4.12.2
services/planner_service.py CHANGED
@@ -1,35 +1,32 @@
1
  """
2
- LifeFlow AI - Planner Service (Fixed: Session Persistence)
3
  核心業務邏輯層:負責協調 Agent 運作、狀態更新與資料處理。
 
 
 
4
  """
5
  import json
6
  import time
7
  import uuid
8
  from datetime import datetime
9
- from typing import Generator, Dict, Any, Tuple, Optional
10
- from contextlib import contextmanager
11
 
12
- # 導入 Core & UI 模組
13
  from core.session import UserSession
14
  from ui.renderers import (
15
  create_task_card,
16
  create_summary_card,
17
  create_timeline_html_enhanced,
18
- create_result_visualization,
19
  )
20
  from core.visualizers import create_animated_map
21
-
22
- # 導入 Config
23
  from config import AGENTS_INFO
24
 
25
- # 導入 Model API
26
- from google.genai.types import HarmCategory, HarmBlockThreshold
27
  from agno.models.google import Gemini
28
  from agno.models.openai import OpenAIChat
29
  from agno.models.groq import Groq
30
 
31
- # 導入 Agent 系統
32
-
33
  from agno.agent import RunEvent
34
  from agno.run.team import TeamRunEvent
35
  from src.agent.base import UserState, Location, get_context
@@ -37,380 +34,244 @@ from src.agent.planner import create_planner_agent
37
  from src.agent.core_team import create_core_team
38
  from src.infra.context import set_session_id
39
  from src.infra.poi_repository import poi_repo
40
- from src.tools import (
41
- ScoutToolkit, OptimizationToolkit,
42
- NavigationToolkit, WeatherToolkit, ReaderToolkit
43
- )
44
  from src.infra.logger import get_logger
 
45
 
 
 
46
 
47
  gemini_safety_settings = [
48
- {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
49
- {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
50
- {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
51
- {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
52
- ]
53
 
54
  logger = get_logger(__name__)
55
  max_retries = 5
56
 
57
 
58
- @contextmanager
59
- def patch_repo_context(session_id: str):
60
  """
61
- 暴力解決方案:
62
- 在執行期間,暫時將 poi_repo 的 save/add 方法「綁架」,
63
- 強迫它們在執行前,先設定當前的 Thread Session。
64
  """
 
 
 
65
 
66
- # 1. 備份原始方法
67
- original_save = getattr(poi_repo, 'save', None)
68
- original_add = getattr(poi_repo, 'add', None)
69
-
70
- # 2. 定義「綁架」後的方法
71
- def patched_save(*args, **kwargs):
72
- set_session_id(session_id)
73
- # 呼叫原始方法
74
- if original_save:
75
- return original_save(*args, **kwargs)
76
- elif original_add:
77
- return original_add(*args, **kwargs)
78
-
79
- # 3. 執行替換
80
- if original_save:
81
- setattr(poi_repo, 'save', patched_save)
82
- if original_add:
83
- setattr(poi_repo, 'add', patched_save)
84
-
85
- try:
86
- yield
87
- finally:
88
- # 4. 恢復原始方法
89
- if original_save:
90
- setattr(poi_repo, 'save', original_save)
91
- if original_add:
92
- setattr(poi_repo, 'add', original_add)
93
 
 
 
 
 
94
 
95
- class PlannerService:
96
- """
97
- PlannerService 封裝了所有的業務流程 (Step 1-4)。
98
- 修正:使用 class level dictionary 來保存活著的 session 物件,防止 Agent 丟失。
99
- """
 
 
 
100
 
101
- # 🌟 [Fix] In-Memory Store: 保存包含 Agent 實例的完整 Session 物件
102
- _active_sessions: Dict[str, UserSession] = {}
103
- _cancelled_sessions: set = set()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  def cancel_session(self, session_id: str):
106
- """標記 Session 為取消狀態"""
107
  if session_id:
108
  logger.info(f"🛑 Requesting cancellation for session: {session_id}")
109
  self._cancelled_sessions.add(session_id)
110
 
111
  def _get_live_session(self, incoming_session: UserSession) -> UserSession:
112
- """
113
- 從 Gradio 傳來的 incoming_session 恢復 Live Session
114
- """
115
  sid = incoming_session.session_id
116
-
117
- if not sid:
118
- return incoming_session
119
-
120
- # 1. 如果內存中有此 Session
121
  if sid and sid in self._active_sessions:
122
  live_session = self._active_sessions[sid]
123
-
124
- # 同步基礎數據
125
  live_session.lat = incoming_session.lat
126
  live_session.lng = incoming_session.lng
127
-
128
- # 🔥 關鍵修正:確保 API Settings 不會被覆蓋為空
129
- # 如果 incoming 有設定,就用 incoming 的;否則保留 live 的
130
  if incoming_session.custom_settings:
131
- # 合併設定,優先使用 incoming (前端最新)
132
  live_session.custom_settings.update(incoming_session.custom_settings)
133
-
134
- # 同步聊天記錄
135
  if len(incoming_session.chat_history) > len(live_session.chat_history):
136
  live_session.chat_history = incoming_session.chat_history
137
-
138
  return live_session
139
-
140
- # 2. 如果是新的 Session,註冊它
141
  self._active_sessions[sid] = incoming_session
142
  return incoming_session
143
 
144
- def _inject_tool_instance(self, tool_instance, session_id):
145
- """
146
- Monkey Patch: 在 Tool 執行前,將 Session ID 寫入 Repo 的 Thread Local。
147
- 這樣 Repo.save() 就能在同一個執行緒中讀到 ID。
148
- """
149
-
150
- for attr_name in dir(tool_instance):
151
- if attr_name.startswith("_"): continue
152
- attr = getattr(tool_instance, attr_name)
153
-
154
- if callable(attr):
155
- if attr_name in ["register", "toolkit_id"]: continue
156
-
157
- def create_wrapper(original_func, sid):
158
- def wrapper(*args, **kwargs):
159
- # 🔥 1. 設定 Thread Local Context
160
- poi_repo.set_thread_session(sid)
161
- try:
162
- # 🔥 2. 執行 Tool
163
- return original_func(*args, **kwargs)
164
- finally:
165
- # (可選) 清理 context,防止污染線程池中的下一個任務
166
- # 但在单次請求中,保留著通常也沒問題,覆蓋即可
167
- pass
168
-
169
- return wrapper
170
-
171
- setattr(tool_instance, attr_name, create_wrapper(attr, session_id))
172
-
173
  def initialize_agents(self, session: UserSession, lat: float, lng: float) -> UserSession:
174
  if not session.session_id:
175
  session.session_id = str(uuid.uuid4())
176
  logger.info(f"🆔 Generated New Session ID: {session.session_id}")
177
 
178
-
179
  session = self._get_live_session(session)
180
  session.lat = lat
181
  session.lng = lng
182
-
183
  if not session.user_state:
184
  session.user_state = UserState(location=Location(lat=lat, lng=lng))
185
  else:
186
  session.user_state.location = Location(lat=lat, lng=lng)
187
 
188
  if session.planner_agent is not None:
189
- logger.info(f"♻️ Agents already initialized for {session.session_id}")
190
  return session
191
 
192
- # 1. 讀取設定
193
  settings = session.custom_settings
194
  provider = settings.get("llm_provider", "Gemini")
195
  main_api_key = settings.get("model_api_key")
196
  selected_model_id = settings.get("model", "gemini-2.5-flash")
197
  helper_model_id = settings.get("groq_fast_model", "openai/gpt-oss-20b")
198
- google_map_key = settings.get("google_maps_api_key")
199
- weather_map_key = settings.get("openweather_api_key")
200
-
201
- # 🔥 讀��� Fast Mode 設定
202
  enable_fast_mode = settings.get("enable_fast_mode", False)
203
  groq_api_key = settings.get("groq_api_key", "")
204
 
205
- model_logger = {"main_provider": provider, "main_model": selected_model_id, "sub_model": None,
206
- "fast_mode": enable_fast_mode}
207
-
208
- # 2. 初始化 "主模型 (Brain)" - 負責 Planner, Leader, Presenter
209
  if provider.lower() == "gemini":
210
- thinking_budget = None
211
- if '2.5' in selected_model_id:
212
- thinking_budget = 1024
213
-
214
- main_brain = Gemini(id=selected_model_id,
215
- api_key=main_api_key,
216
- thinking_budget=thinking_budget,
217
  safety_settings=gemini_safety_settings)
218
  elif provider.lower() == "openai":
219
- main_brain = OpenAIChat(id=selected_model_id, api_key=main_api_key, reasoning_effort="low")
220
  elif provider.lower() == "groq":
221
  main_brain = Groq(id=selected_model_id, api_key=main_api_key, temperature=0.1)
222
  else:
223
- main_brain = Gemini(id='gemini-2.5-flash', api_key=main_api_key, thinking_budget=1024)
224
 
225
- # 3. 初始化 "輔助模型 (Muscle)" - 負責 Scout, Optimizer, Navigator
226
- helper_model = None
227
-
228
- # 🔥 判斷是否啟用 Fast Mode
229
  if enable_fast_mode and groq_api_key:
230
- model_logger["sub_model"] = helper_model_id
231
- logger.info(f"⚡ Fast Mode ENABLED: Using Groq - {helper_model_id} for helpers.")
232
- helper_model = Groq(
233
- id=model_logger["sub_model"],
234
- api_key=groq_api_key,
235
- temperature=0.1
236
- )
237
  else:
238
- # 如果沒開 Fast Mode,或者沒填 Groq Key,就使用主模型 (或其 Lite 版本)
239
- logger.info("🐢 Fast Mode DISABLED: Helpers using Main Provider.")
240
  if provider.lower() == "gemini":
241
- model_logger["sub_model"] = "gemini-2.5-flash-lite"
242
- helper_model = Gemini(id=model_logger["sub_model"], api_key=main_api_key,safety_settings=gemini_safety_settings)
243
  elif provider.lower() == "openai":
244
- model_logger["sub_model"] = "gpt-5-nano"
245
- helper_model = OpenAIChat(id=model_logger["sub_model"], api_key=main_api_key, reasoning_effort="low")
246
- elif provider.lower() == "groq":
247
- model_logger["sub_model"] = "openai/gpt-oss-20b"
248
- helper_model = Groq(id=model_logger["sub_model"], api_key=main_api_key, temperature=0.1)
249
-
250
 
251
- # 4. 分配模型給 Agents
252
- # 🧠 大腦組:需要高智商
253
  models_dict = {
254
- "team": main_brain, # Leader: 指揮需要聰明
255
- "presenter": main_brain, # Presenter: 寫作需要文筆
256
- # 💪 肌肉組:需要速度 (若 Fast Mode 開啟,這裡就是 Groq)
257
- "scout": helper_model,
258
- "optimizer": helper_model,
259
- "navigator": helper_model,
260
- "weatherman": helper_model
261
  }
262
 
263
- planner_model = main_brain
 
 
264
 
265
- # 3. 準備 Tools (先實例化,還不要給 Agent)
266
- scout_tool = ScoutToolkit(google_map_key)
267
- optimizer_tool = OptimizationToolkit()
268
- navigator_tool = NavigationToolkit(google_map_key)
269
- weather_tool = WeatherToolkit(weather_map_key)
270
- reader_tool = ReaderToolkit()
271
 
272
- # 4. 🔥 執行注入!確保所有 Agent 的 Tools 都帶有 Session ID
273
- self._inject_tool_instance(scout_tool, session.session_id)
274
- self._inject_tool_instance(optimizer_tool, session.session_id)
275
- self._inject_tool_instance(navigator_tool, session.session_id)
276
- self._inject_tool_instance(weather_tool, session.session_id)
277
- self._inject_tool_instance(reader_tool, session.session_id)
278
-
279
- # 5. 構建 Tools Dict (使用已注入的 Tool 實例)
280
  tools_dict = {
281
- "scout": [scout_tool],
282
- "optimizer": [optimizer_tool],
283
- "navigator": [navigator_tool],
284
- "weatherman": [weather_tool],
285
- "presenter": [reader_tool],
286
  }
287
 
 
288
  planner_kwargs = {
289
- "additional_context": get_context(session.user_state), # 這裡現在安全了
290
  "timezone_identifier": session.user_state.utc_offset,
291
  "debug_mode": False,
292
  }
293
-
294
  team_kwargs = {"timezone_identifier": session.user_state.utc_offset}
295
 
296
- # 6. 建立 Agents
297
- session.planner_agent = create_planner_agent(planner_model, planner_kwargs, session_id=session.session_id)
298
  session.core_team = create_core_team(models_dict, team_kwargs, tools_dict, session_id=session.session_id)
299
 
300
  self._active_sessions[session.session_id] = session
301
-
302
- logger.info(f"✅ Agents initialized for session {session.session_id}")
303
  return session
304
 
305
  # ================= Step 1: Analyze Tasks =================
306
 
307
  def run_step1_analysis(self, user_input: str, auto_location: bool,
308
  lat: float, lng: float, session: UserSession) -> Generator[Dict[str, Any], None, None]:
309
-
310
- # 🛡️ 檢查 1: 用戶輸入是否為空
311
  if not user_input or len(user_input.strip()) == 0:
312
- yield {
313
- "type": "error",
314
- "message": "⚠️ Please enter your plans first!",
315
- "stream_text": "Waiting for input...",
316
- "block_next_step": True # 🔥 新增 flag 阻止 UI 跳轉
317
- }
318
  return
319
-
320
- # 🛡️ 驗證 2: 位置檢查
321
- # 前端 JS 如果失敗會回傳 0, 0
322
  if auto_location and (lat == 0 or lng == 0):
323
- yield {
324
- "type": "error",
325
- "message": "⚠️ Location detection failed. Please uncheck 'Auto-detect' and enter manually.",
326
- "stream_text": "Location Error...",
327
- "block_next_step": True
328
- }
329
  return
330
-
331
  if not auto_location and (lat is None or lng is None):
332
- yield {
333
- "type": "error",
334
- "message": "⚠️ Please enter valid Latitude/Longitude.",
335
- "stream_text": "Location Error...",
336
- "block_next_step": True
337
- }
338
  return
339
 
340
  try:
341
  session = self.initialize_agents(session, lat, lng)
342
-
343
- # 階段 1: 初始化
344
  self._add_reasoning(session, "planner", "🚀 Starting analysis...")
345
- yield {
346
- "type": "stream",
347
- "stream_text": "🤔 Analyzing your request with AI...",
348
- "agent_status": ("planner", "working", "Initializing..."),
349
- "session": session
350
- }
351
-
352
- # 階段 2: 提取任務 (即時串流優化)
353
  self._add_reasoning(session, "planner", f"Processing: {user_input[:50]}...")
354
  current_text = "🤔 Analyzing your request with AI...\n📋 AI is extracting tasks..."
355
 
356
- # 呼叫 Agent
357
  planner_stream = session.planner_agent.run(
358
  f"help user to update the task_list, user's message: {user_input}",
359
  stream=True, stream_events=True
360
  )
361
 
362
- accumulated_response = ""
363
- displayed_text = current_text + "\n\n"
364
-
365
  for chunk in planner_stream:
366
  if chunk.event == RunEvent.run_content:
367
  content = chunk.content
368
  accumulated_response += content
369
- if "@@@" not in accumulated_response: # 簡單過濾 JSON 標記
370
  displayed_text += content
371
-
372
  formatted_text = displayed_text.replace("\n", "<br/>")
373
- yield {
374
- "type": "stream",
375
- "stream_text": formatted_text,
376
- "agent_status": ("planner", "working", "Thinking..."),
377
- "session": session
378
- }
379
-
380
- # 解析 JSON 結果
381
  json_data = "{" + accumulated_response.split("{", maxsplit=1)[-1]
382
  json_data = json_data.replace("`", "").replace("@", "").replace("\\", " ").replace("\n", " ")
383
- task_list_data = {}
384
  try:
385
  task_list_data = json.loads(json_data)
386
  if task_list_data["global_info"]["start_location"].lower() == "user location":
387
- task_list_data["global_info"]["start_location"] = {
388
- "lat": lat,
389
- "lng": lng
390
- }
391
-
392
- session.planner_agent.update_session_state(
393
- session_id=session.session_id,
394
- session_state_updates={"task_list": task_list_data}
395
- )
396
-
397
  session.task_list = self._convert_task_list_to_ui_format(task_list_data)
398
  except Exception as e:
399
  logger.error(f"Failed to parse task_list: {e}")
400
- print(json_data)
401
  session.task_list = []
402
 
403
- # 🛡️ 檢查 2: Planner 是否回傳空列表
404
- if not session.task_list or len(session.task_list) == 0:
405
  err_msg = "⚠️ AI couldn't identify any tasks."
406
  self._add_reasoning(session, "planner", "❌ No tasks found")
407
- yield {
408
- "type": "error",
409
- "message": err_msg,
410
- "stream_text": err_msg,
411
- "session": session,
412
- "block_next_step": True # 🔥 阻止 UI 跳轉
413
- }
414
  return
415
 
416
  if "priority" in session.task_list:
@@ -422,17 +283,11 @@ class PlannerService:
422
 
423
  high_priority = sum(1 for t in session.task_list if t.get("priority") == "HIGH")
424
  total_time = sum(int(t.get("duration", "0").split()[0]) for t in session.task_list if t.get("duration"))
425
- logger.info(f" Task list: {task_list_data}")
426
- yield {
427
- "type": "complete",
428
- "stream_text": "Analysis complete!",
429
- "start_location": task_list_data["global_info"].get("start_location", "N/A"),
430
- "high_priority": high_priority,
431
- "total_time": total_time,
432
- "start_time": task_list_data["global_info"].get("departure_time", "N/A"),
433
- "session": session,
434
- "block_next_step": False
435
- }
436
 
437
  except Exception as e:
438
  logger.error(f"Error: {e}")
@@ -441,22 +296,13 @@ class PlannerService:
441
  # ================= Task Modification (Chat) =================
442
 
443
  def modify_task_chat(self, user_message: str, session: UserSession) -> Generator[Dict[str, Any], None, None]:
444
-
445
- # 🛡️ 檢查 3: Chat 輸入是否為空
446
  if not user_message or len(user_message.replace(' ', '')) == 0:
447
- yield {
448
- "type": "chat_error",
449
- "message": "Please enter a message.",
450
- "session": session
451
- }
452
  return
453
 
454
  session = self._get_live_session(session)
455
-
456
- # 用戶輸入上屏
457
- session.chat_history.append({
458
- "role": "user", "message": user_message, "time": datetime.now().strftime("%H:%M:%S")
459
- })
460
  yield {"type": "update_history", "session": session}
461
 
462
  try:
@@ -467,10 +313,8 @@ class PlannerService:
467
  yield {"type": "chat_error", "message": "Session lost. Please restart.", "session": session}
468
  return
469
 
470
- # Agent 思考中
471
- session.chat_history.append({
472
- "role": "assistant", "message": "🤔 AI is thinking...", "time": datetime.now().strftime("%H:%M:%S")
473
- })
474
  yield {"type": "update_history", "session": session}
475
 
476
  planner_stream = session.planner_agent.run(
@@ -481,302 +325,167 @@ class PlannerService:
481
  accumulated_response = ""
482
  for chunk in planner_stream:
483
  if chunk.event == RunEvent.run_content:
484
- content = chunk.content
485
- accumulated_response += content
486
- if "@@@" not in accumulated_response: # 簡單過濾 JSON 標記
487
- accumulated_response += content
488
- formatted_text = accumulated_response.replace("\n", "<br/>")
489
 
490
  json_data = "{" + accumulated_response.split("{", maxsplit=1)[-1]
491
  json_data = json_data.replace("`", "").replace("@", "").replace("\\", " ").replace("\n", " ")
492
 
493
  try:
494
  task_list_data = json.loads(json_data)
495
- if isinstance(task_list_data["global_info"]["start_location"], str) and task_list_data["global_info"]["start_location"].lower() == "user location":
496
- task_list_data["global_info"]["start_location"] = {
497
- "lat": session.lat,
498
- "lng": session.lng
499
- }
500
- session.planner_agent.update_session_state(
501
- session_id=session.session_id,
502
- session_state_updates={"task_list": task_list_data}
503
- )
504
  session.task_list = self._convert_task_list_to_ui_format(task_list_data)
505
  except Exception as e:
506
  logger.error(f"Failed to parse modified task_list: {e}")
507
- print(json_data)
508
  raise e
509
 
510
- # 🔥 更新 Summary (回應 "Does summary still exist?" -> Yes!)
511
  high_priority = sum(1 for t in session.task_list if t.get("priority") == "HIGH")
512
  total_time = sum(int(t.get("duration", "0").split()[0]) for t in session.task_list if t.get("duration"))
513
  start_location = task_list_data["global_info"].get("start_location", "N/A")
514
- date = task_list_data["global_info"].get("departure_time", "N/A"),
515
 
516
  summary_html = create_summary_card(len(session.task_list), high_priority, total_time, start_location, date)
517
-
518
- done_message = session.chat_history[-1]["message"] = "✅ Tasks updated based on your request."
519
-
520
- session.chat_history[-1] = {
521
- "role": "assistant",
522
- "message": done_message,
523
- "time": datetime.now().strftime("%H:%M:%S")
524
- }
525
  self._add_reasoning(session, "planner", f"Updated: {user_message[:30]}...")
526
 
527
- yield {
528
- "type": "complete",
529
- "summary_html": summary_html, # 回傳新的 Summary
530
- "session": session
531
- }
532
 
533
  except Exception as e:
534
  logger.error(f"Chat error: {e}")
535
- session.chat_history.append({
536
- "role": "assistant", "message": f"❌ Error: {str(e)}", "time": datetime.now().strftime("%H:%M:%S")
537
- })
538
  yield {"type": "update_history", "session": session}
539
 
540
- # ================= Step 2: Search POIs =================
541
-
542
- def run_step2_search(self, session: UserSession) -> Dict[str, Any]:
543
- # 🌟 [Fix] 獲取 Live Session
544
- session = self._get_live_session(session)
545
- if session.session_id:
546
- set_session_id(session.session_id)
547
- logger.info(f"🔄 [Step 2] Session Context Set: {session.session_id}")
548
-
549
- return {"session": session}
550
-
551
  # ================= Step 3: Run Core Team =================
552
-
553
- def run_step3_team(self, session: UserSession) -> Generator[Dict[str, Any], None, None]:
554
 
555
  attempt = 0
556
  success = False
 
557
  try:
558
  session = self._get_live_session(session)
559
  sid = session.session_id
560
-
561
- if sid in self._cancelled_sessions:
562
- self._cancelled_sessions.remove(sid)
563
-
564
- if session.session_id:
565
- set_session_id(session.session_id)
566
- logger.info(f"🔄 [Step 3] Session Context Set: {session.session_id}")
567
-
568
  if not session.task_list:
569
  yield {"type": "error", "message": "No tasks to plan.", "session": session}
570
  return
571
 
572
- # 準備 Task List String
573
  task_list_input = session.planner_agent.get_session_state()["task_list"]
574
  task_list_str = json.dumps(task_list_input, indent=2, ensure_ascii=False) if isinstance(task_list_input, (
575
- dict, list)) else str(task_list_input)
576
 
577
  self._add_reasoning(session, "team", "🎯 Multi-agent collaboration started")
578
-
579
- # 🔥 初始狀態
580
- yield {
581
- "type": "reasoning_update",
582
- "session": session,
583
- "agent_status": ("team", "working", "Analyzing tasks...")
584
- }
585
-
586
- #print(f"task_list_input: {task_list_str}")
587
-
588
- message = task_list_str
589
 
590
  while attempt < max_retries and not success:
591
  attempt += 1
592
-
593
- # 如果是第 2 次以上嘗試,發個 log
594
- if attempt > 1:
595
- logger.warning(f"🔄 Retry attempt {attempt}/{max_retries} for Session {session.session_id}")
596
- # 可以選擇在這裡 yield 一個 "Retrying..." 的狀態給 UI (選用)
597
-
598
  try:
599
- # 🔥 [CRITICAL FIX] 使用 Patch Context 包裹執行區塊
600
- with patch_repo_context(session.session_id):
601
- active_agents = set()
602
- team_stream = session.core_team.run(
603
- message,
604
- stream=True, stream_events=True, session_id=session.session_id
605
- )
606
-
607
- report_content = ""
608
- start_time = time.perf_counter()
609
- has_content = False
610
- # 🔥 Event Loop: 捕捉事件並 yield 給 UI
611
- for event in team_stream:
612
- if event.event in [RunEvent.run_content, RunEvent.tool_call_started]:
613
- has_content = True
614
- success = True # 標記成功
615
-
616
- if sid in self._cancelled_sessions:
617
- logger.warning(f"🛑 Execution terminated by user for session {sid}")
618
- self._cancelled_sessions.remove(sid)
619
- yield {"type": "error", "message": "Plan cancelled by user."}
620
- return
621
-
622
- # 1. 捕捉 Agent "開始工作"
623
- if event.event == RunEvent.run_started:
624
- agent_id = event.agent_id or "team"
625
- if not event.agent_id: agent_id = "team"
626
-
627
- active_agents.add(agent_id)
628
- if agent_id == "presenter":
629
- report_content = ""
630
-
631
- yield {
632
- "type": "reasoning_update",
633
- "session": session,
634
- "agent_status": (agent_id, "working", "Thinking...")
635
- }
636
-
637
- # 2. 捕捉 Agent "完成工作" (⭐⭐ 修正重點 ⭐⭐)
638
- elif event.event == RunEvent.run_completed:
639
- agent_id = event.agent_id or "team"
640
- if not event.agent_id: agent_id = "team"
641
-
642
- # A. 如果是 Leader:絕對不准休息!
643
- if agent_id == "team":
644
- yield {
645
- "type": "reasoning_update",
646
- "session": session,
647
- "agent_status": ("team", "working", "Processing...")
648
- }
649
- continue # 跳過設為 Idle 的步驟
650
-
651
- # B. 如果是 Member:做完工作了
652
- if agent_id in active_agents:
653
- active_agents.remove(agent_id)
654
-
655
- # B-1. 成員變灰 (下班)
656
- yield {
657
- "type": "reasoning_update",
658
- "session": session,
659
- "agent_status": (agent_id, "idle", "Standby")
660
- }
661
-
662
- # 🔥🔥 B-2. (關鍵新增) Leader 立刻接手 (上班) 🔥🔥
663
- # 這消除了成員做完 -> Leader 分派下一個任務之間的空窗期
664
- yield {
665
- "type": "reasoning_update",
666
- "session": session,
667
- "agent_status": ("team", "working", "Reviewing results...")
668
- }
669
-
670
- # 3. 捕捉 Report 內容
671
- elif event.event == RunEvent.run_content and event.agent_id == "presenter":
672
- report_content += event.content
673
- yield {
674
- "type": "report_stream",
675
- "content": report_content,
676
- "session": session
677
- }
678
-
679
- # 4. Team Delegate (分派任務)
680
- elif event.event == TeamRunEvent.tool_call_started:
681
- tool_name = event.tool.tool_name
682
-
683
- # Leader 顯示正在指揮
684
- yield {
685
- "type": "reasoning_update",
686
- "session": session,
687
- "agent_status": ("team", "working", "Orchestrating...")
688
- }
689
-
690
- if "delegate_task_to_member" in tool_name:
691
- member_id = event.tool.tool_args.get("member_id", "unknown")
692
- msg = f"Delegating to {member_id}..."
693
- self._add_reasoning(session, "team", f"👉 {msg}")
694
-
695
- # 成員亮燈 (接單)
696
- yield {
697
- "type": "reasoning_update",
698
- "session": session,
699
- "agent_status": (member_id, "working", "Receiving Task...")
700
- }
701
- else:
702
- self._add_reasoning(session, "team", f"🔧 Tool: {tool_name}")
703
-
704
- # 5. Member Tool Start
705
- elif event.event == RunEvent.tool_call_started:
706
- member_id = event.agent_id
707
- tool_name = event.tool.tool_name
708
-
709
- # 雙重保險:Member 在用工具時,Leader 也是亮著的 (Monitoring)
710
- yield {
711
- "type": "reasoning_update",
712
- "session": session,
713
- "agent_status": ("team", "working", f"Monitoring {member_id}...")
714
- }
715
-
716
- self._add_reasoning(session, member_id, f"Using tool: {tool_name}...")
717
- yield {
718
- "type": "reasoning_update",
719
- "session": session,
720
- "agent_status": (member_id, "working", f"Running Tool...")
721
- }
722
-
723
- # 6. Team Complete
724
- elif event.event == TeamRunEvent.run_completed:
725
- self._add_reasoning(session, "team", "🎉 Planning process finished")
726
- if hasattr(event, 'metrics'):
727
- logger.info(f"Total tokens: {event.metrics.total_tokens}")
728
- logger.info(f"Input tokens: {event.metrics.input_tokens}")
729
- logger.info(f"Output tokens: {event.metrics.output_tokens}")
730
-
731
-
732
- if not has_content:
733
- logger.error(f"⚠️ Attempt {attempt}: Agent returned NO content (Silent Failure).")
734
- if attempt < max_retries:
735
- continue # 重試 while loop
736
  else:
737
- raise ValueError("Agent failed to generate output after retries.")
738
-
739
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
740
 
741
  finally:
742
  logger.info(f"Run time (s): {time.perf_counter() - start_time}")
743
 
744
-
745
-
746
  for agent in ["scout", "optimizer", "navigator", "weatherman", "presenter"]:
747
- yield {
748
- "type": "reasoning_update",
749
- "session": session,
750
- "agent_status": (agent, "idle", "Standby")
751
- }
752
-
753
- # Leader 顯示完成
754
- yield {
755
- "type": "reasoning_update",
756
- "session": session,
757
- "agent_status": ("team", "complete", "All Done!")
758
- }
759
-
760
- # 迴圈結束
761
- session.final_report = report_html = f"## 🎯 Planning Complete\n\n{report_content}"
762
 
763
- yield {
764
- "type": "complete",
765
- "report_html": report_html,
766
- "session": session,
767
- "agent_status": ("team", "complete", "Finished")
768
- }
769
 
770
  except GeneratorExit:
771
- logger.warning("⚠️ Generator closed by client (Gradio Stop).")
772
- return # 靜默退出,不要報錯
773
-
774
  except Exception as e:
775
  logger.error(f"Error in attempt {attempt}: {e}")
776
- if attempt >= max_retries:
777
- yield {"type": "error", "message": str(e), "session": session}
778
- return
779
 
 
 
780
  # ================= Step 4: Finalize =================
781
 
782
  def run_step4_finalize(self, session: UserSession) -> Dict[str, Any]:
@@ -787,26 +496,15 @@ class PlannerService:
787
  raise ValueError(f"No results found")
788
 
789
  structured_data = poi_repo.load(final_ref_id)
790
-
791
- # 1. Timeline
792
  timeline_html = create_timeline_html_enhanced(structured_data.get("timeline", []))
793
 
794
- # 2. Summary Card & Metrics (快樂表)
795
  metrics = structured_data.get("metrics", {})
796
  traffic = structured_data.get("traffic_summary", {})
797
- # 🔥 組合 Summary Tab 的內容
798
-
799
  task_count = f"{metrics['completed_tasks']} / {metrics['total_tasks']}"
800
  high_prio = sum(1 for t in session.task_list if t.get("priority") == "HIGH")
801
-
802
- # 時間 (優先使用優化後的時間,如果沒有則用交通總時間)
803
  total_time = metrics.get("optimized_duration_min", traffic.get("total_duration_min", 0))
804
-
805
- # 距離 (公尺轉公里)
806
  dist_m = metrics.get("optimized_distance_m", 0)
807
  total_dist_km = dist_m / 1000.0
808
-
809
- # 效率與節省 (用於快樂表)
810
  efficiency = metrics.get("route_efficiency_pct", 0)
811
  saved_dist_m = metrics.get("distance_saved_m", 0)
812
  saved_time_min = metrics.get("time_saved_min", 0)
@@ -814,8 +512,6 @@ class PlannerService:
814
  date = structured_data.get("global_info", {}).get("departure_time", "N/A")
815
 
816
  summary_card = create_summary_card(task_count, high_prio, int(total_time), start_location, date)
817
-
818
- # B. 進階數據快樂表 (AI Efficiency & Distance)
819
  eff_color = "#047857" if efficiency >= 80 else "#d97706"
820
  eff_bg = "#ecfdf5" if efficiency >= 80 else "#fffbeb"
821
  eff_border = "#a7f3d0" if efficiency >= 80 else "#fde68a"
@@ -823,54 +519,24 @@ class PlannerService:
823
  ai_stats_html = f"""
824
  <div style="display: flex; gap: 12px; margin-bottom: 20px;">
825
  <div style="flex: 1; background: {eff_bg}; padding: 16px; border-radius: 12px; border: 1px solid {eff_border};">
826
- <div style="font-size: 0.8rem; color: {eff_color}; font-weight: 600; display: flex; align-items: center; gap: 4px;">
827
- <span>🚀 AI EFFICIENCY</span>
828
- </div>
829
- <div style="font-size: 1.8rem; font-weight: 800; color: {eff_color}; line-height: 1.2;">
830
- {efficiency:.1f}%
831
- </div>
832
- <div style="font-size: 0.75rem; color: {eff_color}; opacity: 0.9; margin-top: 4px;">
833
- ⚡ Saved {saved_time_min:.0f} mins
834
- </div>
835
  </div>
836
-
837
  <div style="flex: 1; background: #eff6ff; padding: 16px; border-radius: 12px; border: 1px solid #bfdbfe;">
838
  <div style="font-size: 0.8rem; color: #2563eb; font-weight: 600;">🚗 TOTAL DISTANCE</div>
839
- <div style="font-size: 1.8rem; font-weight: 800; color: #1d4ed8; line-height: 1.2;">
840
- {total_dist_km:.2f} <span style="font-size: 1rem;">km</span>
841
- </div>
842
- <div style="font-size: 0.75rem; color: #2563eb; opacity: 0.9; margin-top: 4px;">
843
- 📉 Reduced {saved_dist_m} m
844
- </div>
845
  </div>
846
- </div>
847
- """
848
 
849
  full_summary_html = f"{summary_card}{ai_stats_html}<h3>📍 Itinerary Timeline</h3>{timeline_html}"
850
-
851
- # C. 其他 Tab 內容
852
- # task_list_html = self.generate_task_list_html(session)
853
-
854
- # 3. Map
855
  map_fig = create_animated_map(structured_data)
856
- #if isinstance(map_fig, str):
857
- # logger.error(f"CRITICAL: map_fig is a string ('{map_fig}'). Creating default map.")
858
- # from core.visualizers import create_animated_map as default_map_gen
859
- # map_fig = default_map_gen(None)
860
-
861
- # 4. Task List
862
  task_list_html = self.generate_task_list_html(session)
863
-
864
  session.planning_completed = True
865
 
866
- return {
867
- "type": "success",
868
- "summary_tab_html": full_summary_html, # 快樂表
869
- "report_md": session.final_report, # Full Report
870
- "task_list_html": task_list_html, # Task List
871
- "map_fig": map_fig, # Map
872
- "session": session
873
- }
874
 
875
  except Exception as e:
876
  logger.error(f"Finalize error: {e}", exc_info=True)
@@ -879,11 +545,8 @@ class PlannerService:
879
  # ================= Helpers =================
880
 
881
  def _add_reasoning(self, session: UserSession, agent: str, message: str):
882
- session.reasoning_messages.append({
883
- "agent": agent,
884
- "message": message,
885
- "time": datetime.now().strftime("%H:%M:%S")
886
- })
887
 
888
  def _convert_task_list_to_ui_format(self, task_list_data):
889
  ui_tasks = []
@@ -893,9 +556,8 @@ class PlannerService:
893
  tasks = task_list_data
894
  else:
895
  return []
896
-
897
  for i, task in enumerate(tasks, 1):
898
- ui_task = {
899
  "id": i,
900
  "title": task.get("description", "Task"),
901
  "priority": task.get("priority", "MEDIUM"),
@@ -903,26 +565,18 @@ class PlannerService:
903
  "duration": f"{task.get('service_duration_min', 30)} minutes",
904
  "location": task.get("location_hint", "To be determined"),
905
  "icon": self._get_task_icon(task.get("category", "other"))
906
- }
907
- ui_tasks.append(ui_task)
908
  return ui_tasks
909
 
910
  def _get_task_icon(self, category: str) -> str:
911
- icons = {
912
- "medical": "🏥", "shopping": "🛒", "postal": "📮",
913
- "food": "🍽️", "entertainment": "🎭", "transportation": "🚗",
914
- "other": "📋"
915
- }
916
  return icons.get(category.lower(), "📋")
917
 
918
  def generate_task_list_html(self, session: UserSession) -> str:
919
- if not session.task_list:
920
- return "<p>No tasks available</p>"
921
  html = ""
922
  for task in session.task_list:
923
- html += create_task_card(
924
- task["id"], task["title"], task["priority"],
925
- task["time"], task["duration"], task["location"],
926
- task.get("icon", "📋")
927
- )
928
  return html
 
1
  """
2
+ LifeFlow AI - Planner Service (Refactored for MCP Architecture)
3
  核心業務邏輯層:負責協調 Agent 運作、狀態更新與資料處理。
4
+ ✅ 移除本地 Toolkits
5
+ ✅ 整合全域 MCP Client
6
+ ✅ 保持業務邏輯不變
7
  """
8
  import json
9
  import time
10
  import uuid
11
  from datetime import datetime
12
+ from typing import Generator, Dict, Any, Tuple, Optional, AsyncGenerator
 
13
 
14
+ # Core Imports
15
  from core.session import UserSession
16
  from ui.renderers import (
17
  create_task_card,
18
  create_summary_card,
19
  create_timeline_html_enhanced,
 
20
  )
21
  from core.visualizers import create_animated_map
 
 
22
  from config import AGENTS_INFO
23
 
24
+ # Models
 
25
  from agno.models.google import Gemini
26
  from agno.models.openai import OpenAIChat
27
  from agno.models.groq import Groq
28
 
29
+ # Agno Framework
 
30
  from agno.agent import RunEvent
31
  from agno.run.team import TeamRunEvent
32
  from src.agent.base import UserState, Location, get_context
 
34
  from src.agent.core_team import create_core_team
35
  from src.infra.context import set_session_id
36
  from src.infra.poi_repository import poi_repo
 
 
 
 
37
  from src.infra.logger import get_logger
38
+ from src.infra.client_context import client_session_ctx
39
 
40
+ # 🔥🔥🔥 NEW IMPORTS: 只使用 MCPTools
41
+ from agno.tools.mcp import MCPTools
42
 
43
  gemini_safety_settings = [
44
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
45
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
46
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
47
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
48
+ ]
49
 
50
  logger = get_logger(__name__)
51
  max_retries = 5
52
 
53
 
54
+ class PlannerService:
 
55
  """
56
+ PlannerService (MCP Version)
 
 
57
  """
58
+ # Active Sessions 快取 (僅存 Session 物件,不含全域資源)
59
+ _active_sessions: Dict[str, UserSession] = {}
60
+ _cancelled_sessions: set = set()
61
 
62
+ # 🔥 全域工具參照 (由 App 注入)
63
+ _global_toolkits: Dict[str, MCPTools] = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
+ def set_global_tools(self, toolkits_dict: Dict[str, MCPTools]):
66
+ """由 app.py 呼叫,注入 MCP Client"""
67
+ self._global_toolkits = toolkits_dict
68
+ logger.info("💉 MCP Toolkit injected into PlannerService successfully.")
69
 
70
+ def _inject_session_id_into_toolkit(self, toolkit: MCPTools, session_id: str):
71
+ """
72
+ Monkey Patch 2.0:
73
+ 攔截 Toolkit 產生的 Function,並在執行時自動注入 session_id 參數。
74
+ """
75
+ # 1. 備份原始的 get_tools 方法
76
+ # 因為 Agno 是呼叫 get_tools() 來取得工具列表的
77
+ original_get_tools = toolkit.functions
78
 
79
+ def get_tools_wrapper():
80
+ # 2. 取得原始工具列表 (List[Function])
81
+ tools = original_get_tools
82
+
83
+ for tool in tools:
84
+ # 3. 備份每個工具的執行入口 (entrypoint)
85
+ original_entrypoint = tool.entrypoint
86
+
87
+ # 4. 定義新的執行入口 (Wrapper)
88
+ def entrypoint_wrapper(*args, **kwargs):
89
+ # 🔥 核心魔法:在這裡偷偷塞入 session_id
90
+ # 這樣 LLM 沒傳這個參數,也會被自動補上
91
+ kwargs['session_id'] = session_id
92
+
93
+ # 執行原始 MCP 呼叫
94
+ return original_entrypoint(*args, **kwargs)
95
+
96
+ # 5. 替換掉入口
97
+ tool.entrypoint = entrypoint_wrapper
98
+
99
+ return tools
100
+
101
+ # 6. 將 Toolkit 的 get_tools 換成我們的 Wrapper
102
+ # 這是 Instance Level 的修改,只會影響當前這個 Agent 的 Toolkit
103
+ toolkit.get_tools = get_tools_wrapper
104
 
105
  def cancel_session(self, session_id: str):
 
106
  if session_id:
107
  logger.info(f"🛑 Requesting cancellation for session: {session_id}")
108
  self._cancelled_sessions.add(session_id)
109
 
110
  def _get_live_session(self, incoming_session: UserSession) -> UserSession:
 
 
 
111
  sid = incoming_session.session_id
112
+ if not sid: return incoming_session
 
 
 
 
113
  if sid and sid in self._active_sessions:
114
  live_session = self._active_sessions[sid]
 
 
115
  live_session.lat = incoming_session.lat
116
  live_session.lng = incoming_session.lng
 
 
 
117
  if incoming_session.custom_settings:
 
118
  live_session.custom_settings.update(incoming_session.custom_settings)
 
 
119
  if len(incoming_session.chat_history) > len(live_session.chat_history):
120
  live_session.chat_history = incoming_session.chat_history
 
121
  return live_session
 
 
122
  self._active_sessions[sid] = incoming_session
123
  return incoming_session
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  def initialize_agents(self, session: UserSession, lat: float, lng: float) -> UserSession:
126
  if not session.session_id:
127
  session.session_id = str(uuid.uuid4())
128
  logger.info(f"🆔 Generated New Session ID: {session.session_id}")
129
 
 
130
  session = self._get_live_session(session)
131
  session.lat = lat
132
  session.lng = lng
 
133
  if not session.user_state:
134
  session.user_state = UserState(location=Location(lat=lat, lng=lng))
135
  else:
136
  session.user_state.location = Location(lat=lat, lng=lng)
137
 
138
  if session.planner_agent is not None:
 
139
  return session
140
 
141
+ # 1. 設定模型 (Models)
142
  settings = session.custom_settings
143
  provider = settings.get("llm_provider", "Gemini")
144
  main_api_key = settings.get("model_api_key")
145
  selected_model_id = settings.get("model", "gemini-2.5-flash")
146
  helper_model_id = settings.get("groq_fast_model", "openai/gpt-oss-20b")
 
 
 
 
147
  enable_fast_mode = settings.get("enable_fast_mode", False)
148
  groq_api_key = settings.get("groq_api_key", "")
149
 
150
+ # 初始化 Main Brain
 
 
 
151
  if provider.lower() == "gemini":
152
+ main_brain = Gemini(id=selected_model_id, api_key=main_api_key, thinking_budget=1024,
 
 
 
 
 
 
153
  safety_settings=gemini_safety_settings)
154
  elif provider.lower() == "openai":
155
+ main_brain = OpenAIChat(id=selected_model_id, api_key=main_api_key)
156
  elif provider.lower() == "groq":
157
  main_brain = Groq(id=selected_model_id, api_key=main_api_key, temperature=0.1)
158
  else:
159
+ main_brain = Gemini(id='gemini-2.5-flash', api_key=main_api_key)
160
 
161
+ # 初始化 Helper Model
 
 
 
162
  if enable_fast_mode and groq_api_key:
163
+ helper_model = Groq(id=helper_model_id, api_key=groq_api_key, temperature=0.1)
 
 
 
 
 
 
164
  else:
 
 
165
  if provider.lower() == "gemini":
166
+ helper_model = Gemini(id="gemini-2.5-flash-lite", api_key=main_api_key,
167
+ safety_settings=gemini_safety_settings)
168
  elif provider.lower() == "openai":
169
+ helper_model = OpenAIChat(id="gpt-4o-mini", api_key=main_api_key)
170
+ else:
171
+ helper_model = main_brain
 
 
 
172
 
 
 
173
  models_dict = {
174
+ "team": main_brain,
175
+ "presenter": main_brain,
176
+ "scout": helper_model,
177
+ "optimizer": helper_model,
178
+ "navigator": helper_model,
179
+ "weatherman": helper_model
 
180
  }
181
 
182
+ # 2. 準備 Tools (🔥 MCP 重構核心)
183
+ if not self._global_toolkits:
184
+ logger.warning("⚠️ MCP Toolkit is NOT initialized! Agents will have no tools.")
185
 
186
+ def get_tool_list(agent_name):
187
+ toolkit = self._global_toolkits.get(agent_name)
188
+ return [toolkit] if toolkit else []
 
 
 
189
 
 
 
 
 
 
 
 
 
190
  tools_dict = {
191
+ "scout": get_tool_list("scout"),
192
+ "optimizer": get_tool_list("optimizer"),
193
+ "navigator": get_tool_list("navigator"),
194
+ "weatherman": get_tool_list("weatherman"),
195
+ "presenter": get_tool_list("presenter"),
196
  }
197
 
198
+
199
  planner_kwargs = {
200
+ "additional_context": get_context(session.user_state),
201
  "timezone_identifier": session.user_state.utc_offset,
202
  "debug_mode": False,
203
  }
 
204
  team_kwargs = {"timezone_identifier": session.user_state.utc_offset}
205
 
206
+ # 3. 建立 Agents
207
+ session.planner_agent = create_planner_agent(main_brain, planner_kwargs, session_id=session.session_id)
208
  session.core_team = create_core_team(models_dict, team_kwargs, tools_dict, session_id=session.session_id)
209
 
210
  self._active_sessions[session.session_id] = session
211
+ logger.info(f"✅ Agents initialized (MCP Mode) for session {session.session_id}")
 
212
  return session
213
 
214
  # ================= Step 1: Analyze Tasks =================
215
 
216
  def run_step1_analysis(self, user_input: str, auto_location: bool,
217
  lat: float, lng: float, session: UserSession) -> Generator[Dict[str, Any], None, None]:
 
 
218
  if not user_input or len(user_input.strip()) == 0:
219
+ yield {"type": "error", "message": "⚠️ Please enter your plans first!",
220
+ "stream_text": "Waiting for input...", "block_next_step": True}
 
 
 
 
221
  return
 
 
 
222
  if auto_location and (lat == 0 or lng == 0):
223
+ yield {"type": "error", "message": "⚠️ Location detection failed.", "stream_text": "Location Error...",
224
+ "block_next_step": True}
 
 
 
 
225
  return
 
226
  if not auto_location and (lat is None or lng is None):
227
+ yield {"type": "error", "message": "⚠️ Please enter valid Latitude/Longitude.",
228
+ "stream_text": "Location Error...", "block_next_step": True}
 
 
 
 
229
  return
230
 
231
  try:
232
  session = self.initialize_agents(session, lat, lng)
 
 
233
  self._add_reasoning(session, "planner", "🚀 Starting analysis...")
234
+ yield {"type": "stream", "stream_text": "🤔 Analyzing your request with AI...",
235
+ "agent_status": ("planner", "working", "Initializing..."), "session": session}
236
+
 
 
 
 
 
237
  self._add_reasoning(session, "planner", f"Processing: {user_input[:50]}...")
238
  current_text = "🤔 Analyzing your request with AI...\n📋 AI is extracting tasks..."
239
 
 
240
  planner_stream = session.planner_agent.run(
241
  f"help user to update the task_list, user's message: {user_input}",
242
  stream=True, stream_events=True
243
  )
244
 
245
+ accumulated_response, displayed_text = "", current_text + "\n\n"
 
 
246
  for chunk in planner_stream:
247
  if chunk.event == RunEvent.run_content:
248
  content = chunk.content
249
  accumulated_response += content
250
+ if "@@@" not in accumulated_response:
251
  displayed_text += content
 
252
  formatted_text = displayed_text.replace("\n", "<br/>")
253
+ yield {"type": "stream", "stream_text": formatted_text,
254
+ "agent_status": ("planner", "working", "Thinking..."), "session": session}
255
+
 
 
 
 
 
256
  json_data = "{" + accumulated_response.split("{", maxsplit=1)[-1]
257
  json_data = json_data.replace("`", "").replace("@", "").replace("\\", " ").replace("\n", " ")
258
+
259
  try:
260
  task_list_data = json.loads(json_data)
261
  if task_list_data["global_info"]["start_location"].lower() == "user location":
262
+ task_list_data["global_info"]["start_location"] = {"lat": lat, "lng": lng}
263
+ session.planner_agent.update_session_state(session_id=session.session_id,
264
+ session_state_updates={"task_list": task_list_data})
 
 
 
 
 
 
 
265
  session.task_list = self._convert_task_list_to_ui_format(task_list_data)
266
  except Exception as e:
267
  logger.error(f"Failed to parse task_list: {e}")
 
268
  session.task_list = []
269
 
270
+ if not session.task_list:
 
271
  err_msg = "⚠️ AI couldn't identify any tasks."
272
  self._add_reasoning(session, "planner", "❌ No tasks found")
273
+ yield {"type": "error", "message": err_msg, "stream_text": err_msg, "session": session,
274
+ "block_next_step": True}
 
 
 
 
 
275
  return
276
 
277
  if "priority" in session.task_list:
 
283
 
284
  high_priority = sum(1 for t in session.task_list if t.get("priority") == "HIGH")
285
  total_time = sum(int(t.get("duration", "0").split()[0]) for t in session.task_list if t.get("duration"))
286
+ yield {"type": "complete", "stream_text": "Analysis complete!",
287
+ "start_location": task_list_data["global_info"].get("start_location", "N/A"),
288
+ "high_priority": high_priority, "total_time": total_time,
289
+ "start_time": task_list_data["global_info"].get("departure_time", "N/A"), "session": session,
290
+ "block_next_step": False}
 
 
 
 
 
 
291
 
292
  except Exception as e:
293
  logger.error(f"Error: {e}")
 
296
  # ================= Task Modification (Chat) =================
297
 
298
  def modify_task_chat(self, user_message: str, session: UserSession) -> Generator[Dict[str, Any], None, None]:
 
 
299
  if not user_message or len(user_message.replace(' ', '')) == 0:
300
+ yield {"type": "chat_error", "message": "Please enter a message.", "session": session}
 
 
 
 
301
  return
302
 
303
  session = self._get_live_session(session)
304
+ session.chat_history.append(
305
+ {"role": "user", "message": user_message, "time": datetime.now().strftime("%H:%M:%S")})
 
 
 
306
  yield {"type": "update_history", "session": session}
307
 
308
  try:
 
313
  yield {"type": "chat_error", "message": "Session lost. Please restart.", "session": session}
314
  return
315
 
316
+ session.chat_history.append(
317
+ {"role": "assistant", "message": "🤔 AI is thinking...", "time": datetime.now().strftime("%H:%M:%S")})
 
 
318
  yield {"type": "update_history", "session": session}
319
 
320
  planner_stream = session.planner_agent.run(
 
325
  accumulated_response = ""
326
  for chunk in planner_stream:
327
  if chunk.event == RunEvent.run_content:
328
+ accumulated_response += chunk.content
 
 
 
 
329
 
330
  json_data = "{" + accumulated_response.split("{", maxsplit=1)[-1]
331
  json_data = json_data.replace("`", "").replace("@", "").replace("\\", " ").replace("\n", " ")
332
 
333
  try:
334
  task_list_data = json.loads(json_data)
335
+ if isinstance(task_list_data["global_info"]["start_location"], str) and task_list_data["global_info"][
336
+ "start_location"].lower() == "user location":
337
+ task_list_data["global_info"]["start_location"] = {"lat": session.lat, "lng": session.lng}
338
+ session.planner_agent.update_session_state(session_id=session.session_id,
339
+ session_state_updates={"task_list": task_list_data})
 
 
 
 
340
  session.task_list = self._convert_task_list_to_ui_format(task_list_data)
341
  except Exception as e:
342
  logger.error(f"Failed to parse modified task_list: {e}")
 
343
  raise e
344
 
 
345
  high_priority = sum(1 for t in session.task_list if t.get("priority") == "HIGH")
346
  total_time = sum(int(t.get("duration", "0").split()[0]) for t in session.task_list if t.get("duration"))
347
  start_location = task_list_data["global_info"].get("start_location", "N/A")
348
+ date = task_list_data["global_info"].get("departure_time", "N/A")
349
 
350
  summary_html = create_summary_card(len(session.task_list), high_priority, total_time, start_location, date)
351
+ session.chat_history[-1] = {"role": "assistant", "message": "✅ Tasks updated.",
352
+ "time": datetime.now().strftime("%H:%M:%S")}
 
 
 
 
 
 
353
  self._add_reasoning(session, "planner", f"Updated: {user_message[:30]}...")
354
 
355
+ yield {"type": "complete", "summary_html": summary_html, "session": session}
 
 
 
 
356
 
357
  except Exception as e:
358
  logger.error(f"Chat error: {e}")
359
+ session.chat_history.append(
360
+ {"role": "assistant", "message": f"❌ Error: {str(e)}", "time": datetime.now().strftime("%H:%M:%S")})
 
361
  yield {"type": "update_history", "session": session}
362
 
 
 
 
 
 
 
 
 
 
 
 
363
  # ================= Step 3: Run Core Team =================
364
+ async def run_step3_team(self, session: UserSession) -> AsyncGenerator[Dict[str, Any], None]:
365
+ token = client_session_ctx.set(session.session_id)
366
 
367
  attempt = 0
368
  success = False
369
+ start_time = time.perf_counter()
370
  try:
371
  session = self._get_live_session(session)
372
  sid = session.session_id
373
+ if sid in self._cancelled_sessions: self._cancelled_sessions.remove(sid)
 
 
 
 
 
 
 
374
  if not session.task_list:
375
  yield {"type": "error", "message": "No tasks to plan.", "session": session}
376
  return
377
 
 
378
  task_list_input = session.planner_agent.get_session_state()["task_list"]
379
  task_list_str = json.dumps(task_list_input, indent=2, ensure_ascii=False) if isinstance(task_list_input, (
380
+ dict, list)) else str(task_list_input)
381
 
382
  self._add_reasoning(session, "team", "🎯 Multi-agent collaboration started")
383
+ yield {"type": "reasoning_update", "session": session,
384
+ "agent_status": ("team", "working", "Analyzing tasks...")}
 
 
 
 
 
 
 
 
 
385
 
386
  while attempt < max_retries and not success:
387
  attempt += 1
 
 
 
 
 
 
388
  try:
389
+ active_agents = set()
390
+
391
+ # 🔥 重點修改 1: 使用 arun (Async Run)
392
+ # 🔥 重點修改 2: 這個方法本身回傳的是一個 AsyncGenerator,所以要直接 iterate
393
+ team_stream = session.core_team.arun(
394
+ task_list_str,
395
+ stream=True,
396
+ stream_events=True,
397
+ session_id=session.session_id
398
+ )
399
+
400
+ report_content = ""
401
+ start_time = time.perf_counter()
402
+ has_content = False
403
+
404
+ # 🔥 重點修改 3: 使用 async for 來迭代
405
+ async for event in team_stream:
406
+ if event.event in [RunEvent.run_content, RunEvent.tool_call_started]:
407
+ has_content = True
408
+ success = True
409
+
410
+ if sid in self._cancelled_sessions:
411
+ logger.warning(f"🛑 Execution terminated by user for session {sid}")
412
+ self._cancelled_sessions.remove(sid)
413
+ yield {"type": "error", "message": "Plan cancelled by user."}
414
+ return
415
+
416
+ if event.event == RunEvent.run_started:
417
+ agent_id = event.agent_id or "team"
418
+ active_agents.add(agent_id)
419
+ if agent_id == "presenter": report_content = ""
420
+ yield {"type": "reasoning_update", "session": session,
421
+ "agent_status": (agent_id, "working", "Thinking...")}
422
+
423
+ elif event.event == RunEvent.run_completed:
424
+ agent_id = event.agent_id or "team"
425
+ if agent_id == "team":
426
+ yield {"type": "reasoning_update", "session": session,
427
+ "agent_status": ("team", "working", "Processing...")}
428
+ continue
429
+ if agent_id in active_agents: active_agents.remove(agent_id)
430
+ yield {"type": "reasoning_update", "session": session,
431
+ "agent_status": (agent_id, "idle", "Standby")}
432
+ yield {"type": "reasoning_update", "session": session,
433
+ "agent_status": ("team", "working", "Reviewing results...")}
434
+
435
+ elif event.event == RunEvent.run_content and event.agent_id == "presenter":
436
+ report_content += event.content
437
+ yield {"type": "report_stream", "content": report_content, "session": session}
438
+
439
+ elif event.event == TeamRunEvent.tool_call_started:
440
+ tool_name = event.tool.tool_name
441
+ yield {"type": "reasoning_update", "session": session,
442
+ "agent_status": ("team", "working", "Orchestrating...")}
443
+ if "delegate_task_to_member" in tool_name:
444
+ member_id = event.tool.tool_args.get("member_id", "unknown")
445
+ self._add_reasoning(session, "team", f"👉 Delegating to {member_id}...")
446
+ yield {"type": "reasoning_update", "session": session,
447
+ "agent_status": (member_id, "working", "Receiving Task...")}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  else:
449
+ self._add_reasoning(session, "team", f"🔧 Tool: {tool_name}")
450
+
451
+ elif event.event == RunEvent.tool_call_started:
452
+ member_id = event.agent_id
453
+ tool_name = event.tool.tool_name
454
+ yield {"type": "reasoning_update", "session": session,
455
+ "agent_status": ("team", "working", f"Monitoring {member_id}...")}
456
+ self._add_reasoning(session, member_id, f"Using tool: {tool_name}...")
457
+ yield {"type": "reasoning_update", "session": session,
458
+ "agent_status": (member_id, "working", f"Running Tool...")}
459
+
460
+ elif event.event == TeamRunEvent.run_completed:
461
+ self._add_reasoning(session, "team", "🎉 Planning process finished")
462
+ if hasattr(event, 'metrics'):
463
+ logger.info(f"Total tokens: {event.metrics.total_tokens}")
464
+ logger.info(f"Input tokens: {event.metrics.input_tokens}")
465
+ logger.info(f"Output tokens: {event.metrics.output_tokens}")
466
+
467
+ if not has_content and attempt < max_retries: continue
468
+ break
469
 
470
  finally:
471
  logger.info(f"Run time (s): {time.perf_counter() - start_time}")
472
 
 
 
473
  for agent in ["scout", "optimizer", "navigator", "weatherman", "presenter"]:
474
+ yield {"type": "reasoning_update", "session": session, "agent_status": (agent, "idle", "Standby")}
475
+ yield {"type": "reasoning_update", "session": session, "agent_status": ("team", "complete", "All Done!")}
 
 
 
 
 
 
 
 
 
 
 
 
 
476
 
477
+ session.final_report = report_html = f"## 🎯 Planning Complete\n\n{report_content}"
478
+ yield {"type": "complete", "report_html": report_html, "session": session,
479
+ "agent_status": ("team", "complete", "Finished")}
 
 
 
480
 
481
  except GeneratorExit:
482
+ return
 
 
483
  except Exception as e:
484
  logger.error(f"Error in attempt {attempt}: {e}")
485
+ if attempt >= max_retries: yield {"type": "error", "message": str(e), "session": session}
 
 
486
 
487
+ finally:
488
+ client_session_ctx.reset(token)
489
  # ================= Step 4: Finalize =================
490
 
491
  def run_step4_finalize(self, session: UserSession) -> Dict[str, Any]:
 
496
  raise ValueError(f"No results found")
497
 
498
  structured_data = poi_repo.load(final_ref_id)
 
 
499
  timeline_html = create_timeline_html_enhanced(structured_data.get("timeline", []))
500
 
 
501
  metrics = structured_data.get("metrics", {})
502
  traffic = structured_data.get("traffic_summary", {})
 
 
503
  task_count = f"{metrics['completed_tasks']} / {metrics['total_tasks']}"
504
  high_prio = sum(1 for t in session.task_list if t.get("priority") == "HIGH")
 
 
505
  total_time = metrics.get("optimized_duration_min", traffic.get("total_duration_min", 0))
 
 
506
  dist_m = metrics.get("optimized_distance_m", 0)
507
  total_dist_km = dist_m / 1000.0
 
 
508
  efficiency = metrics.get("route_efficiency_pct", 0)
509
  saved_dist_m = metrics.get("distance_saved_m", 0)
510
  saved_time_min = metrics.get("time_saved_min", 0)
 
512
  date = structured_data.get("global_info", {}).get("departure_time", "N/A")
513
 
514
  summary_card = create_summary_card(task_count, high_prio, int(total_time), start_location, date)
 
 
515
  eff_color = "#047857" if efficiency >= 80 else "#d97706"
516
  eff_bg = "#ecfdf5" if efficiency >= 80 else "#fffbeb"
517
  eff_border = "#a7f3d0" if efficiency >= 80 else "#fde68a"
 
519
  ai_stats_html = f"""
520
  <div style="display: flex; gap: 12px; margin-bottom: 20px;">
521
  <div style="flex: 1; background: {eff_bg}; padding: 16px; border-radius: 12px; border: 1px solid {eff_border};">
522
+ <div style="font-size: 0.8rem; color: {eff_color}; font-weight: 600; display: flex; align-items: center; gap: 4px;"><span>🚀 AI EFFICIENCY</span></div>
523
+ <div style="font-size: 1.8rem; font-weight: 800; color: {eff_color}; line-height: 1.2;">{efficiency:.1f}%</div>
524
+ <div style="font-size: 0.75rem; color: {eff_color}; opacity: 0.9; margin-top: 4px;">⚡ Saved {saved_time_min:.0f} mins</div>
 
 
 
 
 
 
525
  </div>
 
526
  <div style="flex: 1; background: #eff6ff; padding: 16px; border-radius: 12px; border: 1px solid #bfdbfe;">
527
  <div style="font-size: 0.8rem; color: #2563eb; font-weight: 600;">🚗 TOTAL DISTANCE</div>
528
+ <div style="font-size: 1.8rem; font-weight: 800; color: #1d4ed8; line-height: 1.2;">{total_dist_km:.2f} <span style="font-size: 1rem;">km</span></div>
529
+ <div style="font-size: 0.75rem; color: #2563eb; opacity: 0.9; margin-top: 4px;">📉 Reduced {saved_dist_m} m</div>
 
 
 
 
530
  </div>
531
+ </div>"""
 
532
 
533
  full_summary_html = f"{summary_card}{ai_stats_html}<h3>📍 Itinerary Timeline</h3>{timeline_html}"
 
 
 
 
 
534
  map_fig = create_animated_map(structured_data)
 
 
 
 
 
 
535
  task_list_html = self.generate_task_list_html(session)
 
536
  session.planning_completed = True
537
 
538
+ return {"type": "success", "summary_tab_html": full_summary_html, "report_md": session.final_report,
539
+ "task_list_html": task_list_html, "map_fig": map_fig, "session": session}
 
 
 
 
 
 
540
 
541
  except Exception as e:
542
  logger.error(f"Finalize error: {e}", exc_info=True)
 
545
  # ================= Helpers =================
546
 
547
  def _add_reasoning(self, session: UserSession, agent: str, message: str):
548
+ session.reasoning_messages.append(
549
+ {"agent": agent, "message": message, "time": datetime.now().strftime("%H:%M:%S")})
 
 
 
550
 
551
  def _convert_task_list_to_ui_format(self, task_list_data):
552
  ui_tasks = []
 
556
  tasks = task_list_data
557
  else:
558
  return []
 
559
  for i, task in enumerate(tasks, 1):
560
+ ui_tasks.append({
561
  "id": i,
562
  "title": task.get("description", "Task"),
563
  "priority": task.get("priority", "MEDIUM"),
 
565
  "duration": f"{task.get('service_duration_min', 30)} minutes",
566
  "location": task.get("location_hint", "To be determined"),
567
  "icon": self._get_task_icon(task.get("category", "other"))
568
+ })
 
569
  return ui_tasks
570
 
571
  def _get_task_icon(self, category: str) -> str:
572
+ icons = {"medical": "🏥", "shopping": "🛒", "postal": "📮", "food": "🍽️", "entertainment": "🎭",
573
+ "transportation": "🚗", "other": "📋"}
 
 
 
574
  return icons.get(category.lower(), "📋")
575
 
576
  def generate_task_list_html(self, session: UserSession) -> str:
577
+ if not session.task_list: return "<p>No tasks available</p>"
 
578
  html = ""
579
  for task in session.task_list:
580
+ html += create_task_card(task["id"], task["title"], task["priority"], task["time"], task["duration"],
581
+ task["location"], task.get("icon", "📋"))
 
 
 
582
  return html
src/infra/poi_repository.py CHANGED
@@ -10,18 +10,39 @@ logger = get_logger(__name__)
10
 
11
 
12
  class PoiRepository:
13
- DB_DIR = "storage"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  DB_FILE = "lifeflow_payloads.db"
15
  DB_PATH = os.path.join(DB_DIR, DB_FILE)
16
 
 
 
 
 
17
  def __init__(self):
 
 
 
 
18
  self._init_db()
19
- # ✅ 改用字典來存儲: Key=SessionID, Value=RefID
20
- self._session_last_id: Dict[str, str] = {}
21
 
22
  def _init_db(self):
23
- os.makedirs(self.DB_DIR, exist_ok=True)
24
  with sqlite3.connect(self.DB_PATH) as conn:
 
25
  conn.execute("""
26
  CREATE TABLE IF NOT EXISTS offloaded_data (
27
  ref_id TEXT PRIMARY KEY,
@@ -31,28 +52,51 @@ class PoiRepository:
31
  )
32
  """)
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  def save(self, data: Any, data_type: str = "generic") -> str:
35
  ref_id = f"{data_type}_{uuid.uuid4().hex[:8]}"
36
 
37
- # 1. 寫入物理 DB (持久化)
38
  with sqlite3.connect(self.DB_PATH) as conn:
 
39
  conn.execute(
40
  "INSERT INTO offloaded_data (ref_id, data_type, payload) VALUES (?, ?, ?)",
41
  (ref_id, data_type, json.dumps(data, default=str))
42
  )
43
 
44
- # 2. 記錄這個 Session 的最後 ID
45
- current_session = get_session_id()
46
- if current_session:
47
- self._session_last_id[current_session] = ref_id
48
- logger.info(f"💾 [Repo] Saved {ref_id} for Session: {current_session}")
49
- else:
50
- logger.warning(f"⚠️ [Repo] Warning: No session context found! 'last_id' not tracked.")
 
 
 
 
 
51
 
52
  return ref_id
53
 
54
  def load(self, ref_id: str) -> Optional[Any]:
55
- # load 保持不變,因為它是靠 ref_id 查的
 
 
 
 
 
56
  with sqlite3.connect(self.DB_PATH) as conn:
57
  cursor = conn.execute("SELECT payload FROM offloaded_data WHERE ref_id = ?", (ref_id,))
58
  row = cursor.fetchone()
@@ -60,9 +104,17 @@ class PoiRepository:
60
  return json.loads(row[0])
61
  return None
62
 
63
- # ✅ 新增方法:獲取特定 Session 的最後 ID
64
  def get_last_id_by_session(self, session_id: str) -> Optional[str]:
65
- return self._session_last_id.get(session_id)
 
 
 
 
 
 
 
 
66
 
67
 
68
- poi_repo = PoiRepository()
 
 
10
 
11
 
12
  class PoiRepository:
13
+ # =========================================================================
14
+ # 1. 設定絕對路徑 (Class Attributes) - 確保在 __init__ 之前就存在
15
+ # =========================================================================
16
+
17
+ # 抓出這個檔案 (poi_repository.py) 的絕對路徑: .../LifeFlow-AI/src/infra
18
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
19
+
20
+ # 往上推兩層回到專案根目錄: .../LifeFlow-AI
21
+ # (src/infra -> src -> LifeFlow-AI)
22
+ PROJECT_ROOT = os.path.dirname(os.path.dirname(BASE_DIR))
23
+
24
+ # 設定 DB 存放目錄: .../LifeFlow-AI/storage
25
+ DB_DIR = os.path.join(PROJECT_ROOT, "storage")
26
+
27
+ # 設定 DB 完整路徑: .../LifeFlow-AI/storage/lifeflow_payloads.db
28
  DB_FILE = "lifeflow_payloads.db"
29
  DB_PATH = os.path.join(DB_DIR, DB_FILE)
30
 
31
+ # =========================================================================
32
+ # 2. 初始化邏輯
33
+ # =========================================================================
34
+
35
  def __init__(self):
36
+ # 1. 先確保資料夾存在 (這時候 self.DB_DIR 已經讀得到了)
37
+ os.makedirs(self.DB_DIR, exist_ok=True)
38
+
39
+ # 2. 再初始化 DB (這時候 self.DB_PATH 已經讀得到了)
40
  self._init_db()
 
 
41
 
42
  def _init_db(self):
43
+ # 使用 self.DB_PATH
44
  with sqlite3.connect(self.DB_PATH) as conn:
45
+ # 1. 既有的資料表
46
  conn.execute("""
47
  CREATE TABLE IF NOT EXISTS offloaded_data (
48
  ref_id TEXT PRIMARY KEY,
 
52
  )
53
  """)
54
 
55
+ # 2. Session 狀態表
56
+ conn.execute("""
57
+ CREATE TABLE IF NOT EXISTS session_state (
58
+ session_id TEXT PRIMARY KEY,
59
+ last_ref_id TEXT,
60
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
61
+ )
62
+ """)
63
+
64
+ # =========================================================================
65
+ # 3. 讀寫操作
66
+ # =========================================================================
67
+
68
  def save(self, data: Any, data_type: str = "generic") -> str:
69
  ref_id = f"{data_type}_{uuid.uuid4().hex[:8]}"
70
 
 
71
  with sqlite3.connect(self.DB_PATH) as conn:
72
+ # 1. 寫入 Payload
73
  conn.execute(
74
  "INSERT INTO offloaded_data (ref_id, data_type, payload) VALUES (?, ?, ?)",
75
  (ref_id, data_type, json.dumps(data, default=str))
76
  )
77
 
78
+ # 2. 更新 Session 的最後 ID
79
+ current_session = get_session_id()
80
+ if current_session:
81
+ conn.execute("""
82
+ INSERT OR REPLACE INTO session_state (session_id, last_ref_id)
83
+ VALUES (?, ?)
84
+ """, (current_session, ref_id))
85
+
86
+ logger.info(f"💾 [Repo] Saved {ref_id} for Session: {current_session}")
87
+ else:
88
+ # 這裡改用 debug level,以免 log 太多雜訊
89
+ logger.debug(f"⚠️ [Repo] No session context found, 'last_id' not tracked.")
90
 
91
  return ref_id
92
 
93
  def load(self, ref_id: str) -> Optional[Any]:
94
+ if not ref_id: return None
95
+
96
+ # 容錯:如果路徑還沒建立 (理論上 init 已建立,但防呆)
97
+ if not os.path.exists(self.DB_PATH):
98
+ return None
99
+
100
  with sqlite3.connect(self.DB_PATH) as conn:
101
  cursor = conn.execute("SELECT payload FROM offloaded_data WHERE ref_id = ?", (ref_id,))
102
  row = cursor.fetchone()
 
104
  return json.loads(row[0])
105
  return None
106
 
 
107
  def get_last_id_by_session(self, session_id: str) -> Optional[str]:
108
+ if not os.path.exists(self.DB_PATH):
109
+ return None
110
+
111
+ with sqlite3.connect(self.DB_PATH) as conn:
112
+ cursor = conn.execute("SELECT last_ref_id FROM session_state WHERE session_id = ?", (session_id,))
113
+ row = cursor.fetchone()
114
+ if row:
115
+ return row[0]
116
+ return None
117
 
118
 
119
+ # 全域���例
120
+ poi_repo = PoiRepository()
src/tools/navigation_toolkit.py CHANGED
@@ -28,7 +28,7 @@ class NavigationToolkit(Toolkit):
28
  str: A JSON string containing the 'nav_ref_id' (e.g., '{"nav_ref_id": "navigation_result_abc"}').
29
  """
30
 
31
- print(f"🚗 Navigator: Loading Ref {optimization_ref_id}...")
32
  data = poi_repo.load(optimization_ref_id)
33
 
34
  if not data:
@@ -65,7 +65,7 @@ class NavigationToolkit(Toolkit):
65
  user_start = global_info.get("start_location")
66
  if user_start and "lat" in user_start and "lng" in user_start:
67
  start_coord = user_start
68
- print(f"📍 Using User Start Location: {start_coord}")
69
 
70
  # 3. 依據 Route 的順序組裝 Waypoints
71
  waypoints = []
@@ -98,7 +98,8 @@ class NavigationToolkit(Toolkit):
98
  if lat is not None and lng is not None:
99
  waypoints.append({"lat": lat, "lng": lng})
100
  else:
101
- print(f"⚠️ Skipped step {i}: No coordinates found. Type: {step_type}, ID: {task_id}")
 
102
 
103
  # 4. 驗證與呼叫 API
104
  if len(waypoints) < 2:
@@ -114,7 +115,7 @@ class NavigationToolkit(Toolkit):
114
  except:
115
  start_time = datetime.now(timezone.utc)
116
 
117
- print(f"🚗 Navigator: Calling Google Routes for {len(waypoints)} stops...")
118
 
119
  traffic_result = self.gmaps.compute_routes(
120
  place_points=waypoints,
@@ -135,7 +136,7 @@ class NavigationToolkit(Toolkit):
135
  if "global_info" not in data:
136
  data["global_info"] = global_info
137
 
138
- print(f"✅ Traffic and timing calculated successfully.\n {data}")
139
 
140
  nav_ref_id = poi_repo.save(data, data_type="navigation_result")
141
 
 
28
  str: A JSON string containing the 'nav_ref_id' (e.g., '{"nav_ref_id": "navigation_result_abc"}').
29
  """
30
 
31
+ logger.info(f"🚗 Navigator: Loading Ref {optimization_ref_id}...")
32
  data = poi_repo.load(optimization_ref_id)
33
 
34
  if not data:
 
65
  user_start = global_info.get("start_location")
66
  if user_start and "lat" in user_start and "lng" in user_start:
67
  start_coord = user_start
68
+ logger.info(f"📍 Using User Start Location: {start_coord}")
69
 
70
  # 3. 依據 Route 的順序組裝 Waypoints
71
  waypoints = []
 
98
  if lat is not None and lng is not None:
99
  waypoints.append({"lat": lat, "lng": lng})
100
  else:
101
+ pass
102
+ logger.warning(f"⚠️ Skipped step {i}: No coordinates found. Type: {step_type}, ID: {task_id}")
103
 
104
  # 4. 驗證與呼叫 API
105
  if len(waypoints) < 2:
 
115
  except:
116
  start_time = datetime.now(timezone.utc)
117
 
118
+ logger.info(f"🚗 Navigator: Calling Google Routes for {len(waypoints)} stops...")
119
 
120
  traffic_result = self.gmaps.compute_routes(
121
  place_points=waypoints,
 
136
  if "global_info" not in data:
137
  data["global_info"] = global_info
138
 
139
+ logger.info(f"✅ Traffic and timing calculated successfully.\n {data}")
140
 
141
  nav_ref_id = poi_repo.save(data, data_type="navigation_result")
142
 
src/tools/optimizer_toolkit.py CHANGED
@@ -39,7 +39,7 @@ class OptimizationToolkit(Toolkit):
39
  "message": str
40
  }
41
  """
42
- logger.info(f"🧮 Optimizer: Fetching data for {ref_id}...")
43
 
44
  data = poi_repo.load(ref_id)
45
  if not data:
@@ -72,7 +72,7 @@ class OptimizationToolkit(Toolkit):
72
  parsed_time = dt_time.fromisoformat(start_time_str)
73
 
74
  start_time = datetime.combine(default_date, parsed_time)
75
- print(f"⚠️ Warning: Received time-only '{start_time_str}'. Auto-fixed to: {start_time}")
76
 
77
  except ValueError:
78
  return json.dumps({
@@ -133,7 +133,7 @@ class OptimizationToolkit(Toolkit):
133
  # 如果不加這一行,Navigator 就會因為找不到 departure_time 而報錯
134
  result["tasks"] = tasks
135
  result["global_info"] = global_info
136
- print(f"🧾 Optimizer: Inherited global_info to result.\n {result}")
137
  # 儲存結果
138
  result_ref_id = poi_repo.save(result, data_type="optimization_result")
139
 
 
39
  "message": str
40
  }
41
  """
42
+ logger.info(f"🧮 Optimizer: Loading Ref {ref_id}...")
43
 
44
  data = poi_repo.load(ref_id)
45
  if not data:
 
72
  parsed_time = dt_time.fromisoformat(start_time_str)
73
 
74
  start_time = datetime.combine(default_date, parsed_time)
75
+ logger.warning(f"⚠️ Warning: Received time-only '{start_time_str}'. Auto-fixed to: {start_time}")
76
 
77
  except ValueError:
78
  return json.dumps({
 
133
  # 如果不加這一行,Navigator 就會因為找不到 departure_time 而報錯
134
  result["tasks"] = tasks
135
  result["global_info"] = global_info
136
+ logger.info(f"🧾 Optimizer: Inherited global_info to result.\n {result}")
137
  # 儲存結果
138
  result_ref_id = poi_repo.save(result, data_type="optimization_result")
139
 
src/tools/reader_toolkit.py CHANGED
@@ -38,7 +38,7 @@ class ReaderToolkit(Toolkit):
38
  """
39
 
40
 
41
- logger.info(f"📖 Presenter: QA Checking Ref {ref_id}...")
42
 
43
  data = poi_repo.load(ref_id)
44
  if not data:
 
38
  """
39
 
40
 
41
+ logger.info(f"📖 Presenter: QA Loading Ref {ref_id}...")
42
 
43
  data = poi_repo.load(ref_id)
44
  if not data:
src/tools/scout_toolkit.py CHANGED
@@ -158,6 +158,7 @@ class ScoutToolkit(Toolkit):
158
  anchor_point = {"lat": lat, "lng": lng}
159
  logger.info(f" ✅ Resolved Start: {name}")
160
  except Exception as e:
 
161
  logger.warning(f" ❌ Error searching start location: {e}")
162
 
163
  elif isinstance(start_loc, dict):
@@ -255,14 +256,14 @@ class ScoutToolkit(Toolkit):
255
  "candidates": candidates
256
  }
257
  enriched_tasks.append(task_entry)
258
- #print(f" - Task {task_id}: Found {len(candidates)} POIs")
259
 
260
  full_payload = {"global_info": global_info, "tasks": enriched_tasks}
261
  ref_id = poi_repo.save(full_payload, data_type="scout_result")
262
 
263
  return json.dumps({
264
  "status": "SUCCESS",
265
- #"message": "Search POI complete.",
266
  "scout_ref": ref_id,
267
  "note": "Please pass this scout_ref to the Optimizer immediately."
268
  })
 
158
  anchor_point = {"lat": lat, "lng": lng}
159
  logger.info(f" ✅ Resolved Start: {name}")
160
  except Exception as e:
161
+ pass
162
  logger.warning(f" ❌ Error searching start location: {e}")
163
 
164
  elif isinstance(start_loc, dict):
 
256
  "candidates": candidates
257
  }
258
  enriched_tasks.append(task_entry)
259
+ logger.info(f" - Task {task_id}: Found {len(candidates)} POIs")
260
 
261
  full_payload = {"global_info": global_info, "tasks": enriched_tasks}
262
  ref_id = poi_repo.save(full_payload, data_type="scout_result")
263
 
264
  return json.dumps({
265
  "status": "SUCCESS",
266
+ "message_task": len(enriched_tasks),
267
  "scout_ref": ref_id,
268
  "note": "Please pass this scout_ref to the Optimizer immediately."
269
  })
src/tools/weather_toolkit.py CHANGED
@@ -16,6 +16,25 @@ class WeatherToolkit(Toolkit):
16
  self.register(self.check_weather_for_timeline)
17
 
18
  def check_weather_for_timeline(self, nav_ref_id: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  logger.debug(f"🌤️ Weatherman: Loading Ref {nav_ref_id}...")
20
  data = poi_repo.load(nav_ref_id)
21
  if not data:
 
16
  self.register(self.check_weather_for_timeline)
17
 
18
  def check_weather_for_timeline(self, nav_ref_id: str) -> str:
19
+
20
+ """
21
+ Enriches the solved navigation route with weather forecasts and Air Quality Index (AQI) to create the final timeline.
22
+
23
+ This tool is the final post-processing step. It loads the solved route data, calculates precise local arrival times for each stop
24
+ (dynamically adjusting for the destination's timezone), and fetches specific weather conditions and AQI for those times.
25
+ It also resolves final location names and saves the complete itinerary for presentation.
26
+
27
+ Args:
28
+ nav_ref_id (str): The unique reference ID returned by the Route Solver (or Navigation) step.
29
+
30
+ Returns:
31
+ str: A JSON string containing the reference ID for the finalized data.
32
+ Structure:
33
+ {
34
+ "final_ref_id": str
35
+ }
36
+ """
37
+
38
  logger.debug(f"🌤️ Weatherman: Loading Ref {nav_ref_id}...")
39
  data = poi_repo.load(nav_ref_id)
40
  if not data: