1"""

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

3

4 启动: agentos dashboard

5 访问: http://localhost:18500

6"""

7

8 from __future__ import annotations

9

10 import json

11 import http.server

12 import os

13 import queue

14 import threading

15 import webbrowser

16 from pathlib import Path

17 from urllib.parse import urlparse, parse_qs

18

19 from agentos.dashboard.tracker import Tracker

20

21 PORT = 18500

22

23# SSE 事件队列（线程安全，用于实时推送）

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

25_sse_lock = threading.Lock()

26

27 def _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

44 DASHBOARD_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}

52 body{

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>

169 const API = '/api';

170

171 async 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

178 function 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

192 function 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

223 async 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

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

258 loadSessions();

259

260// SSE 实时推送

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

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

263 es.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});

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

272</script>

273</body>

274</html>"""

275

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

277# HTTP Handler

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

279

280 class 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

381 class 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

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

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

407 srv = DashboardServer(port=port)

408 srv.start(open_browser=open_browser)