Coverage for agentos/dashboard/server.py: 0%

117 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-02 09:59 +0800

1""" 

2Dashboard HTTP 服务器 — 内嵌单页 HTML,无需外部依赖。 

3 

4启动: agentos dashboard 

5访问: http://localhost:18500 

6""" 

7 

8from __future__ import annotations 

9 

10import json 

11import http.server 

12import os 

13import queue 

14import threading 

15import webbrowser 

16from pathlib import Path 

17from urllib.parse import urlparse, parse_qs 

18 

19from agentos.dashboard.tracker import Tracker 

20 

21PORT = 18500 

22 

23# SSE 事件队列(线程安全,用于实时推送) 

24_sse_queues: list[queue.Queue] = [] 

25_sse_lock = threading.Lock() 

26 

27def _sse_broadcast(event_type: str, data: dict): 

28 """广播事件到所有 SSE 连接。""" 

29 payload = json.dumps({"type": event_type, "data": data}, ensure_ascii=False) 

30 with _sse_lock: 

31 dead = [] 

32 for q in _sse_queues: 

33 try: 

34 q.put_nowait(payload) 

35 except queue.Full: 

36 dead.append(q) 

37 for q in dead: 

38 _sse_queues.remove(q) 

39 

40# ============================================================ 

41# 内嵌前端(纯 HTML/CSS/JS,零外部依赖) 

42# ============================================================ 

43 

