Coverage for agentos/hitl/gradio_ui.py: 34%

265 statements  

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

1""" 

2AgentOS v1.14.2 — Dynamic HITL UI (Gradio-based Approval Dashboard). 

3 

4受 LangGraph Studio / AutoGen UI 启发,在已有 HITL 审批引擎之上 

5增加 Gradio 驱动的响应式审批面板。Agent 遇到高风险操作时, 

6自动弹出 Web UI 而非阻塞终端。 

7 

8Core features: 

9- GradioApp: 一键启动的审批 Dashboard 

10- ApprovalQueue: 实时审批队列,WebSocket 推送 

11- AgentStatusPanel: Agent 状态监控面板 

12- ApprovalCard: 可定制的审批卡片组件 

13- HistoryView: 审批历史追溯 

14- PolicyEditor: 可视化策略编辑器 

15 

16与 hitl/approver.py 的关系: 

17- approver.py: 审批引擎(决策逻辑、风险评级、策略执行) 

18- gradio_ui.py: 交互层(Web UI、实时推送、可视化配置) 

19""" 

20 

21from __future__ import annotations 

22 

23import asyncio 

24import time 

25import uuid 

26import threading 

27import queue 

28from dataclasses import dataclass, field 

29from datetime import datetime 

30from enum import Enum 

31from typing import ( 

32 Any, Callable, Dict, List, Optional, Tuple, 

33) 

34 

35 

36# ── UI Data Models ────────────────────────── 

37 

38 

39class ApprovalStatus(str, Enum): 

40 PENDING = "pending" 

41 APPROVED = "approved" 

42 DENIED = "denied" 

43 EXPIRED = "expired" 

44 CANCELLED = "cancelled" 

45 

46 

47class RiskLevelUI(str, Enum): 

48 SAFE = "safe" 

49 LOW = "low" 

50 MEDIUM = "medium" 

51 HIGH = "high" 

52 CRITICAL = "critical" 

53 

54 

55@dataclass 

56class ApprovalRequestUI: 

57 """UI 层的审批请求,与底层 HITL 解耦。""" 

58 

59 request_id: str = field(default_factory=lambda: f"apr-{uuid.uuid4().hex[:8]}") 

60 agent_name: str = "" 

61 action: str = "" # 人类可读的操作描述 

62 details: str = "" # 详细说明 

63 risk_level: RiskLevelUI = RiskLevelUI.MEDIUM 

64 status: ApprovalStatus = ApprovalStatus.PENDING 

65 created_at: float = field(default_factory=time.time) 

66 expires_at: float = 0.0 # 超时自动拒绝 

67 source_file: str = "" # 触发操作的文件/代码位置 

68 metadata: Dict[str, Any] = field(default_factory=dict) 

69 # Callback when approved/denied 

70 on_decision: Optional[Callable] = None 

71 

72 @property 

73 def elapsed_seconds(self) -> float: 

74 return time.time() - self.created_at 

75 

76 @property 

77 def is_expired(self) -> bool: 

78 if self.expires_at <= 0: 

79 return False 

80 return time.time() > self.expires_at 

81 

82 

83@dataclass 

84class ApprovalHistory: 

85 """审批历史记录。""" 

86 

87 request: ApprovalRequestUI 

88 decision: ApprovalStatus 

89 decided_by: str = "user" # "user" | "auto" | "timeout" 

90 reason: str = "" 

91 decided_at: float = field(default_factory=time.time) 

92 

93 def to_dict(self) -> dict: 

94 return { 

95 "request_id": self.request.request_id, 

96 "action": self.request.action, 

97 "risk_level": self.request.risk_level.value, 

98 "decision": self.decision.value, 

99 "decided_by": self.decided_by, 

100 "reason": self.reason, 

101 "created_at": self.request.created_at, 

102 "decided_at": self.decided_at, 

103 "elapsed_ms": int((self.decided_at - self.request.created_at) * 1000), 

104 } 

105 

106 

107@dataclass 

108class AgentStatusSnapshot: 

109 """Agent 运行状态快照。""" 

110 

111 agent_id: str = "" 

112 agent_name: str = "" 

113 status: str = "idle" # idle | running | waiting_approval | paused | error 

114 current_task: str = "" 

115 elapsed_seconds: float = 0.0 

116 pending_approvals: int = 0 

117 memory_fragments: int = 0 

118 last_error: str = "" 

119 

120 

121# ── Approval Queue ───────────────────────── 

122 

123 

124class ApprovalQueue: 

