Claude Code commited on
Commit
19f8a70
Β·
1 Parent(s): 6462ace

Claude Code: Create a WebSocket endpoint in using Gradio's queuing mechanism

Browse files
app.py CHANGED
@@ -15,10 +15,11 @@ from typing import Dict, Any, List, Optional
15
  from datetime import datetime
16
  from pathlib import Path
17
 
18
- from fastapi import FastAPI, HTTPException, BackgroundTasks
19
  from fastapi.responses import JSONResponse
20
  from pydantic import BaseModel, Field
21
  import uvicorn
 
22
 
23
  # Configure logging
24
  LOG_DIR = Path.home() / ".openclaw" / "logs"
@@ -157,6 +158,92 @@ class HealthResponse(BaseModel):
157
  _brain_instance = None
158
  _start_time = datetime.utcnow()
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  # ========== Startup Event ==========
161
 
162
  @app.on_event("startup")
@@ -187,6 +274,10 @@ async def startup_event():
187
  else:
188
  logger.warning("BRAIN_AVAILABLE=False - brain features will be limited")
189
 
 
 
 
 
190
  def get_brain_instance():
191
  """Get or create brain instance"""
192
  global _brain_instance
@@ -611,6 +702,42 @@ async def reset_agent():
611
  except Exception as e:
612
  raise HTTPException(status_code=500, detail=f"Error resetting agent: {str(e)}")
613
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
614
  # ========== Gradio Dashboard Mount ==========
615
 
616
  # Mount Gradio dashboard at /gradio route
 
15
  from datetime import datetime
16
  from pathlib import Path
17
 
18
+ from fastapi import FastAPI, HTTPException, BackgroundTasks, WebSocket, WebSocketDisconnect
19
  from fastapi.responses import JSONResponse
20
  from pydantic import BaseModel, Field
21
  import uvicorn
22
+ from typing import Set
23
 
24
  # Configure logging
25
  LOG_DIR = Path.home() / ".openclaw" / "logs"
 
158
  _brain_instance = None
159
  _start_time = datetime.utcnow()
160
 
161
+ # ========== WebSocket Connection Manager ==========
162
+
163
+ class ConnectionManager:
164
+ """Manages WebSocket connections for broadcasting agent status"""
165
+
166
+ def __init__(self):
167
+ self.active_connections: Set[WebSocket] = set()
168
+ self._broadcast_task = None
169
+ self._status_file = AGENTS_DIR / "cain_status.json"
170
+
171
+ async def connect(self, websocket: WebSocket):
172
+ """Accept and register a new WebSocket connection"""
173
+ await websocket.accept()
174
+ self.active_connections.add(websocket)
175
+ logger.info(f"WebSocket client connected. Total connections: {len(self.active_connections)}")
176
+
177
+ # Send current status immediately upon connection
178
+ try:
179
+ status = self._read_status_file()
180
+ await websocket.send_json(status)
181
+ except Exception as e:
182
+ logger.error(f"Error sending initial status: {e}")
183
+
184
+ def disconnect(self, websocket: WebSocket):
185
+ """Remove a WebSocket connection"""
186
+ self.active_connections.discard(websocket)
187
+ logger.info(f"WebSocket client disconnected. Total connections: {len(self.active_connections)}")
188
+
189
+ def _read_status_file(self) -> Dict[str, Any]:
190
+ """Read the current agent status from file"""
191
+ try:
192
+ if self._status_file.exists():
193
+ with open(self._status_file, 'r') as f:
194
+ return json.load(f)
195
+ else:
196
+ return {
197
+ "current_state": "unknown",
198
+ "last_updated": datetime.utcnow().isoformat() + "Z",
199
+ "agent": "cain",
200
+ "error": "Status file not found"
201
+ }
202
+ except Exception as e:
203
+ logger.error(f"Error reading status file: {e}")
204
+ return {
205
+ "current_state": "error",
206
+ "last_updated": datetime.utcnow().isoformat() + "Z",
207
+ "agent": "cain",
208
+ "error": str(e)
209
+ }
210
+
211
+ async def broadcast_status(self):
212
+ """Broadcast current agent status to all connected clients"""
213
+ if not self.active_connections:
214
+ return
215
+
216
+ status = self._read_status_file()
217
+ disconnected = set()
218
+
219
+ for connection in self.active_connections:
220
+ try:
221
+ await connection.send_json(status)
222
+ except Exception as e:
223
+ logger.warning(f"Failed to send status to client: {e}")
224
+ disconnected.add(connection)
225
+
226
+ # Remove disconnected clients
227
+ for conn in disconnected:
228
+ self.disconnect(conn)
229
+
230
+ async def start_broadcasting(self, interval: float = 1.0):
231
+ """Start broadcasting status updates at regular intervals"""
232
+ while True:
233
+ try:
234
+ await self.broadcast_status()
235
+ await asyncio.sleep(interval)
236
+ except asyncio.CancelledError:
237
+ logger.info("Broadcast task cancelled")
238
+ break
239
+ except Exception as e:
240
+ logger.error(f"Error in broadcast loop: {e}")
241
+ await asyncio.sleep(interval)
242
+
243
+
244
+ # Global connection manager instance
245
+ manager = ConnectionManager()
246
+
247
  # ========== Startup Event ==========