44DASHBOARD_HTML = r"""<!DOCTYPE html> 

45<html lang="zh-CN"> 

46<head> 

47<meta charset="UTF-8"> 

48<meta name="viewport" content="width=device-width, initial-scale=1.0"> 

49<title>AgentOS Dashboard — 追踪面板</title> 

50<style> 

51*{margin:0;padding:0;box-sizing:border-box} 

52body{ 

53 font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; 

54 background:#0d1117;color:#c9d1d9;min-height:100vh; 

55} 

56.header{ 

57 background:#161b22;border-bottom:1px solid #30363d;padding:16px 24px; 

58 display:flex;align-items:center;justify-content:space-between; 

59} 

60.header h1{ 

61 font-size:20px;font-weight:600; 

62 background:linear-gradient(90deg,#58a6ff,#bc8cff); 

63 -webkit-background-clip:text;-webkit-text-fill-color:transparent; 

64} 

65.header .stats{font-size:13px;color:#8b949e} 

66.main{padding:20px 24px;max-width:1200px;margin:0 auto} 

67.sessions{display:flex;flex-direction:column;gap:12px} 

68.session-card{ 

69 background:#161b22;border:1px solid #30363d;border-radius:8px; 

70 padding:16px 20px;cursor:pointer;transition:border-color .15s; 

71} 

72.session-card:hover{border-color:#58a6ff} 

73.session-card .top{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px} 

74.session-card .task{font-size:15px;font-weight:600;color:#e6edf3} 

75.session-card .id{font-size:11px;color:#484f58;font-family:monospace} 

76.session-card .meta{font-size:12px;color:#8b949e;display:flex;gap:16px} 

77.session-card .status{ 

78 display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600; 

79} 

80.status-completed{background:#1a3a2a;color:#3fb950} 

81.status-running{background:#1a2a3a;color:#58a6ff} 

82.status-error{background:#3a1a1a;color:#f85149} 

83.status-cancelled{background:#3a3a1a;color:#d29922} 

84 

85/* 详情面板 */ 

86.detail-panel{ 

87 background:#161b22;border:1px solid #30363d;border-radius:8px;margin-top:16px; 

88} 

89.detail-header{ 

90 display:flex;justify-content:space-between;align-items:center; 

91 padding:12px 20px;border-bottom:1px solid #30363d; 

92} 

93.detail-header h3{font-size:16px;color:#e6edf3} 

94.detail-header button{ 

95 background:none;border:1px solid #30363d;color:#8b949e;padding:4px 12px; 

96 border-radius:6px;cursor:pointer;font-size:12px; 

97} 

98.detail-header button:hover{color:#e6edf3;border-color:#58a6ff} 

99 

100/* 时间线 */ 

101.timeline{position:relative;padding:16px 20px} 

102.timeline::before{ 

103 content:'';position:absolute;left:26px;top:30px;bottom:30px; 

104 width:2px;background:#30363d; 

105} 

106.step{ 

107 position:relative;padding:8px 0 8px 48px;display:flex;gap:12px; 

108} 

109.step-dot{ 

110 position:absolute;left:20px;top:14px;width:14px;height:14px; 

111 border-radius:50%;border:2px solid #30363d;background:#0d1117;z-index:1; 

112} 

113.step-dot.thinking{border-color:#58a6ff;background:#0d2847} 

114.step-dot.tool_call{border-color:#d29922;background:#2a2010} 

115.step-dot.tool_result{border-color:#3fb950;background:#102a18} 

116.step-dot.final_answer{border-color:#bc8cff;background:#281a3a} 

117.step-content{flex:1} 

118.step-type{ 

119 font-size:11px;font-weight:600;text-transform:uppercase;margin-bottom:4px; 

120} 

121.step-type.thinking{color:#58a6ff} 

122.step-type.tool_call{color:#d29922} 

123.step-type.tool_result{color:#3fb950} 

124.step-type.final_answer{color:#bc8cff} 

125.step-detail{font-size:13px;color:#c9d1d9;word-break:break-word} 

126.step-meta{font-size:11px;color:#484f58;margin-top:4px} 

127 

128/* 统计卡片 */ 

129.stat-row{display:flex;gap:16px;margin-bottom:20px} 

130.stat-card{ 

131 flex:1;background:#161b22;border:1px solid #30363d;border-radius:8px; 

132 padding:16px;text-align:center; 

133} 

134.stat-card .value{font-size:28px;font-weight:700;color:#e6edf3} 

135.stat-card .label{font-size:12px;color:#8b949e;margin-top:4px} 

136.stat-card .value.cost{color:#3fb950} 

137.stat-card .value.tokens{color:#58a6ff} 

138 

139.empty{ 

140 text-align:center;padding:60px 20px;color:#484f58; 

141} 

142.empty p{font-size:14px;margin-bottom:8px} 

143.empty code{ 

144 background:#161b22;border:1px solid #30363d;border-radius:4px; 

145 padding:2px 8px;font-size:13px; 

146} 

147.refresh-btn{ 

148 background:#21262d;border:1px solid #30363d;color:#c9d1d9; 

149 padding:6px 16px;border-radius:6px;cursor:pointer;font-size:12px; 

150 margin-left:8px; 

151} 

152.refresh-btn:hover{border-color:#58a6ff} 

153</style> 

154</head> 

155<body> 

156<div class="header"> 

157 <div> 

158 <h1>AgentOS Dashboard</h1> 

159 <div class="stats" id="stats-bar">正在加载...</div> 

160 </div> 

161 <button class="refresh-btn" onclick="loadSessions()">刷新</button> 

162</div> 

163<div class="main"> 

164 <div class="stat-row" id="stat-cards"></div> 

165 <div class="sessions" id="session-list"></div> 

166 <div id="detail-area"></div> 

167</div> 

168<script> 

169const API = '/api'; 

170 

171async function loadSessions() { 

172 const resp = await fetch(API + '/sessions'); 

173 const data = await resp.json(); 

174 renderStats(data.sessions || []); 

175 renderSessions(data.sessions || []); 

176} 

177 

178function renderStats(sessions) { 

179 const total = sessions.length; 

180 const completed = sessions.filter(s => s.status === 'completed').length; 

181 let totalTokens = 0, totalCost = 0; 

182 sessions.forEach(s => { totalTokens += s.total_tokens || 0; totalCost += s.total_cost_usd || 0; }); 

183 document.getElementById('stats-bar').textContent = 

184 `${total} 次运行 · ${completed} 完成`; 

185 document.getElementById('stat-cards').innerHTML = 

186 `<div class="stat-card"><div class="value">${total}</div><div class="label">总运行次数</div></div> 

187 <div class="stat-card"><div class="value">${completed}</div><div class="label">成功完成</div></div> 

188 <div class="stat-card"><div class="value tokens">${(totalTokens/1000).toFixed(1)}K</div><div class="label">Token 消耗</div></div> 

189 <div class="stat-card"><div class="value cost">$${totalCost.toFixed(4)}</div><div class="label">总成本</div></div>`; 

190} 

191 

192function renderSessions(sessions) { 

193 const el = document.getElementById('session-list'); 

194 if (!sessions.length) { 

195 el.innerHTML = `<div class="empty"> 

196 <p>尚无运行记录</p> 

197 <p>运行 <code>agentos "你的任务"</code> 后会自动记录到这里</p> 

198 </div>`; 

199 return; 

200 } 

201 el.innerHTML = sessions.map(s => { 

202 const dur = s.finished_at ? ((s.finished_at - s.started_at) / 1000).toFixed(1) + 's' : '进行中'; 

203 const ts = new Date(s.started_at * 1000).toLocaleString('zh-CN'); 

204 const statusClass = 'status-' + (s.status || 'running'); 

205 const statusLabel = {completed:'已完成',running:'运行中',error:'错误',cancelled:'已取消'}[s.status] || s.status; 

206 return `<div class="session-card" onclick="showDetail('${s.session_id}')"> 

207 <div class="top"> 

208 <span class="task">${esc(s.task)}</span> 

209 <span class="id">${s.session_id.slice(0,12)}</span> 

210 </div> 

211 <div class="meta"> 

212 <span>${dur}</span> 

213 <span>${s.total_tokens || 0} tokens</span> 

214 <span>$${(s.total_cost_usd || 0).toFixed(4)}</span> 

215 <span>${ts}</span> 

216 <span>${s.model || s.provider || ''}</span> 

217 <span class="status ${statusClass}">${statusLabel}</span> 

218 </div> 

219 </div>`; 

220 }).join(''); 

221} 

222 

223async function showDetail(sid) { 

224 const resp = await fetch(API + '/sessions/' + sid); 

225 const s = await resp.json(); 

226 if (!s) return; 

227 const dur = s.finished_at ? ((s.finished_at - s.started_at) / 1000).toFixed(1) + 's' : '进行中'; 

228 const steps = s.steps || []; 

229 const timeline = steps.length ? steps.map(st => { 

230 const dotClass = st.step_type || ''; 

231 return `<div class="step"> 

232 <div class="step-dot ${dotClass}"></div> 

233 <div class="step-content"> 

234 <div class="step-type ${dotClass}">${st.step_type}</div> 

235 <div class="step-detail">${esc(st.detail)}</div> 

236 <div class="step-meta">#${st.step_index} · ${(st.duration_ms||0).toFixed(0)}ms · ${st.tokens||0} tokens</div> 

237 </div> 

238 </div>`; 

239 }).join('') : '<div class="empty"><p>无步骤记录</p></div>'; 

240 

241 document.getElementById('detail-area').innerHTML = ` 

242 <div class="detail-panel"> 

243 <div class="detail-header"> 

244 <h3>${esc(s.task)}</h3> 

245 <button onclick="document.getElementById('detail-area').innerHTML=''">关闭</button> 

246 </div> 

247 <div class="stat-row" style="padding:16px 20px 0"> 

248 <div class="stat-card"><div class="value">${dur}</div><div class="label">耗时</div></div> 

249 <div class="stat-card"><div class="value tokens">${s.total_tokens||0}</div><div class="label">Tokens</div></div> 

250 <div class="stat-card"><div class="value cost">$${(s.total_cost_usd||0).toFixed(4)}</div><div class="label">成本</div></div> 

251 <div class="stat-card"><div class="value">${steps.length}</div><div class="label">步骤数</div></div> 

252 </div> 

253 <div class="timeline">${timeline}</div> 

254 </div>`; 

255} 

256 

257function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } 

258loadSessions(); 

259 

260// SSE 实时推送 

261var es = new EventSource('/api/events'); 

262es.addEventListener('connected', function(e) { console.log('[dashboard] SSE connected'); }); 

263es.addEventListener('message', function(e) { 

264 try { 

265 var msg = JSON.parse(e.data); 

266 if (msg.type === 'step' || msg.type === 'session_done') { 

267 loadSessions(); 

268 } 

269 } catch (_) {} 

270}); 

271es.onerror = function() { console.log('[dashboard] SSE disconnected, retrying...'); }; 

272</script> 

273</body> 

274</html>""" 

