openvoiceui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/.env.example +104 -0
  2. package/Dockerfile +30 -0
  3. package/LICENSE +21 -0
  4. package/README.md +638 -0
  5. package/SETUP.md +360 -0
  6. package/app.py +232 -0
  7. package/auto-approve-devices.js +111 -0
  8. package/cli/index.js +372 -0
  9. package/config/__init__.py +4 -0
  10. package/config/default.yaml +43 -0
  11. package/config/flags.yaml +67 -0
  12. package/config/loader.py +203 -0
  13. package/config/providers.yaml +71 -0
  14. package/config/speech_normalization.yaml +182 -0
  15. package/config/theme.json +4 -0
  16. package/data/greetings.json +25 -0
  17. package/default-pages/ai-image-creator.html +915 -0
  18. package/default-pages/bulk-image-uploader.html +492 -0
  19. package/default-pages/desktop.html +2865 -0
  20. package/default-pages/file-explorer.html +854 -0
  21. package/default-pages/interactive-map.html +655 -0
  22. package/default-pages/style-guide.html +1005 -0
  23. package/default-pages/website-setup.html +1623 -0
  24. package/deploy/openclaw/Dockerfile +46 -0
  25. package/deploy/openvoiceui.service +30 -0
  26. package/deploy/setup-nginx.sh +50 -0
  27. package/deploy/setup-sudo.sh +306 -0
  28. package/deploy/skill-runner/Dockerfile +19 -0
  29. package/deploy/skill-runner/requirements.txt +14 -0
  30. package/deploy/skill-runner/server.py +269 -0
  31. package/deploy/supertonic/Dockerfile +22 -0
  32. package/deploy/supertonic/server.py +79 -0
  33. package/docker-compose.pinokio.yml +11 -0
  34. package/docker-compose.yml +59 -0
  35. package/greetings.json +25 -0
  36. package/index.html +65 -0
  37. package/inject-device-identity.js +142 -0
  38. package/package.json +82 -0
  39. package/profiles/default.json +114 -0
  40. package/profiles/manager.py +354 -0
  41. package/profiles/schema.json +337 -0
  42. package/prompts/voice-system-prompt.md +149 -0
  43. package/providers/__init__.py +39 -0
  44. package/providers/base.py +63 -0
  45. package/providers/llm/__init__.py +12 -0
  46. package/providers/llm/base.py +71 -0
  47. package/providers/llm/clawdbot_provider.py +112 -0
  48. package/providers/llm/zai_provider.py +115 -0
  49. package/providers/registry.py +320 -0
  50. package/providers/stt/__init__.py +12 -0
  51. package/providers/stt/base.py +58 -0
  52. package/providers/stt/webspeech_provider.py +49 -0
  53. package/providers/stt/whisper_provider.py +100 -0
  54. package/providers/tts/__init__.py +20 -0
  55. package/providers/tts/base.py +91 -0
  56. package/providers/tts/groq_provider.py +74 -0
  57. package/providers/tts/supertonic_provider.py +72 -0
  58. package/requirements.txt +38 -0
  59. package/routes/__init__.py +10 -0
  60. package/routes/admin.py +515 -0
  61. package/routes/canvas.py +1315 -0
  62. package/routes/chat.py +51 -0
  63. package/routes/conversation.py +2158 -0
  64. package/routes/elevenlabs_hybrid.py +306 -0
  65. package/routes/greetings.py +98 -0
  66. package/routes/icons.py +279 -0
  67. package/routes/image_gen.py +364 -0
  68. package/routes/instructions.py +190 -0
  69. package/routes/music.py +838 -0
  70. package/routes/onboarding.py +43 -0
  71. package/routes/pi.py +62 -0
  72. package/routes/profiles.py +215 -0
  73. package/routes/report_issue.py +68 -0
  74. package/routes/static_files.py +533 -0
  75. package/routes/suno.py +664 -0
  76. package/routes/theme.py +81 -0
  77. package/routes/transcripts.py +199 -0
  78. package/routes/vision.py +348 -0
  79. package/routes/workspace.py +288 -0
  80. package/server.py +1510 -0
  81. package/services/__init__.py +1 -0
  82. package/services/auth.py +143 -0
  83. package/services/canvas_versioning.py +239 -0
  84. package/services/db_pool.py +107 -0
  85. package/services/gateway.py +16 -0
  86. package/services/gateway_manager.py +333 -0
  87. package/services/gateways/__init__.py +12 -0
  88. package/services/gateways/base.py +110 -0
  89. package/services/gateways/compat.py +264 -0
  90. package/services/gateways/openclaw.py +1134 -0
  91. package/services/health.py +100 -0
  92. package/services/memory_client.py +455 -0
  93. package/services/paths.py +26 -0
  94. package/services/speech_normalizer.py +285 -0
  95. package/services/tts.py +270 -0
  96. package/setup-config.js +262 -0
  97. package/sounds/air_horn.mp3 +0 -0
  98. package/sounds/bruh.mp3 +0 -0
  99. package/sounds/crowd_cheer.mp3 +0 -0
  100. package/sounds/gunshot.mp3 +0 -0
  101. package/sounds/impact.mp3 +0 -0
  102. package/sounds/lets_go.mp3 +0 -0
  103. package/sounds/record_stop.mp3 +0 -0
  104. package/sounds/rewind.mp3 +0 -0
  105. package/sounds/sad_trombone.mp3 +0 -0
  106. package/sounds/scratch_long.mp3 +0 -0
  107. package/sounds/yeah.mp3 +0 -0
  108. package/src/adapters/ClawdBotAdapter.js +264 -0
  109. package/src/adapters/_template.js +133 -0
  110. package/src/adapters/elevenlabs-classic.js +841 -0
  111. package/src/adapters/elevenlabs-hybrid.js +812 -0
  112. package/src/adapters/hume-evi.js +676 -0
  113. package/src/admin.html +1339 -0
  114. package/src/app.js +8802 -0
  115. package/src/core/Config.js +173 -0
  116. package/src/core/EmotionEngine.js +307 -0
  117. package/src/core/EventBridge.js +180 -0
  118. package/src/core/EventBus.js +117 -0
  119. package/src/core/VoiceSession.js +607 -0
  120. package/src/face/BaseFace.js +259 -0
  121. package/src/face/EyeFace.js +208 -0
  122. package/src/face/HaloSmokeFace.js +509 -0
  123. package/src/face/manifest.json +27 -0
  124. package/src/face/previews/eyes.svg +16 -0
  125. package/src/face/previews/orb.svg +29 -0
  126. package/src/features/MusicPlayer.js +620 -0
  127. package/src/features/Soundboard.js +128 -0
  128. package/src/providers/DeepgramSTT.js +472 -0
  129. package/src/providers/DeepgramStreamingSTT.js +766 -0
  130. package/src/providers/GroqSTT.js +559 -0
  131. package/src/providers/TTSPlayer.js +323 -0
  132. package/src/providers/WebSpeechSTT.js +479 -0
  133. package/src/providers/tts/BaseTTSProvider.js +81 -0
  134. package/src/providers/tts/HumeProvider.js +77 -0
  135. package/src/providers/tts/SupertonicProvider.js +174 -0
  136. package/src/providers/tts/index.js +140 -0
  137. package/src/shell/adapter-registry.js +154 -0
  138. package/src/shell/caller-bridge.js +35 -0
  139. package/src/shell/camera-bridge.js +28 -0
  140. package/src/shell/canvas-bridge.js +32 -0
  141. package/src/shell/commercial-bridge.js +44 -0
  142. package/src/shell/face-bridge.js +44 -0
  143. package/src/shell/music-bridge.js +60 -0
  144. package/src/shell/orchestrator.js +233 -0
  145. package/src/shell/profile-discovery.js +303 -0
  146. package/src/shell/sounds-bridge.js +28 -0
  147. package/src/shell/transcript-bridge.js +61 -0
  148. package/src/shell/waveform-bridge.js +33 -0
  149. package/src/styles/base.css +2862 -0
  150. package/src/styles/face.css +417 -0
  151. package/src/styles/pi-overrides.css +89 -0
  152. package/src/styles/theme-dark.css +67 -0
  153. package/src/test-tts.html +175 -0
  154. package/src/ui/AppShell.js +544 -0
  155. package/src/ui/ProfileSwitcher.js +228 -0
  156. package/src/ui/SessionControl.js +240 -0
  157. package/src/ui/face/FacePicker.js +195 -0
  158. package/src/ui/face/FaceRenderer.js +309 -0
  159. package/src/ui/settings/PlaylistEditor.js +366 -0
  160. package/src/ui/settings/SettingsPanel.css +684 -0
  161. package/src/ui/settings/SettingsPanel.js +419 -0
  162. package/src/ui/settings/TTSVoicePreview.js +210 -0
  163. package/src/ui/themes/ThemeManager.js +213 -0
  164. package/src/ui/visualizers/BaseVisualizer.js +29 -0
  165. package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
  166. package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
  167. package/static/emulators/jsdos/js-dos.css +1 -0
  168. package/static/emulators/jsdos/js-dos.js +22 -0
  169. package/static/favicon.svg +55 -0
  170. package/static/icons/apple-touch-icon.png +0 -0
  171. package/static/icons/favicon-32.png +0 -0
  172. package/static/icons/icon-192.png +0 -0
  173. package/static/icons/icon-512.png +0 -0
  174. package/static/install.html +449 -0
  175. package/static/manifest.json +26 -0
  176. package/static/sw.js +21 -0
  177. package/tts_providers/__init__.py +136 -0
  178. package/tts_providers/base_provider.py +319 -0
  179. package/tts_providers/groq_provider.py +155 -0
  180. package/tts_providers/hume_provider.py +226 -0
  181. package/tts_providers/providers_config.json +119 -0
  182. package/tts_providers/qwen3_provider.py +371 -0
  183. package/tts_providers/resemble_provider.py +315 -0
  184. package/tts_providers/supertonic_provider.py +557 -0
  185. package/tts_providers/supertonic_tts.py +399 -0