125 """线程安全的审批队列,支持 WebSocket 推送通知。 

126 

127 在 Gradio UI 与 Agent HITL 引擎之间架设实时通信桥梁。 

128 """ 

129 

130 def __init__(self, max_size: int = 100, default_timeout: float = 300.0): 

131 self._queue: queue.Queue = queue.Queue(maxsize=max_size) 

132 self._pending: Dict[str, ApprovalRequestUI] = {} 

133 self._history: List[ApprovalHistory] = [] 

134 self._subscribers: List[Callable] = [] # WebSocket callbacks 

135 self._default_timeout = default_timeout 

136 self._lock = threading.Lock() 

137 

138 def submit(self, request: ApprovalRequestUI) -> str: 

139 """提交审批请求。注册到队列并通知订阅者。""" 

140 if request.expires_at <= 0: 

141 request.expires_at = time.time() + self._default_timeout 

142 

143 with self._lock: 

144 self._pending[request.request_id] = request 

145 

146 self._notify_subscribers({ 

147 "event": "new_request", 

148 "request_id": request.request_id, 

149 "action": request.action, 

150 "risk_level": request.risk_level.value, 

151 "pending_count": len(self._pending), 

152 }) 

153 

154 return request.request_id 

155 

156 def decide(self, request_id: str, approved: bool, reason: str = "") -> Optional[ApprovalHistory]: 

157 """处理审批决定。""" 

158 with self._lock: 

159 request = self._pending.pop(request_id, None) 

160 

161 if request is None: 

162 return None 

163 

164 decision = ApprovalStatus.APPROVED if approved else ApprovalStatus.DENIED 

165 history = ApprovalHistory( 

166 request=request, 

167 decision=decision, 

168 reason=reason, 

169 ) 

170 

171 # Trigger callback 

172 if request.on_decision: 

173 try: 

174 request.on_decision(approved, reason) 

175 except Exception: 

176 pass 

177 

178 with self._lock: 

179 self._history.append(history) 

180 

181 self._notify_subscribers({ 

182 "event": "decision", 

183 "request_id": request_id, 

184 "approved": approved, 

185 "reason": reason, 

186 "pending_count": len(self._pending), 

187 }) 

188 

189 return history 

190 

191 def approve_all(self) -> int: 

192 """批量批准所有待处理请求。""" 

193 ids = list(self._pending.keys()) 

194 for rid in ids: 

195 self.decide(rid, True, "batch_approve") 

196 return len(ids) 

197 

198 def deny_all(self) -> int: 

199 """批量拒绝所有待处理请求。""" 

200 ids = list(self._pending.keys()) 

201 for rid in ids: 

202 self.decide(rid, False, "batch_deny") 

203 return len(ids) 

204 

205 def check_timeouts(self) -> int: 

206 """检查并自动拒绝超时请求。""" 

207 now = time.time() 

208 expired_ids = [] 

209 with self._lock: 

210 for rid, req in self._pending.items(): 

211 if req.is_expired: 

212 expired_ids.append(rid) 

213 

214 for rid in expired_ids: 

215 history = self.decide(rid, False, "timeout") 

216 if history: 

217 history.decided_by = "timeout" 

218 

219 return len(expired_ids) 

220 

221 @property 

222 def pending_requests(self) -> List[ApprovalRequestUI]: 

223 with self._lock: 

224 return list(self._pending.values()) 

225 

226 @property 

227 def pending_count(self) -> int: 

228 with self._lock: 

229 return len(self._pending) 

230 

231 @property 

232 def recent_history(self, limit: int = 50) -> List[ApprovalHistory]: 

233 with self._lock: 

234 return self._history[-limit:] 

235 

236 def subscribe(self, callback: Callable) -> None: 

237 """注册 WebSocket 通知回调。""" 

238 self._subscribers.append(callback) 

239 

240 def _notify_subscribers(self, data: dict) -> None: 

241 for cb in self._subscribers: 

242 try: 

243 cb(data) 

244 except Exception: 

245 pass 

246 

247 

248# ── Gradio Approval Dashboard ────────────── 

249 

250 

251class ApprovalDashboard: 

252 """Gradio 驱动的审批面板。 

253 

254 核心布局: 

255 - 顶部: Agent 状态栏 

256 - 左侧: 待审批队列 

257 - 右侧: 审批详情 + 历史 

258 - 底部: 批量操作按钮 

259 

260 Usage: 

261 dashboard = ApprovalDashboard(queue, port=7860) 

262 dashboard.launch() 

263 """ 

264 

265 def __init__( 

266 self, 

267 approval_queue: ApprovalQueue, 

268 port: int = 7860, 

269 title: str = "AgentOS — HITL Approval Dashboard", 

270 theme: str = "soft", 

271 auto_launch: bool = True, 

272 ): 