275 

276# ============================================================ 

277# HTTP Handler 

278# ============================================================ 

279 

280class DashboardHandler(http.server.BaseHTTPRequestHandler): 

281 """Dashboard HTTP 请求处理器。""" 

282 

283 def log_message(self, format, *args): 

284 pass # 静默日志 

285 

286 def do_GET(self): 

287 parsed = urlparse(self.path) 

288 path = parsed.path.rstrip("/") or "/" 

289 

290 # 主页 

291 if path == "/": 

292 self._html(200, DASHBOARD_HTML) 

293 return 

294 

295 # API: 列出所有会话 

296 if path == "/api/sessions": 

297 tracker = Tracker.get() 

298 sessions = tracker.list_sessions() 

299 self._json({"sessions": sessions}) 

300 return 

301 

302 # API: 单个会话详情 

303 if path.startswith("/api/sessions/"): 

304 sid = path.split("/api/sessions/")[-1] 

305 tracker = Tracker.get() 

306 session = tracker.get_session(sid) 

307 if session is None: 

308 self._json({"error": "not found"}, 404) 

309 else: 

310 self._json(session) 

311 return 

312 

313 # API: SSE 实时事件流 

314 if path == "/api/events": 

315 self._handle_sse() 

316 return 

317 

318 # API: 健康检查 