@@ -0,0 +1,515 @@
1
+ """
2
+ routes/admin.py — Admin API Blueprint (P2-T6)
3
+
4
+ Provides two groups of endpoints:
5
+
6
+ 1. Gateway RPC Proxy — send one-shot RPC calls to the OpenClaw Gateway
7
+ POST /api/admin/gateway/rpc — proxy any RPC method
8
+ GET /api/admin/gateway/status — ping gateway (connect + disconnect)
9
+
10
+ 2. Refactor Monitoring — read-only views of refactor-state/ files
11
+ GET /api/refactor/status — playbook-state.json (all task statuses)
12
+ GET /api/refactor/activity — last 50 entries from activity-log.jsonl
13
+ GET /api/refactor/metrics — metrics.json
14
+ POST /api/refactor/control — pause / resume / skip a task
15
+ GET /api/server-stats — CPU, RAM, disk, uptime (psutil)
16
+
17
+ Ref: Canvas Section 11 (OpenClaw Integration), P2-T6 spec, ADR-005 (header versioning)
18
+ """
19
+
20
+ import asyncio
21
+ import json
22
+ import logging
23
+ import os
24
+ import time
25
+ import uuid
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+
29
+ import psutil
30
+ import websockets
31
+ from flask import Blueprint, jsonify, request
32
+
33
+ from services.gateways.compat import (
34
+ build_connect_params, is_challenge_event,
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ admin_bp = Blueprint('admin', __name__)
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Paths
43
+ # ---------------------------------------------------------------------------
44
+
45
+ _PROJECT_ROOT = Path(__file__).parent.parent
46
+ REFACTOR_STATE_DIR = _PROJECT_ROOT / 'refactor-state'
47
+ PLAYBOOK_STATE_PATH = REFACTOR_STATE_DIR / 'playbook-state.json'
48
+ ACTIVITY_LOG_PATH = REFACTOR_STATE_DIR / 'activity-log.jsonl'
49
+ METRICS_PATH = REFACTOR_STATE_DIR / 'metrics.json'
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Gateway RPC helper
53
+ # ---------------------------------------------------------------------------
54
+
55
+ GATEWAY_URL = os.getenv('CLAWDBOT_GATEWAY_URL', 'ws://127.0.0.1:18791')
56
+ GATEWAY_AUTH_TOKEN = None # read at call time so env changes propagate
57
+
58
+
59
+ def _get_auth_token() -> str | None:
60
+ return os.getenv('CLAWDBOT_AUTH_TOKEN')
61
+
62
+
63
+ async def _gateway_rpc(method: str, params: dict, timeout: float = 10.0) -> dict:
64
+ """
65
+ Connect to Gateway, handshake, send one RPC request, return the response.
66
+
67
+ Returns a dict with:
68
+ {"ok": True, "result": <response payload>}
69
+ or
70
+ {"ok": False, "error": <message>}
71
+ """
72
+ auth_token = _get_auth_token()
73
+ if not auth_token:
74
+ return {"ok": False, "error": "CLAWDBOT_AUTH_TOKEN not set"}
75
+
76
+ try:
77
+ async with websockets.connect(GATEWAY_URL, open_timeout=timeout) as ws:
78
+ # Step 1 — receive challenge
79
+ raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
80
+ challenge = json.loads(raw)
81
+ if not is_challenge_event(challenge):
82
+ return {"ok": False, "error": f"Unexpected greeting: {challenge}"}
83
+
84
+ # Step 2 — send connect request
85
+ req_id = str(uuid.uuid4())
86
+ params = build_connect_params(
87
+ auth_token=auth_token,
88
+ client_id="cli",
89
+ client_mode="cli",
90
+ platform="linux",
91
+ user_agent="openvoice-ui-admin/1.0.0",
92
+ caps=[],
93
+ )
94
+ await ws.send(json.dumps({
95
+ "type": "req",
96
+ "id": f"connect-{req_id}",
97
+ "method": "connect",
98
+ "params": params,
99
+ }))
100
+
101
+ # Step 3 — receive hello
102
+ raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
103
+ hello = json.loads(raw)
104
+ if hello.get('type') != 'res' or hello.get('error'):
105
+ return {"ok": False, "error": f"Gateway auth failed: {hello.get('error')}"}
106
+
107
+ # Step 4 — send the actual RPC
108
+ rpc_id = str(uuid.uuid4())
109
+ await ws.send(json.dumps({
110
+ "type": "req",
111
+ "id": rpc_id,
112
+ "method": method,
113
+ "params": params,
114
+ }))
115
+
116
+ # Step 5 — collect response (drain until we get our req id back)
117
+ start = time.time()
118
+ while time.time() - start < timeout:
119
+ raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
120
+ msg = json.loads(raw)
121
+ if msg.get('id') == rpc_id:
122
+ if msg.get('error'):
123
+ return {"ok": False, "error": msg['error']}
124
+ return {"ok": True, "result": msg.get('result', msg.get('payload', {}))}
125
+ # Skip unrelated events (heartbeat, presence, etc.)
126
+
127
+ return {"ok": False, "error": "RPC timed out waiting for response"}
128
+
129
+ except OSError as exc:
130
+ return {"ok": False, "error": f"Gateway unreachable: {exc}"}
131
+ except asyncio.TimeoutError:
132
+ return {"ok": False, "error": "Gateway connection timed out"}
133
+ except Exception as exc:
134
+ logger.error(f"Gateway RPC error: {exc}")
135
+ return {"ok": False, "error": "Internal server error"}
136
+
137
+
138
+ def _run_rpc(method: str, params: dict, timeout: float = 10.0) -> dict:
139
+ """Synchronous wrapper around _gateway_rpc for use in Flask routes."""
140
+ try:
141
+ loop = asyncio.new_event_loop()
142
+ try:
143
+ return loop.run_until_complete(_gateway_rpc(method, params, timeout))
144
+ finally:
145
+ loop.close()
146
+ except Exception as exc:
147
+ logger.error("RPC error: %s", exc)
148
+ return {"ok": False, "error": "Internal server error"}
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # RPC method allowlist — only these methods may be proxied to the Gateway
152
+ # (P7-T3 security audit: prevents unrestricted Gateway access)
153
+ # ---------------------------------------------------------------------------
154
+
155
+ ALLOWED_RPC_METHODS = frozenset({
156
+ # Session management
157
+ 'sessions.list',
158
+ 'sessions.history',
159
+ 'sessions.abort',
160
+ # Chat operations
161
+ 'chat.abort',
162
+ 'chat.send',
163
+ # Diagnostic
164
+ 'ping',
165
+ 'status',
166
+ 'agent.status',
167
+ })
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Auth check endpoint
172
+ # ---------------------------------------------------------------------------
173
+
174
+ @admin_bp.route('/api/auth/check', methods=['GET'])
175
+ def auth_check():
176
+ """
177
+ Check if the current Clerk session is on the allowed list.
178
+ Called by the frontend after sign-in to determine whether to show the full UI
179
+ or a waiting-list screen.
180
+
181
+ Returns:
182
+ 200 {"allowed": true, "user_id": "..."} — user is approved
183
+ 403 {"allowed": false, "user_id": "..."} — signed in but not on allowlist
184
+ 401 {"allowed": false, "user_id": null} — not signed in at all
185
+ """
186
+ try:
187
+ from services.auth import get_token_from_request, verify_clerk_token
188
+ token = get_token_from_request()
189
+ if not token:
190
+ return jsonify({'allowed': False, 'user_id': None, 'reason': 'not_signed_in'}), 401
191
+ user_id = verify_clerk_token(token)
192
+ if user_id:
193
+ return jsonify({'allowed': True, 'user_id': user_id})
194
+ # Token valid but user not in allowlist (verify_clerk_token returns None when blocked)
195
+ return jsonify({'allowed': False, 'user_id': None, 'reason': 'not_on_allowlist'}), 403
196
+ except Exception as exc:
197
+ logger.error(f'auth_check error: {exc}')
198
+ return jsonify({'allowed': False, 'user_id': None, 'reason': 'error'}), 500
199
+
200
+
201
+ # Gateway RPC proxy endpoints
202
+ # ---------------------------------------------------------------------------
203
+
204
+ @admin_bp.route('/api/admin/gateway/status', methods=['GET'])
205
+ def gateway_status():
206
+ """
207
+ Ping the Gateway — connect, handshake, disconnect.
208
+ Returns 200 with {"connected": true} on success.
209
+ """
210
+ result = _run_rpc('ping', {}, timeout=8.0)
211
+ # A 'ping' method may not exist on all gateways; what matters is whether
212
+ # the handshake succeeded. The helper returns ok=True if auth worked.
213
+ if result['ok']:
214
+ return jsonify({"connected": True, "gateway_url": GATEWAY_URL})
215
+ # If ping method not found but handshake worked the error will say so
216
+ err = result.get('error', '')
217
+ err_str = str(err).lower() if err else ''
218
+ # "missing scope" or "unknown method" = handshake succeeded, just no permission for ping
219
+ auth_ok = 'missing scope' in err_str or 'method' in err_str or 'unknown' in err_str
220
+ return jsonify({
221
+ "connected": auth_ok,
222
+ "message": "Handshake OK (ping restricted)" if auth_ok else "Auth failed",
223
+ "gateway_url": GATEWAY_URL,
224
+ "detail": err,
225
+ }), 200
226
+
227
+
228
+ @admin_bp.route('/api/admin/gateway/rpc', methods=['POST'])
229
+ def gateway_rpc_proxy():
230
+ """
231
+ Proxy an arbitrary RPC call to the Gateway.
232
+
233
+ Request body:
234
+ {"method": "chat.abort", "params": {"sessionKey": "voice-main-6", "runId": "…"}}
235
+
236
+ Response:
237
+ {"ok": true, "result": <gateway response payload>}
238
+ {"ok": false, "error": "<reason>"}
239
+
240
+ Security note: this is an internal admin endpoint — do NOT expose it
241
+ publicly without authentication middleware.
242
+ """
243
+ data = request.get_json(silent=True) or {}
244
+ method = data.get('method', '').strip()
245
+ params = data.get('params', {})
246
+
247
+ if not method:
248
+ return jsonify({"ok": False, "error": "Missing 'method' field"}), 400
249
+
250
+ # Method allowlist guard (P7-T3 security audit)
251
+ if method not in ALLOWED_RPC_METHODS:
252
+ return jsonify({"ok": False, "error": f"Method '{method}' is not allowed"}), 403
253
+
254
+ timeout = float(data.get('timeout', 10))
255
+ result = _run_rpc(method, params, timeout=timeout)
256
+ status_code = 200 if result['ok'] else 502
257
+ return jsonify(result), status_code
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Refactor monitoring endpoints (spec from P0-T2)
262
+ # ---------------------------------------------------------------------------
263
+
264
+ @admin_bp.route('/api/refactor/status', methods=['GET'])
265
+ def refactor_status():
266
+ """
267
+ Return the full playbook-state.json — all task statuses, phase gates, etc.
268
+ Used by the refactor-dashboard canvas page.
269
+ """
270
+ if not PLAYBOOK_STATE_PATH.exists():
271
+ return jsonify({"error": "playbook-state.json not found"}), 404
272
+ try:
273
+ data = json.loads(PLAYBOOK_STATE_PATH.read_text())
274
+ return jsonify(data)
275
+ except Exception as exc:
276
+ logger.error(f"Failed to read playbook state: {exc}")
277
+ return jsonify({"error": "Internal server error"}), 500
278
+
279
+
280
+ @admin_bp.route('/api/refactor/activity', methods=['GET'])
281
+ def refactor_activity():
282
+ """
283
+ Return the last 50 entries from activity-log.jsonl.
284
+ Each line is a JSON object; newest entries are returned first.
285
+ """
286
+ if not ACTIVITY_LOG_PATH.exists():
287
+ return jsonify([])
288
+ try:
289
+ lines = ACTIVITY_LOG_PATH.read_text().strip().splitlines()
290
+ entries = []
291
+ for line in reversed(lines[-200:]):
292
+ try:
293
+ entries.append(json.loads(line))
294
+ except json.JSONDecodeError:
295
+ continue
296
+ return jsonify(entries[:50])
297
+ except Exception as exc:
298
+ logger.error(f"Failed to read activity log: {exc}")
299
+ return jsonify({"error": "Internal server error"}), 500
300
+
301
+
302
+ @admin_bp.route('/api/refactor/metrics', methods=['GET'])
303
+ def refactor_metrics():
304
+ """Return metrics.json (line counts, test coverage, etc.)."""
305
+ if not METRICS_PATH.exists():
306
+ return jsonify({"error": "metrics.json not found"}), 404
307
+ try:
308
+ data = json.loads(METRICS_PATH.read_text())
309
+ return jsonify(data)
310
+ except Exception as exc:
311
+ logger.error(f"Failed to read metrics: {exc}")
312
+ return jsonify({"error": "Internal server error"}), 500
313
+
314
+
315
+ @admin_bp.route('/api/refactor/control', methods=['POST'])
316
+ def refactor_control():
317
+ """
318
+ Control the refactor automation.
319
+
320
+ Request body:
321
+ {"action": "pause"} — set paused=true
322
+ {"action": "resume"} — set paused=false
323
+ {"action": "skip", "task_id": "P2-T6"} — mark task as skipped
324
+
325
+ Response:
326
+ {"ok": true, "state": <updated playbook state>}
327
+ """
328
+ if not PLAYBOOK_STATE_PATH.exists():
329
+ return jsonify({"ok": False, "error": "playbook-state.json not found"}), 404
330
+
331
+ data = request.get_json(silent=True) or {}
332
+ action = data.get('action', '').strip()
333
+
334
+ if action not in ('pause', 'resume', 'skip'):
335
+ return jsonify({"ok": False, "error": "action must be pause|resume|skip"}), 400
336
+
337
+ try:
338
+ state = json.loads(PLAYBOOK_STATE_PATH.read_text())
339
+
340
+ if action == 'pause':
341
+ state['paused'] = True
342
+
343
+ elif action == 'resume':
344
+ state['paused'] = False
345
+
346
+ elif action == 'skip':
347
+ task_id = data.get('task_id', '').strip()
348
+ if not task_id:
349
+ return jsonify({"ok": False, "error": "task_id required for skip"}), 400
350
+ if task_id not in state.get('tasks', {}):
351
+ return jsonify({"ok": False, "error": f"Unknown task: {task_id}"}), 404
352
+ state['tasks'][task_id]['status'] = 'skipped'
353
+ state['tasks'][task_id]['completed_at'] = datetime.now(timezone.utc).isoformat()
354
+ state['tasks'][task_id]['notes'] = (
355
+ (state['tasks'][task_id].get('notes') or '') + ' [skipped via admin API]'
356
+ ).strip()
357
+
358
+ state['last_updated'] = datetime.now(timezone.utc).isoformat()
359
+
360
+ # Atomic write
361
+ tmp = PLAYBOOK_STATE_PATH.with_suffix('.tmp')
362
+ tmp.write_text(json.dumps(state, indent=2))
363
+ tmp.replace(PLAYBOOK_STATE_PATH)
364
+
365
+ return jsonify({"ok": True, "state": state})
366
+
367
+ except Exception as exc:
368
+ logger.error(f"refactor control error: {exc}")
369
+ return jsonify({"ok": False, "error": "Internal server error"}), 500
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # Server stats endpoint
374
+ # ---------------------------------------------------------------------------
375
+
376
+ @admin_bp.route('/api/server-stats', methods=['GET'])
377
+ def server_stats():
378
+ """
379
+ VPS resource snapshot — CPU, RAM, disk, uptime, top processes.
380
+ Polled by the refactor-dashboard canvas page every few seconds.
381
+ """
382
+ try:
383
+ cpu = psutil.cpu_percent(interval=0.3)
384
+ mem = psutil.virtual_memory()
385
+ disk = psutil.disk_usage('/')
386
+ boot_dt = datetime.fromtimestamp(psutil.boot_time())
387
+ up = datetime.now() - boot_dt
388
+ days, rem = divmod(int(up.total_seconds()), 86400)
389
+ hours, rem = divmod(rem, 3600)
390
+ minutes = rem // 60
391
+ uptime_str = (f"{days}d " if days else "") + f"{hours}h {minutes}m"
392
+
393
+ # Top processes by CPU
394
+ procs = []
395
+ for p in sorted(
396
+ psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']),
397
+ key=lambda x: x.info.get('cpu_percent') or 0,
398
+ reverse=True,
399
+ )[:8]:
400
+ try:
401
+ info = p.info
402
+ if (info.get('cpu_percent') or 0) > 0:
403
+ procs.append({
404
+ 'pid': info['pid'],
405
+ 'name': info['name'],
406
+ 'cpu': round(info['cpu_percent'], 1),
407
+ 'mem': round(info.get('memory_percent') or 0, 1),
408
+ })
409
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
410
+ pass
411
+
412
+ from services.gateway_manager import gateway_manager
413
+ gateways = gateway_manager.list_gateways()
414
+
415
+ return jsonify({
416
+ 'cpu_percent': cpu,
417
+ 'gateways': gateways,
418
+ 'memory': {
419
+ 'used_gb': round(mem.used / 1024 ** 3, 2),
420
+ 'total_gb': round(mem.total / 1024 ** 3, 2),
421
+ 'percent': round(mem.percent, 1),
422
+ },
423
+ 'disk': {
424
+ 'used_gb': round(disk.used / 1024 ** 3, 1),
425
+ 'free_gb': round(disk.free / 1024 ** 3, 1),
426
+ 'total_gb': round(disk.total / 1024 ** 3, 1),
427
+ 'percent': round(disk.percent, 1),
428
+ },
429
+ 'uptime': uptime_str,
430
+ 'top_processes': procs[:5],
431
+ 'timestamp': datetime.now(timezone.utc).isoformat(),
432
+ })
433
+
434
+ except Exception as exc:
435
+ logger.error(f"server-stats error: {exc}")
436
+ return jsonify({"error": "Internal server error"}), 500
437
+
438
+
439
+ # ---------------------------------------------------------------------------
440
+ # Framework install endpoint
441
+ # ---------------------------------------------------------------------------
442
+
443
+ @admin_bp.route('/api/admin/install/start', methods=['POST'])
444
+ def install_start():
445
+ """
446
+ Trigger agent-driven framework installation.
447
+ Sends install request to OpenClaw Gateway and streams response as SSE.
448
+ Falls back to JSON response if streaming not available.
449
+ """
450
+ import json as _json
451
+ data = request.get_json(silent=True) or {}
452
+ url = data.get('url', '').strip()
453
+ if not url:
454
+ return jsonify({'error': 'url is required'}), 400
455
+
456
+ message = (
457
+ f"[ADMIN INSTALL REQUEST] Please install this agent framework: {url}\n"
458
+ "Steps to complete:\n"
459
+ "1. Research the framework (README, install method, dependencies)\n"
460
+ "2. Install it (pip install or equivalent)\n"
461
+ "3. Write a connector file in providers/ or connectors/\n"
462
+ "4. Run a quick test\n"
463
+ "5. Register it\n"
464
+ "Report each step as you complete it."
465
+ )
466
+
467
+ def generate():
468
+ try:
469
+ yield f"data: {_json.dumps({'type':'log','level':'section','message':f'Starting install: {url}'})}\n\n"
470
+ yield f"data: {_json.dumps({'type':'log','step':'research','message':'Sending to agent...'})}\n\n"
471
+
472
+ # Try to send via gateway RPC
473
+ import asyncio, websockets, uuid
474
+
475
+ gateway_url = os.environ.get('CLAWDBOT_GATEWAY_URL', 'ws://127.0.0.1:18791')
476
+ auth_token = os.environ.get('CLAWDBOT_AUTH_TOKEN', '')
477
+
478
+ async def _send():
479
+ async with websockets.connect(gateway_url, open_timeout=10) as ws:
480
+ challenge = await asyncio.wait_for(ws.recv(), timeout=5)
481
+ await ws.send(_json.dumps({'type':'connect','token':auth_token,'protocol':3,'role':'operator'}))
482
+ hello = await asyncio.wait_for(ws.recv(), timeout=5)
483
+ req_id = str(uuid.uuid4())[:8]
484
+ await ws.send(_json.dumps({'type':'req','id':req_id,'method':'chat.send','params':{'sessionKey':'admin-install','message':message,'deliver':False}}))
485
+ collected = ''
486
+ for _ in range(120):
487
+ try:
488
+ raw = await asyncio.wait_for(ws.recv(), timeout=5)
489
+ evt = _json.loads(raw)
490
+ if evt.get('stream') == 'assistant' and evt.get('text'):
491
+ collected = evt['text']
492
+ if evt.get('state') == 'final' or (evt.get('stream') == 'lifecycle' and evt.get('phase') == 'end'):
493
+ break
494
+ except asyncio.TimeoutError:
495
+ break
496
+ return collected
497
+
498
+ loop = asyncio.new_event_loop()
499
+ try:
500
+ result = loop.run_until_complete(_send())
501
+ for line in result.split('\n'):
502
+ if line.strip():
503
+ yield f"data: {_json.dumps({'type':'log','message':line})}\n\n"
504
+ yield f"data: {_json.dumps({'type':'done','message':'Agent completed'})}\n\n"
505
+ except Exception as e:
506
+ logger.error("Agent run gateway error: %s", e)
507
+ yield f"data: {_json.dumps({'type':'log','level':'error','message':'Gateway error'})}\n\n"
508
+ finally:
509
+ loop.close()
510
+ except Exception as e:
511
+ logger.error("Agent run error: %s", e)
512
+ yield f"data: {_json.dumps({'type':'log','level':'error','message':'Internal server error'})}\n\n"
513
+
514
+ from flask import Response as _Response
515
+ return _Response(generate(), mimetype='text/event-stream', headers={'Cache-Control':'no-cache','X-Accel-Buffering':'no'})