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.
- package/.env.example +104 -0
- package/Dockerfile +30 -0
- package/LICENSE +21 -0
- package/README.md +638 -0
- package/SETUP.md +360 -0
- package/app.py +232 -0
- package/auto-approve-devices.js +111 -0
- package/cli/index.js +372 -0
- package/config/__init__.py +4 -0
- package/config/default.yaml +43 -0
- package/config/flags.yaml +67 -0
- package/config/loader.py +203 -0
- package/config/providers.yaml +71 -0
- package/config/speech_normalization.yaml +182 -0
- package/config/theme.json +4 -0
- package/data/greetings.json +25 -0
- package/default-pages/ai-image-creator.html +915 -0
- package/default-pages/bulk-image-uploader.html +492 -0
- package/default-pages/desktop.html +2865 -0
- package/default-pages/file-explorer.html +854 -0
- package/default-pages/interactive-map.html +655 -0
- package/default-pages/style-guide.html +1005 -0
- package/default-pages/website-setup.html +1623 -0
- package/deploy/openclaw/Dockerfile +46 -0
- package/deploy/openvoiceui.service +30 -0
- package/deploy/setup-nginx.sh +50 -0
- package/deploy/setup-sudo.sh +306 -0
- package/deploy/skill-runner/Dockerfile +19 -0
- package/deploy/skill-runner/requirements.txt +14 -0
- package/deploy/skill-runner/server.py +269 -0
- package/deploy/supertonic/Dockerfile +22 -0
- package/deploy/supertonic/server.py +79 -0
- package/docker-compose.pinokio.yml +11 -0
- package/docker-compose.yml +59 -0
- package/greetings.json +25 -0
- package/index.html +65 -0
- package/inject-device-identity.js +142 -0
- package/package.json +82 -0
- package/profiles/default.json +114 -0
- package/profiles/manager.py +354 -0
- package/profiles/schema.json +337 -0
- package/prompts/voice-system-prompt.md +149 -0
- package/providers/__init__.py +39 -0
- package/providers/base.py +63 -0
- package/providers/llm/__init__.py +12 -0
- package/providers/llm/base.py +71 -0
- package/providers/llm/clawdbot_provider.py +112 -0
- package/providers/llm/zai_provider.py +115 -0
- package/providers/registry.py +320 -0
- package/providers/stt/__init__.py +12 -0
- package/providers/stt/base.py +58 -0
- package/providers/stt/webspeech_provider.py +49 -0
- package/providers/stt/whisper_provider.py +100 -0
- package/providers/tts/__init__.py +20 -0
- package/providers/tts/base.py +91 -0
- package/providers/tts/groq_provider.py +74 -0
- package/providers/tts/supertonic_provider.py +72 -0
- package/requirements.txt +38 -0
- package/routes/__init__.py +10 -0
- package/routes/admin.py +515 -0
- package/routes/canvas.py +1315 -0
- package/routes/chat.py +51 -0
- package/routes/conversation.py +2158 -0
- package/routes/elevenlabs_hybrid.py +306 -0
- package/routes/greetings.py +98 -0
- package/routes/icons.py +279 -0
- package/routes/image_gen.py +364 -0
- package/routes/instructions.py +190 -0
- package/routes/music.py +838 -0
- package/routes/onboarding.py +43 -0
- package/routes/pi.py +62 -0
- package/routes/profiles.py +215 -0
- package/routes/report_issue.py +68 -0
- package/routes/static_files.py +533 -0
- package/routes/suno.py +664 -0
- package/routes/theme.py +81 -0
- package/routes/transcripts.py +199 -0
- package/routes/vision.py +348 -0
- package/routes/workspace.py +288 -0
- package/server.py +1510 -0
- package/services/__init__.py +1 -0
- package/services/auth.py +143 -0
- package/services/canvas_versioning.py +239 -0
- package/services/db_pool.py +107 -0
- package/services/gateway.py +16 -0
- package/services/gateway_manager.py +333 -0
- package/services/gateways/__init__.py +12 -0
- package/services/gateways/base.py +110 -0
- package/services/gateways/compat.py +264 -0
- package/services/gateways/openclaw.py +1134 -0
- package/services/health.py +100 -0
- package/services/memory_client.py +455 -0
- package/services/paths.py +26 -0
- package/services/speech_normalizer.py +285 -0
- package/services/tts.py +270 -0
- package/setup-config.js +262 -0
- package/sounds/air_horn.mp3 +0 -0
- package/sounds/bruh.mp3 +0 -0
- package/sounds/crowd_cheer.mp3 +0 -0
- package/sounds/gunshot.mp3 +0 -0
- package/sounds/impact.mp3 +0 -0
- package/sounds/lets_go.mp3 +0 -0
- package/sounds/record_stop.mp3 +0 -0
- package/sounds/rewind.mp3 +0 -0
- package/sounds/sad_trombone.mp3 +0 -0
- package/sounds/scratch_long.mp3 +0 -0
- package/sounds/yeah.mp3 +0 -0
- package/src/adapters/ClawdBotAdapter.js +264 -0
- package/src/adapters/_template.js +133 -0
- package/src/adapters/elevenlabs-classic.js +841 -0
- package/src/adapters/elevenlabs-hybrid.js +812 -0
- package/src/adapters/hume-evi.js +676 -0
- package/src/admin.html +1339 -0
- package/src/app.js +8802 -0
- package/src/core/Config.js +173 -0
- package/src/core/EmotionEngine.js +307 -0
- package/src/core/EventBridge.js +180 -0
- package/src/core/EventBus.js +117 -0
- package/src/core/VoiceSession.js +607 -0
- package/src/face/BaseFace.js +259 -0
- package/src/face/EyeFace.js +208 -0
- package/src/face/HaloSmokeFace.js +509 -0
- package/src/face/manifest.json +27 -0
- package/src/face/previews/eyes.svg +16 -0
- package/src/face/previews/orb.svg +29 -0
- package/src/features/MusicPlayer.js +620 -0
- package/src/features/Soundboard.js +128 -0
- package/src/providers/DeepgramSTT.js +472 -0
- package/src/providers/DeepgramStreamingSTT.js +766 -0
- package/src/providers/GroqSTT.js +559 -0
- package/src/providers/TTSPlayer.js +323 -0
- package/src/providers/WebSpeechSTT.js +479 -0
- package/src/providers/tts/BaseTTSProvider.js +81 -0
- package/src/providers/tts/HumeProvider.js +77 -0
- package/src/providers/tts/SupertonicProvider.js +174 -0
- package/src/providers/tts/index.js +140 -0
- package/src/shell/adapter-registry.js +154 -0
- package/src/shell/caller-bridge.js +35 -0
- package/src/shell/camera-bridge.js +28 -0
- package/src/shell/canvas-bridge.js +32 -0
- package/src/shell/commercial-bridge.js +44 -0
- package/src/shell/face-bridge.js +44 -0
- package/src/shell/music-bridge.js +60 -0
- package/src/shell/orchestrator.js +233 -0
- package/src/shell/profile-discovery.js +303 -0
- package/src/shell/sounds-bridge.js +28 -0
- package/src/shell/transcript-bridge.js +61 -0
- package/src/shell/waveform-bridge.js +33 -0
- package/src/styles/base.css +2862 -0
- package/src/styles/face.css +417 -0
- package/src/styles/pi-overrides.css +89 -0
- package/src/styles/theme-dark.css +67 -0
- package/src/test-tts.html +175 -0
- package/src/ui/AppShell.js +544 -0
- package/src/ui/ProfileSwitcher.js +228 -0
- package/src/ui/SessionControl.js +240 -0
- package/src/ui/face/FacePicker.js +195 -0
- package/src/ui/face/FaceRenderer.js +309 -0
- package/src/ui/settings/PlaylistEditor.js +366 -0
- package/src/ui/settings/SettingsPanel.css +684 -0
- package/src/ui/settings/SettingsPanel.js +419 -0
- package/src/ui/settings/TTSVoicePreview.js +210 -0
- package/src/ui/themes/ThemeManager.js +213 -0
- package/src/ui/visualizers/BaseVisualizer.js +29 -0
- package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
- package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
- package/static/emulators/jsdos/js-dos.css +1 -0
- package/static/emulators/jsdos/js-dos.js +22 -0
- package/static/favicon.svg +55 -0
- package/static/icons/apple-touch-icon.png +0 -0
- package/static/icons/favicon-32.png +0 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/install.html +449 -0
- package/static/manifest.json +26 -0
- package/static/sw.js +21 -0
- package/tts_providers/__init__.py +136 -0
- package/tts_providers/base_provider.py +319 -0
- package/tts_providers/groq_provider.py +155 -0
- package/tts_providers/hume_provider.py +226 -0
- package/tts_providers/providers_config.json +119 -0
- package/tts_providers/qwen3_provider.py +371 -0
- package/tts_providers/resemble_provider.py +315 -0
- package/tts_providers/supertonic_provider.py +557 -0
- package/tts_providers/supertonic_tts.py +399 -0
package/routes/admin.py
ADDED
|
@@ -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'})
|