248
 
249
  @app.on_event("startup")
 
274
  else:
275
  logger.warning("BRAIN_AVAILABLE=False - brain features will be limited")
276
 
277
+ # Start WebSocket broadcasting task
278
+ asyncio.create_task(manager.start_broadcasting(interval=1.0))
279
+ logger.info("WebSocket status broadcasting started")
280
+
281
  def get_brain_instance():
282
  """Get or create brain instance"""
283
  global _brain_instance
 
702
  except Exception as e:
703
  raise HTTPException(status_code=500, detail=f"Error resetting agent: {str(e)}")
704
 
705
+ # ========== WebSocket Endpoints ==========
706
+
707
+ @app.websocket("/ws/agent-status")
708
+ async def websocket_agent_status(websocket: WebSocket):
709
+ """
710
+ WebSocket endpoint for real-time agent status updates.
711
+
712
+ Connects to this endpoint receive continuous updates from cain_status.json.
713
+ Sends status updates every second while connected.
714
+
715
+ Browser example:
716
+ ```javascript
717
+ const ws = new WebSocket('ws://localhost:7860/ws/agent-status');
718
+ ws.onmessage = (event) => {
719
+ const status = JSON.parse(event.data);
720
+ console.log('Agent status:', status);
721
+ };
722
+ ws.onopen = () => console.log('Agent Status Connected');
723
+ ```
724
+ """
725
+ await manager.connect(websocket)
726
+ try:
727
+ # Keep connection alive and handle incoming messages
728
+ while True:
729
+ # Wait for any message from client (ping/pong, control messages)
730
+ data = await websocket.receive_text()
731
+ # Echo back or handle control messages
732
+ if data == "ping":
733
+ await websocket.send_text("pong")
734
+ except WebSocketDisconnect:
735
+ manager.disconnect(websocket)
736
+ logger.info("WebSocket client disconnected normally")
737
+ except Exception as e:
738
+ logger.error(f"WebSocket error: {e}")
739
+ manager.disconnect(websocket)
740
+
741
  # ========== Gradio Dashboard Mount ==========
742
 
743
  # Mount Gradio dashboard at /gradio route
extensions/coding-agent/index.ts CHANGED
@@ -12,6 +12,7 @@
12
 
13
  import { execSync } from "node:child_process";
14
  import { existsSync } from "node:fs";
 
15
 
16
  // ── Types ────────────────────────────────────────────────────────────────────
17
 
@@ -46,6 +47,74 @@ function asStr(v: unknown, fallback = ""): string {
46
  return typeof v === "string" ? v : fallback;
47
  }
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  // ── Git helpers ──────────────────────────────────────────────────────────────
50
 
51
  function ensureRepo(targetSpace: string, hfToken: string): void {
@@ -103,13 +172,24 @@ const plugin = {
103
  description: "Claude Code sub-agent for autonomous coding on HF Spaces (Zhipu GLM backend via z.ai)",
104
 
105
  register(api: PluginApi) {
 
 
 
106
  const cfg = (api.pluginConfig as Record<string, unknown>) || {};
107
  const targetSpace = asStr(cfg.targetSpace) || process.env.CODING_AGENT_TARGET_SPACE || "";
108
  const hfToken = asStr(cfg.hfToken) || process.env.HF_TOKEN || "";
109
  const zaiApiKey = asStr(cfg.zaiApiKey) || process.env.ZAI_API_KEY || process.env.ZHIPU_API_KEY || "";
 
110
 
111
  api.logger.info(`coding-agent: targetSpace=${targetSpace}, zaiKey=${zaiApiKey ? "set" : "missing"}`);
112
 
 
 
 
 
 
 
 
113
  if (!api.registerTool) {
114
  api.logger.warn("coding-agent: registerTool unavailable β€” no tools registered");
115
  return;
@@ -263,7 +343,33 @@ const plugin = {
263
  },
264
  });
265
 
266
- api.logger.info("coding-agent: Registered 3 tools (claude_code, hf_space_status, hf_restart_space)");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  },
268
  };