273 self._queue = approval_queue 

274 self._port = port 

275 self._title = title 

276 self._theme = theme 

277 self._auto_launch = auto_launch 

278 self._app = None 

279 self._agent_statuses: Dict[str, AgentStatusSnapshot] = {} 

280 self._selected_request_id: str = "" 

281 

282 def launch(self, share: bool = False) -> Any: 

283 """启动 Gradio 面板。 

284 

285 返回 Gradio Blocks 实例,可在 Jupyter 中内嵌或独立运行。 

286 """ 

287 try: 

288 import gradio as gr 

289 except ImportError: 

290 raise ImportError( 

291 "Gradio is required for ApprovalDashboard. " 

292 "Install with: pip install gradio>=4.0" 

293 ) 

294 

295 with gr.Blocks(title=self._title, theme=self._theme) as app: 

296 self._app = app 

297 self._build_ui(app) 

298 

299 if self._auto_launch: 

300 app.launch( 

301 server_port=self._port, 

302 share=share, 

303 prevent_thread_lock=False, 

304 ) 

305 

306 return app 

307 

308 def _build_ui(self, app: Any) -> None: 

309 """构建完整 UI 布局。""" 

310 import gradio as gr 

311 

312 # ── Header ── 

313 gr.Markdown( 

314 f"# {self._title}\n" 

315 f"### Real-time Human-in-the-Loop Approval Dashboard" 

316 ) 

317 

318 # ── Agent Status Bar ── 

319 with gr.Row(): 

320 self._agent_status_display = gr.HTML( 

321 value=self._render_agent_status_bar(), 

322 every=3.0, 

323 ) 

324 

325 # ── Main Layout ── 

326 with gr.Row(): 

327 # Left: Pending Queue 

328 with gr.Column(scale=1): 

329 gr.Markdown("### Pending Approvals") 

330 self._queue_list = gr.HTML( 

331 value=self._render_pending_queue(), 

332 every=2.0, 

333 ) 

334 with gr.Row(): 

335 self._btn_approve_all = gr.Button( 

336 "Approve All", variant="primary", size="sm" 

337 ) 

338 self._btn_deny_all = gr.Button( 

339 "Deny All", variant="stop", size="sm" 

340 ) 

341 

342 # Right: Detail + History 

343 with gr.Column(scale=2): 

344 with gr.Tabs(): 

345 with gr.TabItem("Approval Detail"): 

346 self._detail_view = gr.HTML( 

347 value="<p>Select a request from the queue...</p>" 

348 ) 

349 with gr.Row(): 

350 self._btn_approve = gr.Button( 

351 "Approve", variant="primary" 

352 ) 

353 self._btn_deny = gr.Button( 

354 "Deny", variant="stop" 

355 ) 

356 self._reason_input = gr.Textbox( 

357 label="Reason (optional)", 

358 placeholder="Why this decision...", 

359 ) 

360 

361 with gr.TabItem("History"): 

362 self._history_view = gr.HTML( 

363 value=self._render_history(), 

364 every=3.0, 

365 ) 

366 

367 with gr.TabItem("Policy"): 

368 self._policy_view = gr.HTML( 

369 value=self._render_policy_editor() 

370 ) 

371 

372 # ── Event Handlers ── 

373 self._btn_approve.click( 

374 fn=self._handle_approve, 

375 inputs=[self._reason_input], 

376 outputs=[self._detail_view, self._queue_list, self._history_view], 

377 ) 

378 self._btn_deny.click( 

379 fn=self._handle_deny, 

380 inputs=[self._reason_input], 

381 outputs=[self._detail_view, self._queue_list, self._history_view], 

382 ) 

383 self._btn_approve_all.click( 

384 fn=lambda: self._queue.approve_all(), 

385 outputs=[], 

386 ) 

387 self._btn_deny_all.click( 

388 fn=lambda: self._queue.deny_all(), 

389 outputs=[], 

390 ) 

391 

392 def _handle_approve(self, reason: str) -> Tuple[str, str, str]: 

393 if not self._selected_request_id: 

394 return self._detail_view, self._queue_list, self._history_view 

395 self._queue.decide(self._selected_request_id, True, reason) 

396 self._selected_request_id = "" 

397 return ( 

398 "<p>Select a request from the queue...</p>", 

399 self._render_pending_queue(), 

400 self._render_history(), 

401 ) 

402 

403 def _handle_deny(self, reason: str) -> Tuple[str, str, str]: 

