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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# services package
|
package/services/auth.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Clerk JWT authentication middleware for OpenVoiceUI.
|
|
3
|
+
|
|
4
|
+
Verifies Clerk session tokens from:
|
|
5
|
+
1. Authorization: Bearer <token> header
|
|
6
|
+
2. __session cookie (set automatically by Clerk for browser requests)
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from services.auth import verify_clerk_token, get_token_from_request
|
|
10
|
+
|
|
11
|
+
token = get_token_from_request()
|
|
12
|
+
user_id = verify_clerk_token(token) # returns str or None
|
|
13
|
+
"""
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
import jwt
|
|
21
|
+
import requests
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Configuration
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def _derive_clerk_domain(key: str) -> str:
|
|
30
|
+
"""Derive the Clerk frontend domain from a publishable key (pk_live_/pk_test_)."""
|
|
31
|
+
import base64
|
|
32
|
+
try:
|
|
33
|
+
suffix = key.split('_', 2)[-1]
|
|
34
|
+
padding = (4 - len(suffix) % 4) % 4
|
|
35
|
+
decoded = base64.b64decode(suffix + '=' * padding).decode('utf-8').rstrip('$')
|
|
36
|
+
return decoded
|
|
37
|
+
except Exception:
|
|
38
|
+
return ''
|
|
39
|
+
|
|
40
|
+
_raw_clerk_key = (os.getenv('CLERK_PUBLISHABLE_KEY') or os.getenv('VITE_CLERK_PUBLISHABLE_KEY', '')).strip()
|
|
41
|
+
_CLERK_FRONTEND_DOMAIN = os.getenv('CLERK_FRONTEND_API') or (_derive_clerk_domain(_raw_clerk_key) if _raw_clerk_key else '')
|
|
42
|
+
_JWKS_URL = f'https://{_CLERK_FRONTEND_DOMAIN}/.well-known/jwks.json'
|
|
43
|
+
_JWKS_CACHE_TTL = 3600 # refresh keys every 60 minutes
|
|
44
|
+
|
|
45
|
+
# Allowlist of Clerk user IDs permitted to access this deployment.
|
|
46
|
+
# Set ALLOWED_USER_IDS=user_abc123,user_xyz789 in .env
|
|
47
|
+
# If the env var is empty or unset, the check is SKIPPED (open to any valid Clerk user).
|
|
48
|
+
# Always set this in production — agents have full access.
|
|
49
|
+
_raw_allowed = os.getenv('ALLOWED_USER_IDS', '')
|
|
50
|
+
_ALLOWED_USER_IDS: set[str] = {uid.strip() for uid in _raw_allowed.split(',') if uid.strip()}
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# JWKS cache
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
_jwks_cache: dict = {'keys': None, 'fetched_at': 0}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_jwks() -> list:
|
|
60
|
+
"""Return cached JWKS key list, refreshing if stale."""
|
|
61
|
+
now = time.time()
|
|
62
|
+
if _jwks_cache['keys'] is None or (now - _jwks_cache['fetched_at']) > _JWKS_CACHE_TTL:
|
|
63
|
+
try:
|
|
64
|
+
resp = requests.get(_JWKS_URL, timeout=10)
|
|
65
|
+
resp.raise_for_status()
|
|
66
|
+
_jwks_cache['keys'] = resp.json().get('keys', [])
|
|
67
|
+
_jwks_cache['fetched_at'] = now
|
|
68
|
+
logger.debug('JWKS refreshed (%d keys)', len(_jwks_cache['keys']))
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
logger.error('Failed to fetch JWKS from %s: %s', _JWKS_URL, exc)
|
|
71
|
+
# Return stale keys if available
|
|
72
|
+
if _jwks_cache['keys']:
|
|
73
|
+
return _jwks_cache['keys']
|
|
74
|
+
return []
|
|
75
|
+
return _jwks_cache['keys']
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Token verification
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def verify_clerk_token(token: str) -> Optional[str]:
|
|
83
|
+
"""
|
|
84
|
+
Verify a Clerk JWT and return the user_id (sub claim) if valid.
|
|
85
|
+
|
|
86
|
+
Returns None if the token is missing, malformed, or invalid.
|
|
87
|
+
"""
|
|
88
|
+
if not token:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
keys = _get_jwks()
|
|
92
|
+
if not keys:
|
|
93
|
+
logger.warning('No JWKS keys available — cannot verify token')
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
for key_data in keys:
|
|
97
|
+
try:
|
|
98
|
+
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key_data)
|
|
99
|
+
payload = jwt.decode(
|
|
100
|
+
token,
|
|
101
|
+
public_key,
|
|
102
|
+
algorithms=['RS256'],
|
|
103
|
+
options={'verify_aud': False}, # Clerk tokens don't use aud in all configs
|
|
104
|
+
)
|
|
105
|
+
user_id = payload.get('sub')
|
|
106
|
+
if not user_id:
|
|
107
|
+
return None
|
|
108
|
+
# Log user_id on every successful auth so it can be captured for ALLOWED_USER_IDS
|
|
109
|
+
logger.info('Clerk auth: user_id=%s', user_id)
|
|
110
|
+
# Enforce allowlist if configured
|
|
111
|
+
if _ALLOWED_USER_IDS and user_id not in _ALLOWED_USER_IDS:
|
|
112
|
+
logger.warning('Clerk auth: user_id=%s not in ALLOWED_USER_IDS — access denied', user_id)
|
|
113
|
+
return None
|
|
114
|
+
return user_id
|
|
115
|
+
except jwt.ExpiredSignatureError:
|
|
116
|
+
logger.debug('Token expired')
|
|
117
|
+
return None
|
|
118
|
+
except jwt.InvalidTokenError:
|
|
119
|
+
continue # try next key
|
|
120
|
+
|
|
121
|
+
logger.debug('Token did not validate against any JWKS key')
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_token_from_request() -> Optional[str]:
|
|
126
|
+
"""
|
|
127
|
+
Extract Clerk session token from the current Flask request.
|
|
128
|
+
|
|
129
|
+
Checks in order:
|
|
130
|
+
1. Authorization: Bearer <token>
|
|
131
|
+
2. __session cookie
|
|
132
|
+
"""
|
|
133
|
+
from flask import request
|
|
134
|
+
|
|
135
|
+
auth_header = request.headers.get('Authorization', '')
|
|
136
|
+
if auth_header.startswith('Bearer '):
|
|
137
|
+
return auth_header[7:].strip()
|
|
138
|
+
|
|
139
|
+
cookie_token = request.cookies.get('__session')
|
|
140
|
+
if cookie_token:
|
|
141
|
+
return cookie_token
|
|
142
|
+
|
|
143
|
+
return None
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Canvas Page Versioning — automatic version history for canvas pages.
|
|
3
|
+
|
|
4
|
+
Watches the canvas-pages directory for file changes and saves previous
|
|
5
|
+
versions to a .versions/ subdirectory. This catches ALL writes regardless
|
|
6
|
+
of source (agent tool, API, manual edit).
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from services.canvas_versioning import start_version_watcher, list_versions, restore_version
|
|
10
|
+
|
|
11
|
+
Version files are stored as:
|
|
12
|
+
canvas-pages/.versions/<page-stem>.<unix-timestamp>.html
|
|
13
|
+
|
|
14
|
+
Auto-cleanup keeps the last MAX_VERSIONS_PER_PAGE versions per page.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from services.paths import CANVAS_PAGES_DIR
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Configuration
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
MAX_VERSIONS_PER_PAGE = 20 # Keep last N versions per page
|
|
34
|
+
CHECK_INTERVAL_SECONDS = 15 # How often to scan for changes
|
|
35
|
+
VERSIONS_DIRNAME = '.versions' # Subdirectory name inside canvas-pages
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Internal state
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
_file_hashes: dict[str, str] = {} # filename -> content hash
|
|
42
|
+
_file_contents: dict[str, bytes] = {} # filename -> last-known content (for saving)
|
|
43
|
+
_watcher_thread: threading.Thread | None = None
|
|
44
|
+
_stop_event = threading.Event()
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Helpers
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def _versions_dir() -> Path:
|
|
51
|
+
"""Return the .versions/ directory path, creating it if needed."""
|
|
52
|
+
vdir = CANVAS_PAGES_DIR / VERSIONS_DIRNAME
|
|
53
|
+
vdir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
return vdir
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _content_hash(data: bytes) -> str:
|
|
58
|
+
"""SHA-256 hash of file content."""
|
|
59
|
+
return hashlib.sha256(data).hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _save_version(filename: str, old_content: bytes) -> Path | None:
|
|
63
|
+
"""Save old_content as a timestamped version file."""
|
|
64
|
+
try:
|
|
65
|
+
stem = Path(filename).stem
|
|
66
|
+
timestamp = int(time.time())
|
|
67
|
+
version_name = f'{stem}.{timestamp}.html'
|
|
68
|
+
version_path = _versions_dir() / version_name
|
|
69
|
+
version_path.write_bytes(old_content)
|
|
70
|
+
logger.info(f'Canvas version saved: {version_name} ({len(old_content)} bytes)')
|
|
71
|
+
_cleanup_versions(stem)
|
|
72
|
+
return version_path
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
logger.error(f'Failed to save canvas version for {filename}: {exc}')
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cleanup_versions(page_stem: str) -> None:
|
|
79
|
+
"""Keep only the latest MAX_VERSIONS_PER_PAGE versions for a page."""
|
|
80
|
+
vdir = _versions_dir()
|
|
81
|
+
versions = sorted(
|
|
82
|
+
vdir.glob(f'{page_stem}.*.html'),
|
|
83
|
+
key=lambda p: p.stat().st_mtime,
|
|
84
|
+
reverse=True,
|
|
85
|
+
)
|
|
86
|
+
for old_version in versions[MAX_VERSIONS_PER_PAGE:]:
|
|
87
|
+
try:
|
|
88
|
+
old_version.unlink()
|
|
89
|
+
logger.debug(f'Pruned old version: {old_version.name}')
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _initial_scan() -> None:
|
|
95
|
+
"""Scan all existing pages and record their hashes (no versioning on startup)."""
|
|
96
|
+
if not CANVAS_PAGES_DIR.exists():
|
|
97
|
+
return
|
|
98
|
+
for page_path in CANVAS_PAGES_DIR.glob('*.html'):
|
|
99
|
+
try:
|
|
100
|
+
content = page_path.read_bytes()
|
|
101
|
+
_file_hashes[page_path.name] = _content_hash(content)
|
|
102
|
+
_file_contents[page_path.name] = content
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
logger.debug(f'Initial scan: could not read {page_path.name}: {exc}')
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _check_for_changes() -> None:
|
|
108
|
+
"""Scan canvas-pages for modified files and save versions."""
|
|
109
|
+
if not CANVAS_PAGES_DIR.exists():
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
current_files = set()
|
|
113
|
+
for page_path in CANVAS_PAGES_DIR.glob('*.html'):
|
|
114
|
+
filename = page_path.name
|
|
115
|
+
current_files.add(filename)
|
|
116
|
+
try:
|
|
117
|
+
content = page_path.read_bytes()
|
|
118
|
+
new_hash = _content_hash(content)
|
|
119
|
+
|
|
120
|
+
if filename in _file_hashes:
|
|
121
|
+
if new_hash != _file_hashes[filename]:
|
|
122
|
+
# File changed — save the OLD content as a version
|
|
123
|
+
old_content = _file_contents.get(filename)
|
|
124
|
+
if old_content:
|
|
125
|
+
_save_version(filename, old_content)
|
|
126
|
+
_file_hashes[filename] = new_hash
|
|
127
|
+
_file_contents[filename] = content
|
|
128
|
+
else:
|
|
129
|
+
# New file — just record it (no previous version to save)
|
|
130
|
+
_file_hashes[filename] = new_hash
|
|
131
|
+
_file_contents[filename] = content
|
|
132
|
+
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
logger.debug(f'Version check: could not read {filename}: {exc}')
|
|
135
|
+
|
|
136
|
+
# Note: we don't remove deleted files from tracking — they might come back
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Background watcher
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def _watcher_loop() -> None:
|
|
144
|
+
"""Background thread that periodically checks for file changes."""
|
|
145
|
+
logger.info(f'Canvas version watcher started (interval={CHECK_INTERVAL_SECONDS}s, max_versions={MAX_VERSIONS_PER_PAGE})')
|
|
146
|
+
_initial_scan()
|
|
147
|
+
|
|
148
|
+
while not _stop_event.is_set():
|
|
149
|
+
try:
|
|
150
|
+
_check_for_changes()
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
logger.error(f'Canvas version watcher error: {exc}')
|
|
153
|
+
_stop_event.wait(CHECK_INTERVAL_SECONDS)
|
|
154
|
+
|
|
155
|
+
logger.info('Canvas version watcher stopped')
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def start_version_watcher() -> None:
|
|
159
|
+
"""Start the background version watcher thread."""
|
|
160
|
+
global _watcher_thread
|
|
161
|
+
if _watcher_thread and _watcher_thread.is_alive():
|
|
162
|
+
logger.debug('Canvas version watcher already running')
|
|
163
|
+
return
|
|
164
|
+
_stop_event.clear()
|
|
165
|
+
_watcher_thread = threading.Thread(
|
|
166
|
+
target=_watcher_loop,
|
|
167
|
+
name='canvas-version-watcher',
|
|
168
|
+
daemon=True,
|
|
169
|
+
)
|
|
170
|
+
_watcher_thread.start()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def stop_version_watcher() -> None:
|
|
174
|
+
"""Stop the background version watcher thread."""
|
|
175
|
+
_stop_event.set()
|
|
176
|
+
if _watcher_thread:
|
|
177
|
+
_watcher_thread.join(timeout=5)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# Public API (used by canvas.py routes)
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def list_versions(page_id: str) -> list[dict]:
|
|
185
|
+
"""List all saved versions for a page, newest first."""
|
|
186
|
+
vdir = _versions_dir()
|
|
187
|
+
versions = []
|
|
188
|
+
for vpath in sorted(vdir.glob(f'{page_id}.*.html'), key=lambda p: p.stat().st_mtime, reverse=True):
|
|
189
|
+
# Parse timestamp from filename: page-id.1709510400.html
|
|
190
|
+
parts = vpath.stem.rsplit('.', 1)
|
|
191
|
+
if len(parts) == 2:
|
|
192
|
+
try:
|
|
193
|
+
ts = int(parts[1])
|
|
194
|
+
except ValueError:
|
|
195
|
+
ts = int(vpath.stat().st_mtime)
|
|
196
|
+
else:
|
|
197
|
+
ts = int(vpath.stat().st_mtime)
|
|
198
|
+
|
|
199
|
+
versions.append({
|
|
200
|
+
'filename': vpath.name,
|
|
201
|
+
'timestamp': ts,
|
|
202
|
+
'iso': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts)),
|
|
203
|
+
'size': vpath.stat().st_size,
|
|
204
|
+
})
|
|
205
|
+
return versions
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def restore_version(page_id: str, timestamp: int) -> bool:
|
|
209
|
+
"""Restore a specific version, saving the current as a new version first."""
|
|
210
|
+
version_file = _versions_dir() / f'{page_id}.{timestamp}.html'
|
|
211
|
+
current_file = CANVAS_PAGES_DIR / f'{page_id}.html'
|
|
212
|
+
|
|
213
|
+
if not version_file.exists():
|
|
214
|
+
logger.error(f'Version not found: {version_file}')
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
# Save current as a version before restoring
|
|
218
|
+
if current_file.exists():
|
|
219
|
+
current_content = current_file.read_bytes()
|
|
220
|
+
_save_version(current_file.name, current_content)
|
|
221
|
+
|
|
222
|
+
# Restore the old version
|
|
223
|
+
restored_content = version_file.read_bytes()
|
|
224
|
+
current_file.write_bytes(restored_content)
|
|
225
|
+
|
|
226
|
+
# Update tracking
|
|
227
|
+
_file_hashes[current_file.name] = _content_hash(restored_content)
|
|
228
|
+
_file_contents[current_file.name] = restored_content
|
|
229
|
+
|
|
230
|
+
logger.info(f'Restored canvas page {page_id} to version {timestamp}')
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def get_version_content(page_id: str, timestamp: int) -> bytes | None:
|
|
235
|
+
"""Get the content of a specific version."""
|
|
236
|
+
version_file = _versions_dir() / f'{page_id}.{timestamp}.html'
|
|
237
|
+
if version_file.exists():
|
|
238
|
+
return version_file.read_bytes()
|
|
239
|
+
return None
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# services/db_pool.py
|
|
2
|
+
"""SQLite connection pool with WAL mode enabled.
|
|
3
|
+
|
|
4
|
+
Recipe R2 — SQLite WAL + Connection Pool
|
|
5
|
+
Fixes SQLite write contention under concurrent Flask request handling.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from services.db_pool import SQLitePool
|
|
9
|
+
db = SQLitePool("usage.db")
|
|
10
|
+
|
|
11
|
+
# Write (auto-commit, retries on busy)
|
|
12
|
+
db.execute("INSERT INTO conversation_log VALUES (?)", (message,))
|
|
13
|
+
|
|
14
|
+
# Read (concurrent reads via WAL)
|
|
15
|
+
results = db.query("SELECT * FROM conversation_log LIMIT 10")
|
|
16
|
+
|
|
17
|
+
# Manual connection (for row_factory etc.)
|
|
18
|
+
with db.get_connection() as conn:
|
|
19
|
+
conn.row_factory = sqlite3.Row
|
|
20
|
+
rows = conn.execute("SELECT * FROM foo").fetchall()
|
|
21
|
+
"""
|
|
22
|
+
import sqlite3
|
|
23
|
+
import threading
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
from queue import Queue, Empty
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SQLitePool:
|
|
30
|
+
"""Thread-safe SQLite connection pool with WAL mode."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, db_path: str, pool_size: int = 5):
|
|
33
|
+
self.db_path = str(db_path)
|
|
34
|
+
self.pool_size = pool_size
|
|
35
|
+
self._pool = Queue(maxsize=pool_size)
|
|
36
|
+
self._lock = threading.Lock()
|
|
37
|
+
|
|
38
|
+
# Initialize pool with WAL-enabled connections
|
|
39
|
+
for _ in range(pool_size):
|
|
40
|
+
self._pool.put(self._create_connection())
|
|
41
|
+
|
|
42
|
+
def _create_connection(self) -> sqlite3.Connection:
|
|
43
|
+
"""Create a new connection with WAL mode and optimized settings."""
|
|
44
|
+
conn = sqlite3.connect(
|
|
45
|
+
self.db_path,
|
|
46
|
+
timeout=30.0,
|
|
47
|
+
check_same_thread=False,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Enable WAL mode for concurrent reads without blocking writers
|
|
51
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
52
|
+
# NORMAL is safe with WAL and much faster than FULL
|
|
53
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
54
|
+
# 64 MB page cache per connection
|
|
55
|
+
conn.execute("PRAGMA cache_size=-64000")
|
|
56
|
+
# 30 s busy timeout (belt-and-suspenders alongside pool timeout)
|
|
57
|
+
conn.execute("PRAGMA busy_timeout=30000")
|
|
58
|
+
|
|
59
|
+
return conn
|
|
60
|
+
|
|
61
|
+
@contextmanager
|
|
62
|
+
def get_connection(self):
|
|
63
|
+
"""Get a connection from the pool (blocks up to 35 s before raising)."""
|
|
64
|
+
try:
|
|
65
|
+
conn = self._pool.get(timeout=35)
|
|
66
|
+
except Empty:
|
|
67
|
+
raise RuntimeError(
|
|
68
|
+
"SQLitePool: timed out waiting for a free connection. "
|
|
69
|
+
f"Pool size is {self.pool_size}."
|
|
70
|
+
)
|
|
71
|
+
try:
|
|
72
|
+
yield conn
|
|
73
|
+
finally:
|
|
74
|
+
self._pool.put(conn)
|
|
75
|
+
|
|
76
|
+
def execute(self, query: str, params: tuple = ()):
|
|
77
|
+
"""Execute a write query with automatic retry on busy."""
|
|
78
|
+
max_retries = 3
|
|
79
|
+
for attempt in range(max_retries):
|
|
80
|
+
try:
|
|
81
|
+
with self.get_connection() as conn:
|
|
82
|
+
cursor = conn.execute(query, params)
|
|
83
|
+
conn.commit()
|
|
84
|
+
return cursor
|
|
85
|
+
except sqlite3.OperationalError as e:
|
|
86
|
+
if "locked" in str(e) and attempt < max_retries - 1:
|
|
87
|
+
time.sleep(0.1 * (attempt + 1))
|
|
88
|
+
continue
|
|
89
|
+
raise
|
|
90
|
+
|
|
91
|
+
def query(self, query: str, params: tuple = ()) -> list:
|
|
92
|
+
"""Execute a read query and return all rows."""
|
|
93
|
+
with self.get_connection() as conn:
|
|
94
|
+
cursor = conn.execute(query, params)
|
|
95
|
+
return cursor.fetchall()
|
|
96
|
+
|
|
97
|
+
def close(self):
|
|
98
|
+
"""Drain the pool and close all connections."""
|
|
99
|
+
closed = 0
|
|
100
|
+
while not self._pool.empty():
|
|
101
|
+
try:
|
|
102
|
+
conn = self._pool.get_nowait()
|
|
103
|
+
conn.close()
|
|
104
|
+
closed += 1
|
|
105
|
+
except Empty:
|
|
106
|
+
break
|
|
107
|
+
return closed
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backwards-compatibility shim.
|
|
3
|
+
|
|
4
|
+
The gateway implementation has moved to:
|
|
5
|
+
services/gateways/openclaw.py — OpenClaw persistent WS gateway
|
|
6
|
+
services/gateway_manager.py — registry, plugin loader, router
|
|
7
|
+
|
|
8
|
+
New code should import from gateway_manager:
|
|
9
|
+
from services.gateway_manager import gateway_manager
|
|
10
|
+
|
|
11
|
+
Existing code that imports gateway_connection continues to work unchanged:
|
|
12
|
+
from services.gateway import gateway_connection
|
|
13
|
+
"""
|
|
14
|
+
from services.gateway_manager import gateway_manager as gateway_connection
|
|
15
|
+
|
|
16
|
+
__all__ = ['gateway_connection']
|