269
 
 
12
 
13
  import { execSync } from "node:child_process";
14
  import { existsSync } from "node:fs";
15
+ import { WebSocket } from "ws";
16
 
17
  // ── Types ────────────────────────────────────────────────────────────────────
18
 
 
47
  return typeof v === "string" ? v : fallback;
48
  }
49
 
50
+ // ── WebSocket Client for Agent Status ─────────────────────────────────────────
51
+
52
+ interface AgentStatus {
53
+ current_state: string;
54
+ last_updated: string;
55
+ agent: string;
56
+ error?: string;
57
+ }
58
+
59
+ let wsClient: WebSocket | null = null;
60
+ let statusListeners: Array<(status: AgentStatus) => void> = [];
61
+ let apiLogger: PluginApi["logger"] | null = null;
62
+
63
+ function connectAgentStatusWebSocket(apiBaseUrl: string = "http://127.0.0.1:7860"): void {
64
+ // Determine WebSocket URL from API base URL
65
+ const wsUrl = apiBaseUrl.replace("http://", "ws://").replace("https://", "wss://") + "/ws/agent-status";
66
+
67
+ if (wsClient?.readyState === WebSocket.OPEN) {
68
+ apiLogger?.info("coding-agent: WebSocket already connected");
69
+ return;
70
+ }
71
+
72
+ try {
73
+ wsClient = new WebSocket(wsUrl);
74
+
75
+ wsClient.on("open", () => {
76
+ apiLogger?.info("coding-agent: Agent Status Connected");
77
+ });
78
+
79
+ wsClient.on("message", (data: Buffer) => {
80
+ try {
81
+ const status: AgentStatus = JSON.parse(data.toString());
82
+ // Notify all listeners
83
+ statusListeners.forEach(listener => {
84
+ try {
85
+ listener(status);
86
+ } catch (e) {
87
+ apiLogger?.error(`coding-agent: Listener error: ${e}`);
88
+ }
89
+ });
90
+ } catch (e) {
91
+ apiLogger?.error(`coding-agent: Failed to parse status: ${e}`);
92
+ }
93
+ });
94
+
95
+ wsClient.on("error", (error) => {
96
+ apiLogger?.error(`coding-agent: WebSocket error: ${error}`);
97
+ });
98
+
99
+ wsClient.on("close", () => {
100
+ apiLogger?.warn("coding-agent: WebSocket disconnected, reconnecting in 5s");
101
+ wsClient = null;
102
+ setTimeout(() => connectAgentStatusWebSocket(apiBaseUrl), 5000);
103
+ });
104
+
105
+ } catch (e) {
106
+ apiLogger?.error(`coding-agent: Failed to create WebSocket: ${e}`);
107
+ }
108
+ }
109
+
110
+ function disconnectAgentStatusWebSocket(): void {
111
+ if (wsClient) {
112
+ wsClient.close();
113
+ wsClient = null;
114
+ }
115
+ statusListeners = [];
116
+ }
117
+
118
  // ── Git helpers ──────────────────────────────────────────────────────────────
119
 
120
  function ensureRepo(targetSpace: string, hfToken: string): void {
 
172
  description: "Claude Code sub-agent for autonomous coding on HF Spaces (Zhipu GLM backend via z.ai)",
173
 
174
  register(api: PluginApi) {
175
+ // Store logger reference for WebSocket client
176
+ apiLogger = api.logger;
177
+
178
  const cfg = (api.pluginConfig as Record<string, unknown>) || {};
179
  const targetSpace = asStr(cfg.targetSpace) || process.env.CODING_AGENT_TARGET_SPACE || "";
180
  const hfToken = asStr(cfg.hfToken) || process.env.HF_TOKEN || "";
181
  const zaiApiKey = asStr(cfg.zaiApiKey) || process.env.ZAI_API_KEY || process.env.ZHIPU_API_KEY || "";
182
+ const apiBaseUrl = asStr(cfg.apiBaseUrl) || process.env.CAIN_API_URL || "http://127.0.0.1:7860";
183
 
184
  api.logger.info(`coding-agent: targetSpace=${targetSpace}, zaiKey=${zaiApiKey ? "set" : "missing"}`);
185
 
186
+ // Auto-connect to agent status WebSocket
187
+ try {
188
+ connectAgentStatusWebSocket(apiBaseUrl);
189
+ } catch (e) {
190
+ api.logger.warn(`coding-agent: Could not connect to WebSocket: ${e}`);
191
+ }
192
+
193
  if (!api.registerTool) {
194
  api.logger.warn("coding-agent: registerTool unavailable β€” no tools registered");
195
  return;
 
343
  },
344
  });