404 if not self._selected_request_id: 

405 return self._detail_view, self._queue_list, self._history_view 

406 self._queue.decide(self._selected_request_id, False, reason) 

407 self._selected_request_id = "" 

408 return ( 

409 "<p>Select a request from the queue...</p>", 

410 self._render_pending_queue(), 

411 self._render_history(), 

412 ) 

413 

414 def update_agent_status(self, snapshot: AgentStatusSnapshot) -> None: 

415 """更新 Agent 状态(由外部 Agent loop 调用)。""" 

416 self._agent_statuses[snapshot.agent_id] = snapshot 

417 

418 def _render_agent_status_bar(self) -> str: 

419 """渲染 Agent 状态栏 HTML。""" 

420 if not self._agent_statuses: 

421 return ( 

422 '<div style="padding:12px;background:#f0f0f0;border-radius:8px;">' 

423 '<span style="color:#888;">No agents connected</span></div>' 

424 ) 

425 

426 rows = [] 

427 for agent_id, snap in self._agent_statuses.items(): 

428 status_color = { 

429 "idle": "#4CAF50", 

430 "running": "#2196F3", 

431 "waiting_approval": "#FF9800", 

432 "paused": "#9E9E9E", 

433 "error": "#F44336", 

434 }.get(snap.status, "#9E9E9E") 

435 

436 rows.append( 

437 f'<div style="display:inline-block;margin:4px 8px;padding:8px 12px;' 

438 f'background:#fff;border-radius:6px;border-left:4px solid {status_color};">' 

439 f'<b>{snap.agent_name}</b> ' 

440 f'<span style="color:{status_color};">● {snap.status}</span> ' 

441 f'| {snap.current_task[:30]} ' 

442 f'| {snap.pending_approvals} pending' 

443 f'</div>' 

444 ) 

445 

446 return ( 

447 '<div style="padding:12px;background:#f0f0f0;border-radius:8px;">' 

448 + "".join(rows) 

449 + "</div>" 

450 ) 

451 

452 def _render_pending_queue(self) -> str: 

453 """渲染待处理队列 HTML。""" 

454 pending = self._queue.pending_requests 

455 if not pending: 

456 return '<p style="color:#888;">No pending approvals</p>' 

457 

458 risk_colors = { 

459 "safe": "#4CAF50", 

460 "low": "#8BC34A", 

461 "medium": "#FF9800", 

462 "high": "#F44336", 

463 "critical": "#B71C1C", 

464 } 

465 

466 cards = [] 

467 for req in pending: 

468 color = risk_colors.get(req.risk_level.value, "#999") 

469 elapsed = int(req.elapsed_seconds) 

470 cards.append( 

471 f'<div onclick="selectRequest(\'{req.request_id}\')" ' 

472 f'style="cursor:pointer;margin:6px 0;padding:10px;' 

473 f'background:#fff;border-radius:6px;' 

474 f'border-left:4px solid {color};">' 

475 f'<div style="font-weight:bold;">{req.action[:60]}</div>' 

476 f'<div style="color:{color};font-size:0.85em;">' 

477 f'Risk: {req.risk_level.value} | Agent: {req.agent_name} | ' 

478 f'{elapsed}s ago</div>' 

479 f'</div>' 

480 ) 

481 

482 return "".join(cards) 

483 

484 def _render_history(self) -> str: 

485 """渲染审批历史。""" 

486 history = self._queue.recent_history(limit=30) 

487 if not history: 

488 return "<p>No history yet</p>" 

489 

490 rows = ["<table style='width:100%;border-collapse:collapse;'>"] 

491 rows.append( 

492 "<tr style='background:#eee;'><th>Time</th><th>Action</th>" 

493 "<th>Decision</th><th>By</th><th>Latency</th></tr>" 

494 ) 

495 for h in reversed(history): 

496 dt = datetime.fromtimestamp(h.decided_at).strftime("%H:%M:%S") 

497 decision_color = "#4CAF50" if h.decision == ApprovalStatus.APPROVED else "#F44336" 

498 rows.append( 

499 f"<tr><td>{dt}</td>" 

500 f"<td>{h.request.action[:40]}</td>" 

501 f"<td style='color:{decision_color};font-weight:bold;'>" 

502 f"{h.decision.value}</td>" 

503 f"<td>{h.decided_by}</td>" 

504 f"<td>{h.decided_at - h.request.created_at:.1f}s</td></tr>" 

505 ) 

506 rows.append("</table>") 

507 return "".join(rows) 

508 

509 def _render_policy_editor(self) -> str: 

