Spaces:
Running
feat: 🚀 Evolve to Stateful MCP Architecture with Context Injection Middleware
Browse filesMajor 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 +266 -397
- config.py +1 -0
- requirements.txt +1 -0
- services/planner_service.py +250 -596
- src/infra/poi_repository.py +68 -16
- src/tools/navigation_toolkit.py +6 -5
- src/tools/optimizer_toolkit.py +3 -3
- src/tools/reader_toolkit.py +1 -1
- src/tools/scout_toolkit.py +3 -2
- src/tools/weather_toolkit.py +19 -0
|
@@ -1,35 +1,190 @@
|
|
| 1 |
"""
|
| 2 |
-
LifeFlow AI - Main Application (Fixed
|
| 3 |
-
✅
|
| 4 |
-
✅
|
| 5 |
"""
|
| 6 |
|
| 7 |
import gradio as gr
|
| 8 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 17 |
-
|
| 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 |
-
|
| 46 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 208 |
-
|
| 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 |
-
|
|
|
|
| 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()
|
| 292 |
-
|
| 293 |
stepper = create_progress_stepper(1)
|
| 294 |
-
status_bar = gr.Markdown("
|
| 295 |
|
| 296 |
-
#
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
#
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 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 |
-
|
| 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 |
-
|
| 510 |
-
|
| 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 |
-
|
| 530 |
-
|
| 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 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
return (gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), update_stepper(1),
|
| 549 |
-
|
| 550 |
-
home_btn.click(
|
| 551 |
-
|
| 552 |
-
|
| 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 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
|
@@ -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 |
|
|
@@ -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
|
|
@@ -1,35 +1,32 @@
|
|
| 1 |
"""
|
| 2 |
-
LifeFlow AI - Planner Service (
|
| 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 |
-
#
|
| 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 |
-
#
|
| 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 |
-
#
|
| 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 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
|
| 54 |
logger = get_logger(__name__)
|
| 55 |
max_retries = 5
|
| 56 |
|
| 57 |
|
| 58 |
-
|
| 59 |
-
def patch_repo_context(session_id: str):
|
| 60 |
"""
|
| 61 |
-
|
| 62 |
-
在執行期間,暫時將 poi_repo 的 save/add 方法「綁架」,
|
| 63 |
-
強迫它們在執行前,先設定當前的 Thread Session。
|
| 64 |
"""
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
-
#
|
| 67 |
-
|
| 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 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 206 |
-
"fast_mode": enable_fast_mode}
|
| 207 |
-
|
| 208 |
-
# 2. 初始化 "主模型 (Brain)" - 負責 Planner, Leader, Presenter
|
| 209 |
if provider.lower() == "gemini":
|
| 210 |
-
|
| 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
|
| 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
|
| 224 |
|
| 225 |
-
#
|
| 226 |
-
helper_model = None
|
| 227 |
-
|
| 228 |
-
# 🔥 判斷是否啟用 Fast Mode
|
| 229 |
if enable_fast_mode and groq_api_key:
|
| 230 |
-
|
| 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 |
-
|
| 242 |
-
|
| 243 |
elif provider.lower() == "openai":
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 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 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
"weatherman": helper_model
|
| 261 |
}
|
| 262 |
|
| 263 |
-
|
|
|
|
|
|
|
| 264 |
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 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":
|
| 282 |
-
"optimizer":
|
| 283 |
-
"navigator":
|
| 284 |
-
"weatherman":
|
| 285 |
-
"presenter":
|
| 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 |
-
#
|
| 297 |
-
session.planner_agent = create_planner_agent(
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 347 |
-
|
| 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:
|
| 370 |
displayed_text += content
|
| 371 |
-
|
| 372 |
formatted_text = displayed_text.replace("\n", "<br/>")
|
| 373 |
-
yield {
|
| 374 |
-
|
| 375 |
-
|
| 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 |
-
|
| 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 |
-
|
| 389 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 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 |
-
|
| 471 |
-
|
| 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 |
-
|
| 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"][
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
yield {
|
| 657 |
-
|
| 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 |
-
|
| 738 |
-
|
| 739 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
"session": session,
|
| 767 |
-
"agent_status": ("team", "complete", "Finished")
|
| 768 |
-
}
|
| 769 |
|
| 770 |
except GeneratorExit:
|
| 771 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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
|
|
@@ -10,18 +10,39 @@ logger = get_logger(__name__)
|
|
| 10 |
|
| 11 |
|
| 12 |
class PoiRepository:
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
return ref_id
|
| 53 |
|
| 54 |
def load(self, ref_id: str) -> Optional[Any]:
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
|
| 68 |
-
|
|
|
|
|
|
| 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()
|
|
@@ -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 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
|
|
@@ -39,7 +39,7 @@ class OptimizationToolkit(Toolkit):
|
|
| 39 |
"message": str
|
| 40 |
}
|
| 41 |
"""
|
| 42 |
-
logger.info(f"🧮 Optimizer:
|
| 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 |
-
|
| 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 |
-
|
| 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 |
|
|
@@ -38,7 +38,7 @@ class ReaderToolkit(Toolkit):
|
|
| 38 |
"""
|
| 39 |
|
| 40 |
|
| 41 |
-
logger.info(f"📖 Presenter: QA
|
| 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:
|
|
@@ -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 |
-
|
| 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 |
-
|
| 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 |
})
|
|
@@ -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:
|