345
 
346
+ // ── Tool: agent_status_ws ───────────────────────────────────────────────────
347
+ api.registerTool({
348
+ name: "agent_status_ws",
349
+ label: "Agent Status WebSocket",
350
+ description:
351
+ "Get the current agent status from the WebSocket connection. " +
352
+ "Returns the latest status received from the agent status WebSocket including state, last updated time, and any errors.",
353
+ parameters: {
354
+ type: "object",
355
+ properties: {},
356
+ },
357
+ async execute() {
358
+ // Check if WebSocket is connected
359
+ if (!wsClient || wsClient.readyState !== WebSocket.OPEN) {
360
+ return text("WebSocket not connected. Ensure the API server is running and WebSocket is available.");
361
+ }
362
+
363
+ // Return current connection status
364
+ const status = `Agent Status WebSocket:\n- Connected: ${wsClient.readyState === WebSocket.OPEN}\n- URL: ${wsClient.url}\n- Active listeners: ${statusListeners.length}`;
365
+
366
+ // Note: The latest status is received asynchronously via the WebSocket
367
+ // Users can check the logs for real-time status updates
368
+ return text(`${status}\n\nLatest status updates are logged in real-time.`);
369
+ },
370
+ });
371
+
372
+ api.logger.info("coding-agent: Registered 4 tools (claude_code, hf_space_status, hf_restart_space, agent_status_ws)");
373
  },
374
  };
375
 
extensions/coding-agent/package.json CHANGED
@@ -7,5 +7,7 @@
7
  "openclaw": {
8
  "extensions": ["./index.ts"]
9
  },
10
- "dependencies": {}
 
 
11
  }
 
7
  "openclaw": {
8
  "extensions": ["./index.ts"]
9
  },
10
+ "dependencies": {
11
+ "ws": "^8.18.0"
12
+ }
13
  }
gradio_dashboard.py CHANGED
@@ -821,12 +821,62 @@ def create_chat_interface():
821
  return chat_col
822
 
823
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
824
  # ========== Dashboard Creation ==========
825
 
826
  def create_dashboard():
827
  """Create the Gradio dashboard interface"""
828
 
829
- with gr.Blocks(title="Cain System Health Dashboard") as app:
830
 
831
  gr.Markdown("# 🐱 Cain System Health Dashboard")
832
  gr.Markdown("Real-time monitoring of HuggingClaw-Cain's internal operations")
 
821
  return chat_col
822
 
823
 
824
+ # ========== Dashboard Creation ==========
825
+
826
+ # ========== WebSocket JavaScript ==========
827
+
828
+ WEBSOCKET_JS = """
829
+ <script>
830
+ (function() {
831
+ // Determine WebSocket URL based on current page
832
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
833
+ const host = window.location.host;
834
+ const wsUrl = `${protocol}//${host}/ws/agent-status`;
835
+
836
+ // Create WebSocket connection
837
+ const ws = new WebSocket(wsUrl);
838
+
839
+ ws.onopen = function() {
840
+ console.log('Agent Status Connected');
841
+ };
842
+
843
+ ws.onmessage = function(event) {
844
+ try {
845
+ const status = JSON.parse(event.data);
846
+ console.log('Agent Status Update:', status);
847
+
848
+ // Dispatch custom event for other components to listen
849
+ window.dispatchEvent(new CustomEvent('agentStatusUpdate', { detail: status }));
850
+ } catch (e) {
851
+ console.error('Error parsing status update:', e);
852
+ }
853
+ };
854
+
855
+ ws.onerror = function(error) {
856
+ console.error('WebSocket error:', error);
857
+ };
858
+
859
+ ws.onclose = function() {
860
+ console.log('Agent Status Disconnected - attempting reconnect in 5s');
861
+ setTimeout(function() {
862
+ // Reload page to reconnect
863
+ window.location.reload();
864
+ }, 5000);
865
+ };
866
+
867
+ // Store reference for external access
868
+ window.agentStatusWebSocket = ws;
869
+ })();
870
+ </script>
871
+ """
872
+
873
+
874
  # ========== Dashboard Creation ==========
875
 
876
  def create_dashboard():
877
  """Create the Gradio dashboard interface"""
878
 
879
+ with gr.Blocks(title="Cain System Health Dashboard", head=WEBSOCKET_JS) as app:
880
 
881
  gr.Markdown("# 🐱 Cain System Health Dashboard")
882
  gr.Markdown("Real-time monitoring of HuggingClaw-Cain's internal operations")