510 """渲染策略编辑器(占位,可扩展为交互式表单)。""" 

511 return """ 

512 <div style="padding:16px;"> 

513 <h3>Approval Policy</h3> 

514 <p>Configure auto-approval thresholds by risk level:</p> 

515 <ul> 

516 <li><b>Safe/Low:</b> Auto-approve</li> 

517 <li><b>Medium:</b> Ask if confidence &lt; 90%</li> 

518 <li><b>High:</b> Always ask</li> 

519 <li><b>Critical:</b> Always ask + require 2FA</li> 

520 </ul> 

521 <p><i>Interactive policy editor coming in v1.14.3</i></p> 

522 </div> 

523 """ 

524 

525 @property 

526 def queue(self) -> ApprovalQueue: 

527 return self._queue 

528 

529 

530# ── Agent Integration Bridge ──────────────── 

531 

532 

533class HITLUIBridge: 

534 """连接 Agent HITL 引擎与 Gradio UI 的桥梁。 

535 

536 在 Agent loop 中使用: 

537 bridge = HITLUIBridge(queue, agent_id) 

538 bridge.send_approval_request(action="Delete file X", risk_level="high") 

539 # UI 弹出审批卡片,Agent 在此阻塞等待结果 

540 approved = await bridge.wait_for_decision(timeout=60) 

541 """ 

542 

543 def __init__( 

544 self, 

545 approval_queue: ApprovalQueue, 

546 agent_id: str = "", 

547 agent_name: str = "", 

548 ): 

549 self._queue = approval_queue 

550 self.agent_id = agent_id 

551 self.agent_name = agent_name 

552 self._decision_events: Dict[str, asyncio.Event] = {} 

553 self._decision_results: Dict[str, Tuple[bool, str]] = {} 

554 

555 async def send_approval_request( 

556 self, 

557 action: str, 

558 details: str = "", 

559 risk_level: str = "medium", 

560 source_file: str = "", 

561 timeout: float = 300.0, 

562 metadata: Optional[Dict[str, Any]] = None, 

563 ) -> Tuple[bool, str]: 

564 """发送审批请求到 UI,阻塞等待用户决定。 

565 

566 Returns: 

567 (approved: bool, reason: str) 

568 """ 

569 event = asyncio.Event() 

570 request_id = f"apr-{uuid.uuid4().hex[:8]}" 

571 self._decision_events[request_id] = event 

572 

573 def on_decision(approved: bool, reason: str) -> None: 

574 self._decision_results[request_id] = (approved, reason) 

575 event.set() 

576 

577 request = ApprovalRequestUI( 

578 request_id=request_id, 

579 agent_name=self.agent_name, 

580 action=action, 

581 details=details, 

582 risk_level=RiskLevelUI(risk_level), 

583 expires_at=time.time() + timeout, 

584 source_file=source_file, 

585 metadata=metadata or {}, 

586 on_decision=on_decision, 

587 ) 

588 

589 self._queue.submit(request) 

590 

591 # 等待用户决定或超时 

592 try: 

593 await asyncio.wait_for(event.wait(), timeout=timeout) 

594 result = self._decision_results.pop(request_id, (False, "timeout")) 

595 self._decision_events.pop(request_id, None) 

596 return result 

597 except asyncio.TimeoutError: 

598 self._queue.decide(request_id, False, "timeout") 

599 self._decision_events.pop(request_id, None) 

600 return (False, "timeout") 

601 

602 

603# ── Quick Launch ──────────────────────────── 

604 

605 

606def create_hitl_dashboard( 

607 port: int = 7860, 

608 theme: str = "soft", 

609 share: bool = False, 

610) -> Tuple[ApprovalDashboard, ApprovalQueue]: 

611 """一键创建并启动 HITL 审批面板。 

612 

613 Usage: 

614 dashboard, queue = create_hitl_dashboard(port=7860) 

615 # Agent 代码中: 

616 bridge = HITLUIBridge(queue, agent_name="FileAgent") 

617 approved, reason = await bridge.send_approval_request( 

618 action="Delete 50 files in /tmp/", 

619 risk_level="high", 

620 ) 

621 """ 

622 queue = ApprovalQueue() 

623 dashboard = ApprovalDashboard( 

624 approval_queue=queue, 

625 port=port, 

626 theme=theme, 

627 auto_launch=True, 

628 ) 

629 

630 # 在后台线程启动 Gradio 

631 thread = threading.Thread( 

632 target=dashboard.launch, 

633 kwargs={"share": share}, 

634 daemon=True, 

635 ) 

636 thread.start() 

637 

638 return dashboard, queue