319 if path == "/api/health": 

320 tracker = Tracker.get() 

321 self._json({ 

322 "status": "ok", 

323 "active_sessions": len(tracker._active), 

324 "sse_clients": len(_sse_queues), 

325 }) 

326 return 

327 

328 self._html(404, "<h1>404</h1>") 

329 

330 def _json(self, data, status=200): 

331 body = json.dumps(data, ensure_ascii=False).encode("utf-8") 

332 self.send_response(status) 

333 self.send_header("Content-Type", "application/json; charset=utf-8") 

334 self.send_header("Content-Length", str(len(body))) 

335 self.send_header("Access-Control-Allow-Origin", "*") 

336 self.end_headers() 

337 self.wfile.write(body) 

338 

339 def _html(self, status, content): 

340 body = content.encode("utf-8") 

341 self.send_response(status) 

342 self.send_header("Content-Type", "text/html; charset=utf-8") 

343 self.send_header("Content-Length", str(len(body))) 

344 self.end_headers() 

345 self.wfile.write(body) 

346 

347 def _handle_sse(self): 

348 """SSE (Server-Sent Events) 实时推送。""" 

349 self.send_response(200) 

350 self.send_header("Content-Type", "text/event-stream") 

351 self.send_header("Cache-Control", "no-cache") 

352 self.send_header("Connection", "keep-alive") 

353 self.send_header("Access-Control-Allow-Origin", "*") 

354 self.end_headers() 

355 

356 q: queue.Queue = queue.Queue(maxsize=256) 

357 with _sse_lock: 

358 _sse_queues.append(q) 

359 

360 try: 

361 # 发送初始连接事件 

362 self.wfile.write(b"event: connected\ndata: {}\n\n") 

363 self.wfile.flush() 

364 while True: 

365 try: 

366 payload = q.get(timeout=30) # 30s 心跳 

367 self.wfile.write(f"data: {payload}\n\n".encode("utf-8")) 

368 self.wfile.flush() 

369 except queue.Empty: 

370 # 心跳保活 

371 self.wfile.write(b": heartbeat\n\n") 

372 self.wfile.flush() 

373 except (BrokenPipeError, ConnectionResetError, OSError): 

374 pass 

375 finally: 

376 with _sse_lock: 

377 if q in _sse_queues: 

378 _sse_queues.remove(q) 

379 

380 

381class DashboardServer: 

382 """Dashboard HTTP 服务器。""" 

383 

384 def __init__(self, port: int = PORT): 

385 self.port = port 

386 self._server: http.server.HTTPServer | None = None 

387 

388 def start(self, open_browser: bool = True): 

389 self._server = http.server.HTTPServer(("0.0.0.0", self.port), DashboardHandler) 

390 # 注册 Tracker → SSE 桥接 

391 Tracker.get().subscribe(_sse_broadcast) 

392 url = f"http://localhost:{self.port}" 

393 print(f"AgentOS Dashboard → {url}") 

394 if open_browser: 

395 try: 

396 webbrowser.open(url) 

397 except Exception: 

398 pass 

399 try: 

400 self._server.serve_forever() 

401 except KeyboardInterrupt: 

402 print("\nDashboard stopped.") 

403 

404 

405def start_dashboard(port: int = PORT, open_browser: bool = True): 

406 """便捷启动函数。""" 

407 srv = DashboardServer(port=port) 

408 srv.start(open_browser=open_browser)