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/canvas.py
ADDED
|
@@ -0,0 +1,1315 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Canvas routes blueprint — extracted from server.py (P2-T5).
|
|
3
|
+
|
|
4
|
+
Provides all canvas-related HTTP endpoints plus the canvas context tracking
|
|
5
|
+
and manifest management helpers that other modules (e.g. server.py's
|
|
6
|
+
conversation handler) need via direct import.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import html as html_module
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import requests as http_requests
|
|
21
|
+
from flask import Blueprint, Response, jsonify, redirect, request, send_file
|
|
22
|
+
|
|
23
|
+
from services.canvas_versioning import (
|
|
24
|
+
list_versions,
|
|
25
|
+
restore_version,
|
|
26
|
+
get_version_content,
|
|
27
|
+
start_version_watcher,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Constants
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
from services.paths import APP_ROOT as _APP_ROOT, CANVAS_MANIFEST_PATH, CANVAS_PAGES_DIR
|
|
35
|
+
CANVAS_SSE_PORT = int(os.getenv('CANVAS_SSE_PORT', '3030'))
|
|
36
|
+
CANVAS_SESSION_PORT = int(os.getenv('CANVAS_SESSION_PORT', '3002'))
|
|
37
|
+
BRAIN_EVENTS_PATH = Path('/tmp/openvoiceui-events.jsonl')
|
|
38
|
+
# Self-hosted installs: auth is disabled by default. Set CANVAS_REQUIRE_AUTH=true to enable Clerk JWT checks.
|
|
39
|
+
CANVAS_REQUIRE_AUTH = os.getenv('CANVAS_REQUIRE_AUTH', 'false').lower() == 'true'
|
|
40
|
+
|
|
41
|
+
CATEGORY_KEYWORDS = {
|
|
42
|
+
'dashboards': ['dashboard', 'monitor', 'status', 'overview', 'control panel', 'panel'],
|
|
43
|
+
'weather': ['weather', 'temperature', 'forecast', 'climate', 'rain', 'sunny', 'humidity'],
|
|
44
|
+
'research': ['research', 'analysis', 'study', 'compare', 'investigate', 'explore'],
|
|
45
|
+
'social': ['twitter', 'x.com', 'social', 'post', 'tweet', 'follower', 'engagement'],
|
|
46
|
+
'finance': ['price', 'cost', 'budget', 'money', 'crypto', 'stock', 'market'],
|
|
47
|
+
'tasks': ['todo', 'task', 'project', 'plan', 'roadmap', 'checklist'],
|
|
48
|
+
'reference': ['guide', 'reference', 'documentation', 'help', 'how to', 'tutorial'],
|
|
49
|
+
'entertainment': ['music', 'radio', 'playlist', 'dj', 'audio', 'song'],
|
|
50
|
+
'video': ['video', 'remotion', 'render', 'animation', 'movie', 'clip', 'recording'],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
CATEGORY_ICONS = {
|
|
54
|
+
'dashboards': '📊',
|
|
55
|
+
'weather': '🌤️',
|
|
56
|
+
'research': '🔬',
|
|
57
|
+
'social': '🐦',
|
|
58
|
+
'finance': '💰',
|
|
59
|
+
'tasks': '✅',
|
|
60
|
+
'reference': '📖',
|
|
61
|
+
'entertainment': '🎵',
|
|
62
|
+
'video': '🎬',
|
|
63
|
+
'uncategorized': '📁',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
CATEGORY_COLORS = {
|
|
67
|
+
'dashboards': '#4a9eff',
|
|
68
|
+
'weather': '#ffb347',
|
|
69
|
+
'research': '#9b59b6',
|
|
70
|
+
'social': '#1da1f2',
|
|
71
|
+
'finance': '#2ecc71',
|
|
72
|
+
'tasks': '#e74c3c',
|
|
73
|
+
'reference': '#95a5a6',
|
|
74
|
+
'entertainment': '#e91e63',
|
|
75
|
+
'video': '#ff6b35',
|
|
76
|
+
'uncategorized': '#6e7681',
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Canvas context state (module-level so other modules can import it)
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
_canvas_context_lock = threading.Lock()
|
|
84
|
+
|
|
85
|
+
canvas_context = {
|
|
86
|
+
'current_page': None, # filename of current page
|
|
87
|
+
'current_title': None, # title of current page
|
|
88
|
+
'page_content': None, # brief content summary
|
|
89
|
+
'updated_at': None, # when context was last updated
|
|
90
|
+
'all_pages': [], # list of all known canvas pages
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Manifest cache
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
_manifest_cache: dict = {'data': None, 'mtime': 0}
|
|
98
|
+
_last_sync_time: float = 0
|
|
99
|
+
_SYNC_THROTTLE_SECONDS: int = 60 # auto-sync at most once per minute
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Internal helpers
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def _notify_brain(event_type: str, **data) -> None:
|
|
106
|
+
"""Append a canvas event to the Brain event log (non-critical)."""
|
|
107
|
+
try:
|
|
108
|
+
event = {'type': event_type, 'timestamp': datetime.now().isoformat()}
|
|
109
|
+
event.update(data)
|
|
110
|
+
with open(BRAIN_EVENTS_PATH, 'a') as f:
|
|
111
|
+
f.write(json.dumps(event) + '\n')
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
logging.getLogger(__name__).debug(f'Brain notification failed (non-critical): {exc}')
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Canvas context helpers (imported by server.py conversation handler)
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def update_canvas_context(page_path: str, title: str = None, content_summary: str = None) -> None:
|
|
121
|
+
"""Update the current canvas context (called by frontend)."""
|
|
122
|
+
global canvas_context
|
|
123
|
+
canvas_context['current_page'] = page_path
|
|
124
|
+
canvas_context['current_title'] = title
|
|
125
|
+
canvas_context['page_content'] = content_summary
|
|
126
|
+
canvas_context['updated_at'] = datetime.now().isoformat()
|
|
127
|
+
|
|
128
|
+
_notify_brain('canvas_display', page=page_path, title=title)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
if CANVAS_PAGES_DIR.exists():
|
|
132
|
+
pages = sorted(
|
|
133
|
+
CANVAS_PAGES_DIR.glob('*.html'),
|
|
134
|
+
key=lambda p: p.stat().st_mtime,
|
|
135
|
+
reverse=True,
|
|
136
|
+
)[:30]
|
|
137
|
+
canvas_context['all_pages'] = [
|
|
138
|
+
{'name': p.name, 'title': p.stem.replace('-', ' '), 'mtime': p.stat().st_mtime}
|
|
139
|
+
for p in pages
|
|
140
|
+
]
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def extract_canvas_page_content(page_path: str, max_chars: int = 1000) -> str:
|
|
146
|
+
"""Extract readable text content from a canvas HTML page."""
|
|
147
|
+
try:
|
|
148
|
+
if page_path.startswith('/pages/'):
|
|
149
|
+
page_path = page_path[7:]
|
|
150
|
+
full_path = CANVAS_PAGES_DIR / page_path
|
|
151
|
+
if not full_path.exists():
|
|
152
|
+
return ''
|
|
153
|
+
html_raw = full_path.read_text(errors='ignore')
|
|
154
|
+
html_raw = re.sub(r'<script[^>]*>.*?</script>', '', html_raw, flags=re.DOTALL | re.IGNORECASE)
|
|
155
|
+
html_raw = re.sub(r'<style[^>]*>.*?</style>', '', html_raw, flags=re.DOTALL | re.IGNORECASE)
|
|
156
|
+
text = re.sub(r'<[^>]+>', ' ', html_raw)
|
|
157
|
+
text = re.sub(r'\s+', ' ', text).strip()
|
|
158
|
+
text = html_module.unescape(text)
|
|
159
|
+
return text[:max_chars]
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
logging.getLogger(__name__).debug(f'Failed to extract canvas content: {exc}')
|
|
162
|
+
return ''
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_canvas_context() -> str:
|
|
166
|
+
"""Return canvas context string for the agent's system prompt with full page catalog."""
|
|
167
|
+
manifest = load_canvas_manifest()
|
|
168
|
+
parts = ['\n--- CANVAS CONTEXT ---']
|
|
169
|
+
|
|
170
|
+
if canvas_context.get('current_page'):
|
|
171
|
+
page_name = canvas_context['current_title'] or canvas_context['current_page']
|
|
172
|
+
parts.append(f"Currently viewing: {page_name}")
|
|
173
|
+
page_content = extract_canvas_page_content(canvas_context['current_page'], max_chars=800)
|
|
174
|
+
if page_content:
|
|
175
|
+
parts.append('\nPage content summary:')
|
|
176
|
+
parts.append(page_content[:800])
|
|
177
|
+
|
|
178
|
+
starred = [p for p in manifest.get('pages', {}).values() if p.get('starred')]
|
|
179
|
+
if starred:
|
|
180
|
+
parts.append('\nStarred pages (user favorites, say name to open):')
|
|
181
|
+
for p in starred[:5]:
|
|
182
|
+
aliases = p.get('voice_aliases', [])[:2]
|
|
183
|
+
alias_str = f" (say: {', '.join(aliases)})" if aliases else ''
|
|
184
|
+
parts.append(f" - {p['display_name']}{alias_str}")
|
|
185
|
+
|
|
186
|
+
categories = manifest.get('categories', {})
|
|
187
|
+
all_pages = manifest.get('pages', {})
|
|
188
|
+
if categories:
|
|
189
|
+
parts.append('\nAvailable pages (use [CANVAS:page-id] to open):')
|
|
190
|
+
for cat_id, cat in categories.items():
|
|
191
|
+
cat_pages = cat.get('pages', [])
|
|
192
|
+
if cat_pages:
|
|
193
|
+
parts.append(f" {cat.get('icon', '📄')} {cat['name']}:")
|
|
194
|
+
for pid in cat_pages:
|
|
195
|
+
display = all_pages.get(pid, {}).get('display_name', pid)
|
|
196
|
+
parts.append(f" - {display} → [CANVAS:{pid}]")
|
|
197
|
+
|
|
198
|
+
recent = manifest.get('recently_viewed', [])[:5]
|
|
199
|
+
if recent:
|
|
200
|
+
recent_names = []
|
|
201
|
+
for pid in recent:
|
|
202
|
+
if pid in manifest.get('pages', {}):
|
|
203
|
+
recent_names.append(manifest['pages'][pid].get('display_name', pid))
|
|
204
|
+
if recent_names:
|
|
205
|
+
parts.append(f"\nRecently viewed: {', '.join(recent_names[:3])}")
|
|
206
|
+
|
|
207
|
+
parts.append('\nVOICE COMMANDS:')
|
|
208
|
+
parts.append('- "Show [page name]" - Open a specific canvas page')
|
|
209
|
+
parts.append('- "Show [category] pages" - Show category overview')
|
|
210
|
+
parts.append('- "What pages do we have?" - List available pages')
|
|
211
|
+
parts.append('- "Update this page" - Modify the current page')
|
|
212
|
+
parts.append('\nAGENT CANVAS CONTROL:')
|
|
213
|
+
parts.append('- To open a canvas page, include: [CANVAS:page-name]')
|
|
214
|
+
parts.append('- Example: [CANVAS:dashboard] or [CANVAS:weather]')
|
|
215
|
+
parts.append('- To open the canvas menu, include: [CANVAS_MENU]')
|
|
216
|
+
parts.append('- The canvas will open automatically when user sees your response')
|
|
217
|
+
parts.append('\nAGENT SONG GENERATION (Suno AI):')
|
|
218
|
+
parts.append('- To generate a new song, include: [SUNO_GENERATE:describe the song here]')
|
|
219
|
+
parts.append('- Example: [SUNO_GENERATE:upbeat track about a sunny day]')
|
|
220
|
+
parts.append('- The frontend will call /api/suno, poll for completion (~45s), then auto-play the new song')
|
|
221
|
+
parts.append('- Songs are saved to generated_music/ and appear in the music player')
|
|
222
|
+
parts.append('- Costs ~12 Suno credits per song (2 tracks generated per request)')
|
|
223
|
+
parts.append('\nAGENT MUSIC CONTROL:')
|
|
224
|
+
parts.append('- To play music/radio, include: [MUSIC_PLAY]')
|
|
225
|
+
parts.append('- To play a specific track, include: [MUSIC_PLAY:track name]')
|
|
226
|
+
parts.append('- To stop music, include: [MUSIC_STOP]')
|
|
227
|
+
parts.append('- To skip to next track, include: [MUSIC_NEXT]')
|
|
228
|
+
parts.append('- Available tracks are loaded dynamically from the music library')
|
|
229
|
+
parts.append('- The music player will open/close automatically when user sees your response')
|
|
230
|
+
parts.append('--- END CANVAS CONTEXT ---')
|
|
231
|
+
|
|
232
|
+
return '\n'.join(parts)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def get_current_canvas_page_for_worker() -> str | None:
|
|
236
|
+
"""Return current canvas page filename for workers to update."""
|
|
237
|
+
if canvas_context.get('current_page'):
|
|
238
|
+
page = canvas_context['current_page']
|
|
239
|
+
if page.startswith('/pages/'):
|
|
240
|
+
page = page[7:]
|
|
241
|
+
return page
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
# Manifest helpers
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def load_canvas_manifest() -> dict:
|
|
250
|
+
"""Load manifest with mtime-based caching."""
|
|
251
|
+
global _manifest_cache
|
|
252
|
+
if CANVAS_MANIFEST_PATH.exists():
|
|
253
|
+
try:
|
|
254
|
+
mtime = CANVAS_MANIFEST_PATH.stat().st_mtime
|
|
255
|
+
if mtime > _manifest_cache['mtime']:
|
|
256
|
+
with open(CANVAS_MANIFEST_PATH, 'r') as f:
|
|
257
|
+
_manifest_cache['data'] = json.load(f)
|
|
258
|
+
_manifest_cache['mtime'] = mtime
|
|
259
|
+
if _manifest_cache['data']:
|
|
260
|
+
return _manifest_cache['data']
|
|
261
|
+
except (json.JSONDecodeError, IOError) as exc:
|
|
262
|
+
logging.getLogger(__name__).warning(f'Failed to load canvas manifest: {exc}')
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
'version': 1,
|
|
266
|
+
'last_updated': datetime.now().isoformat(),
|
|
267
|
+
'categories': {},
|
|
268
|
+
'pages': {},
|
|
269
|
+
'uncategorized': [],
|
|
270
|
+
'recently_viewed': [],
|
|
271
|
+
'user_custom_order': None,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def save_canvas_manifest(manifest: dict) -> None:
|
|
276
|
+
"""Save manifest directly (Docker bind-mounted files don't support atomic rename)."""
|
|
277
|
+
manifest['last_updated'] = datetime.now().isoformat()
|
|
278
|
+
try:
|
|
279
|
+
data = json.dumps(manifest, indent=2)
|
|
280
|
+
with open(CANVAS_MANIFEST_PATH, 'w') as f:
|
|
281
|
+
f.write(data)
|
|
282
|
+
_manifest_cache['mtime'] = 0 # invalidate cache
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
logging.getLogger(__name__).error(f'Failed to save canvas manifest: {exc}')
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def suggest_category(title: str, content: str = '') -> str:
|
|
288
|
+
"""Suggest category based on title and content keywords."""
|
|
289
|
+
text = (title + ' ' + (content or '')[:500]).lower()
|
|
290
|
+
scores = {}
|
|
291
|
+
for category, keywords in CATEGORY_KEYWORDS.items():
|
|
292
|
+
score = sum(3 if kw in text else 0 for kw in keywords)
|
|
293
|
+
if score > 0:
|
|
294
|
+
scores[category] = score
|
|
295
|
+
return max(scores, key=scores.get) if scores else 'uncategorized'
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def generate_voice_aliases(title: str) -> list[str]:
|
|
299
|
+
"""Generate voice-friendly aliases for a page."""
|
|
300
|
+
aliases = []
|
|
301
|
+
name = title.lower()
|
|
302
|
+
aliases.append(name)
|
|
303
|
+
words = name.replace('-', ' ').split()
|
|
304
|
+
if len(words) > 1:
|
|
305
|
+
aliases.extend(words)
|
|
306
|
+
if words:
|
|
307
|
+
aliases.append(f'{words[0]} page')
|
|
308
|
+
return list(set(aliases))[:5]
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def sync_canvas_manifest() -> dict:
|
|
312
|
+
"""Full sync with pages directory."""
|
|
313
|
+
global _last_sync_time
|
|
314
|
+
_last_sync_time = time.time()
|
|
315
|
+
manifest = load_canvas_manifest()
|
|
316
|
+
logger = logging.getLogger(__name__)
|
|
317
|
+
|
|
318
|
+
if not CANVAS_PAGES_DIR.exists():
|
|
319
|
+
logger.warning(f'Canvas pages directory not found: {CANVAS_PAGES_DIR}')
|
|
320
|
+
return manifest
|
|
321
|
+
|
|
322
|
+
existing_files = {p.name for p in CANVAS_PAGES_DIR.glob('*.html')}
|
|
323
|
+
manifest_files = {p.get('filename') for p in manifest['pages'].values()}
|
|
324
|
+
|
|
325
|
+
for filename in existing_files - manifest_files:
|
|
326
|
+
page_id = Path(filename).stem
|
|
327
|
+
filepath = CANVAS_PAGES_DIR / filename
|
|
328
|
+
title = page_id.replace('-', ' ').title()
|
|
329
|
+
try:
|
|
330
|
+
content = filepath.read_text()[:1000]
|
|
331
|
+
except Exception:
|
|
332
|
+
content = ''
|
|
333
|
+
category = suggest_category(title, content)
|
|
334
|
+
manifest['pages'][page_id] = {
|
|
335
|
+
'filename': filename,
|
|
336
|
+
'display_name': title,
|
|
337
|
+
'description': '',
|
|
338
|
+
'category': category,
|
|
339
|
+
'tags': [],
|
|
340
|
+
'created': datetime.fromtimestamp(filepath.stat().st_ctime).isoformat(),
|
|
341
|
+
'modified': datetime.fromtimestamp(filepath.stat().st_mtime).isoformat(),
|
|
342
|
+
'starred': False,
|
|
343
|
+
'voice_aliases': generate_voice_aliases(title),
|
|
344
|
+
'access_count': 0,
|
|
345
|
+
}
|
|
346
|
+
if category not in manifest['categories']:
|
|
347
|
+
manifest['categories'][category] = {
|
|
348
|
+
'name': category.title(),
|
|
349
|
+
'icon': CATEGORY_ICONS.get(category, '📄'),
|
|
350
|
+
'color': CATEGORY_COLORS.get(category, '#4a9eff'),
|
|
351
|
+
'pages': [],
|
|
352
|
+
}
|
|
353
|
+
if page_id not in manifest['categories'][category]['pages']:
|
|
354
|
+
manifest['categories'][category]['pages'].append(page_id)
|
|
355
|
+
# Note: uncategorized pages are managed via manifest['categories']['uncategorized']['pages']
|
|
356
|
+
|
|
357
|
+
# Reconcile: pages registered in pages{} but missing from their category list
|
|
358
|
+
for page_id, page_data in manifest['pages'].items():
|
|
359
|
+
cat = page_data.get('category', 'uncategorized')
|
|
360
|
+
if cat not in manifest['categories']:
|
|
361
|
+
manifest['categories'][cat] = {
|
|
362
|
+
'name': cat.title(),
|
|
363
|
+
'icon': CATEGORY_ICONS.get(cat, '📄'),
|
|
364
|
+
'color': CATEGORY_COLORS.get(cat, '#4a9eff'),
|
|
365
|
+
'pages': [],
|
|
366
|
+
}
|
|
367
|
+
if page_id not in manifest['categories'][cat]['pages']:
|
|
368
|
+
manifest['categories'][cat]['pages'].append(page_id)
|
|
369
|
+
logger.info(f'Reconciled missing category entry: {page_id} → {cat}')
|
|
370
|
+
|
|
371
|
+
deleted_files = manifest_files - existing_files
|
|
372
|
+
for filename in list(deleted_files):
|
|
373
|
+
page_id = Path(filename).stem
|
|
374
|
+
if page_id in manifest['pages']:
|
|
375
|
+
old_cat = manifest['pages'][page_id].get('category')
|
|
376
|
+
if old_cat and old_cat in manifest['categories']:
|
|
377
|
+
if page_id in manifest['categories'][old_cat].get('pages', []):
|
|
378
|
+
manifest['categories'][old_cat]['pages'].remove(page_id)
|
|
379
|
+
if page_id in manifest.get('uncategorized', []):
|
|
380
|
+
manifest['uncategorized'].remove(page_id)
|
|
381
|
+
del manifest['pages'][page_id]
|
|
382
|
+
|
|
383
|
+
save_canvas_manifest(manifest)
|
|
384
|
+
logger.info(f'Canvas manifest synced: {len(manifest["pages"])} pages')
|
|
385
|
+
return manifest
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def add_page_to_manifest(filename: str, title: str, description: str = '', content: str = '') -> dict:
|
|
389
|
+
"""Add or update a page in the manifest (called after page creation/update).
|
|
390
|
+
When updating an existing page, all user-customised fields are preserved —
|
|
391
|
+
only 'modified' and, if explicitly supplied, 'display_name' are touched.
|
|
392
|
+
"""
|
|
393
|
+
manifest = load_canvas_manifest()
|
|
394
|
+
page_id = Path(filename).stem
|
|
395
|
+
category = suggest_category(title, content)
|
|
396
|
+
|
|
397
|
+
is_new_page = False
|
|
398
|
+
if page_id in manifest['pages']:
|
|
399
|
+
# Page already exists — preserve user-customised state (description, starred, etc.)
|
|
400
|
+
existing = manifest['pages'][page_id]
|
|
401
|
+
manifest['pages'][page_id] = {
|
|
402
|
+
**existing,
|
|
403
|
+
'filename': filename,
|
|
404
|
+
'modified': datetime.now().isoformat(),
|
|
405
|
+
# Only update display_name if one is explicitly provided
|
|
406
|
+
'display_name': title if title else existing.get('display_name', page_id),
|
|
407
|
+
# Never clear description — it may hold serialised desktop state or notes
|
|
408
|
+
'description': description[:200] if description else existing.get('description', ''),
|
|
409
|
+
}
|
|
410
|
+
else:
|
|
411
|
+
is_new_page = True
|
|
412
|
+
manifest['pages'][page_id] = {
|
|
413
|
+
'filename': filename,
|
|
414
|
+
'display_name': title,
|
|
415
|
+
'description': description[:200] if description else '',
|
|
416
|
+
'category': category,
|
|
417
|
+
'tags': [],
|
|
418
|
+
'created': datetime.now().isoformat(),
|
|
419
|
+
'modified': datetime.now().isoformat(),
|
|
420
|
+
'starred': False,
|
|
421
|
+
'is_public': False,
|
|
422
|
+
'is_locked': False,
|
|
423
|
+
'voice_aliases': generate_voice_aliases(title),
|
|
424
|
+
'access_count': 0,
|
|
425
|
+
}
|
|
426
|
+
if category not in manifest['categories']:
|
|
427
|
+
manifest['categories'][category] = {
|
|
428
|
+
'name': category.title(),
|
|
429
|
+
'icon': CATEGORY_ICONS.get(category, '📄'),
|
|
430
|
+
'color': CATEGORY_COLORS.get(category, '#4a9eff'),
|
|
431
|
+
'pages': [],
|
|
432
|
+
}
|
|
433
|
+
if page_id not in manifest['categories'][category]['pages']:
|
|
434
|
+
manifest['categories'][category]['pages'].append(page_id)
|
|
435
|
+
if page_id in manifest.get('uncategorized', []):
|
|
436
|
+
manifest['uncategorized'].remove(page_id)
|
|
437
|
+
|
|
438
|
+
# Auto-inject new pages into the desktop state so they appear as icons
|
|
439
|
+
# even when the desktop page isn't actively open in the browser
|
|
440
|
+
if is_new_page and page_id != 'desktop':
|
|
441
|
+
_inject_page_into_desktop_state(manifest, page_id)
|
|
442
|
+
|
|
443
|
+
save_canvas_manifest(manifest)
|
|
444
|
+
return manifest['pages'][page_id]
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _inject_page_into_desktop_state(manifest: dict, page_id: str) -> None:
|
|
448
|
+
"""Inject a newly created page into the desktop's serialised state.
|
|
449
|
+
|
|
450
|
+
The desktop stores its icon layout in the 'description' field of the
|
|
451
|
+
'desktop' page entry as a JSON blob with desktopPages, knownPages, etc.
|
|
452
|
+
When a page is created while the desktop isn't open, it would never get
|
|
453
|
+
added. This ensures every new page appears as a desktop icon immediately.
|
|
454
|
+
"""
|
|
455
|
+
desktop_entry = manifest.get('pages', {}).get('desktop')
|
|
456
|
+
if not desktop_entry:
|
|
457
|
+
return
|
|
458
|
+
desc = desktop_entry.get('description', '')
|
|
459
|
+
if not desc:
|
|
460
|
+
return
|
|
461
|
+
try:
|
|
462
|
+
state = json.loads(desc)
|
|
463
|
+
except (json.JSONDecodeError, TypeError):
|
|
464
|
+
return
|
|
465
|
+
|
|
466
|
+
changed = False
|
|
467
|
+
known = state.get('knownPages', [])
|
|
468
|
+
desktop_pages = state.get('desktopPages', [])
|
|
469
|
+
hidden = state.get('hiddenPages', [])
|
|
470
|
+
recycle = state.get('recycleBin', [])
|
|
471
|
+
|
|
472
|
+
if page_id not in known:
|
|
473
|
+
known.append(page_id)
|
|
474
|
+
changed = True
|
|
475
|
+
# Add to desktop unless user previously recycled/hid it
|
|
476
|
+
if page_id not in desktop_pages and page_id not in hidden and page_id not in recycle:
|
|
477
|
+
desktop_pages.append(page_id)
|
|
478
|
+
changed = True
|
|
479
|
+
|
|
480
|
+
if changed:
|
|
481
|
+
state['knownPages'] = known
|
|
482
|
+
state['desktopPages'] = desktop_pages
|
|
483
|
+
desktop_entry['description'] = json.dumps(state)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def track_page_access(page_id: str) -> None:
|
|
487
|
+
"""Track when a page is accessed (for recently viewed)."""
|
|
488
|
+
manifest = load_canvas_manifest()
|
|
489
|
+
if page_id in manifest['pages']:
|
|
490
|
+
manifest['pages'][page_id]['access_count'] = manifest['pages'][page_id].get('access_count', 0) + 1
|
|
491
|
+
recently = manifest.get('recently_viewed', [])
|
|
492
|
+
if page_id in recently:
|
|
493
|
+
recently.remove(page_id)
|
|
494
|
+
recently.insert(0, page_id)
|
|
495
|
+
manifest['recently_viewed'] = recently[:20]
|
|
496
|
+
save_canvas_manifest(manifest)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ---------------------------------------------------------------------------
|
|
500
|
+
# Blueprint
|
|
501
|
+
# ---------------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
canvas_bp = Blueprint('canvas', __name__)
|
|
504
|
+
logger = logging.getLogger(__name__)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@canvas_bp.route('/api/canvas/update', methods=['POST'])
|
|
508
|
+
def canvas_update():
|
|
509
|
+
"""
|
|
510
|
+
Canvas Display Proxy — forward display commands to Canvas SSE server.
|
|
511
|
+
POST /api/canvas/update
|
|
512
|
+
Body: {"displayOutput": {"type": "page|image|status", "path": "/pages/xyz.html", "title": "Title"}}
|
|
513
|
+
"""
|
|
514
|
+
try:
|
|
515
|
+
data = request.get_json()
|
|
516
|
+
if not data or 'displayOutput' not in data:
|
|
517
|
+
return jsonify({'error': 'Missing displayOutput'}), 400
|
|
518
|
+
|
|
519
|
+
display_output = data['displayOutput']
|
|
520
|
+
display_type = display_output.get('type')
|
|
521
|
+
path = display_output.get('path', '')
|
|
522
|
+
title = display_output.get('title', '')
|
|
523
|
+
|
|
524
|
+
logger.info(f'Canvas update: {display_type} - {title}')
|
|
525
|
+
|
|
526
|
+
if display_type == 'page' and path:
|
|
527
|
+
update_canvas_context(path, title)
|
|
528
|
+
logger.info(f'Canvas context updated: {path}')
|
|
529
|
+
|
|
530
|
+
try:
|
|
531
|
+
canvas_response = http_requests.post(
|
|
532
|
+
f'http://localhost:{CANVAS_SSE_PORT}/update',
|
|
533
|
+
json=data,
|
|
534
|
+
headers={'Content-Type': 'application/json'},
|
|
535
|
+
timeout=5,
|
|
536
|
+
)
|
|
537
|
+
if canvas_response.status_code != 200:
|
|
538
|
+
logger.warning(f'Canvas SSE server error: {canvas_response.status_code}')
|
|
539
|
+
except Exception as sse_exc:
|
|
540
|
+
# SSE server not running — canvas context already updated above, non-fatal
|
|
541
|
+
logger.debug(f'Canvas SSE not available (no live display): {sse_exc}')
|
|
542
|
+
|
|
543
|
+
return jsonify({'success': True, 'message': 'Canvas updated successfully'})
|
|
544
|
+
|
|
545
|
+
except Exception as exc:
|
|
546
|
+
logger.error(f'Canvas update error: {exc}')
|
|
547
|
+
return jsonify({'error': 'Canvas update failed'}), 500
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
@canvas_bp.route('/api/canvas/show', methods=['POST'])
|
|
551
|
+
def canvas_show_page():
|
|
552
|
+
"""
|
|
553
|
+
Quick helper to show a page on canvas.
|
|
554
|
+
POST /api/canvas/show
|
|
555
|
+
Body: {"type": "page", "path": "/pages/test.html", "title": "My Page"}
|
|
556
|
+
"""
|
|
557
|
+
try:
|
|
558
|
+
data = request.get_json()
|
|
559
|
+
path = data.get('path', '')
|
|
560
|
+
if not path:
|
|
561
|
+
return jsonify({'error': 'Missing path'}), 400
|
|
562
|
+
# Delegate to canvas_update (same logic, wraps displayOutput format)
|
|
563
|
+
return canvas_update()
|
|
564
|
+
except Exception as exc:
|
|
565
|
+
logger.error(f'Canvas show error: {exc}')
|
|
566
|
+
return jsonify({'error': 'Canvas operation failed'}), 500
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@canvas_bp.route('/canvas-proxy')
|
|
570
|
+
def canvas_proxy():
|
|
571
|
+
"""Proxy Canvas live.html to serve over HTTPS; rewrites SSE/session URLs."""
|
|
572
|
+
try:
|
|
573
|
+
canvas_path = '/var/www/canvas-display/canvas/live.html'
|
|
574
|
+
with open(canvas_path, 'r') as f:
|
|
575
|
+
html_content = f.read()
|
|
576
|
+
html_content = html_content.replace(f'http://localhost:{CANVAS_SSE_PORT}/events', '/canvas-sse/events')
|
|
577
|
+
html_content = html_content.replace('http://localhost:3030/events', '/canvas-sse/events')
|
|
578
|
+
html_content = html_content.replace('/sse/events', '/canvas-sse/events')
|
|
579
|
+
html_content = html_content.replace('/api/session/', '/canvas-session/')
|
|
580
|
+
return Response(html_content, mimetype='text/html')
|
|
581
|
+
except Exception as exc:
|
|
582
|
+
logger.error(f'Canvas proxy error: {exc}')
|
|
583
|
+
return '<html><body><h1>Canvas Error</h1><p>Internal server error</p></body></html>', 500
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
@canvas_bp.route('/canvas-sse/<path:path>')
|
|
587
|
+
def canvas_sse_proxy(path):
|
|
588
|
+
"""Proxy SSE events from Canvas server."""
|
|
589
|
+
try:
|
|
590
|
+
resp = http_requests.get(
|
|
591
|
+
f'http://localhost:{CANVAS_SSE_PORT}/{path}',
|
|
592
|
+
stream=True,
|
|
593
|
+
headers={'Accept': 'text/event-stream'},
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
def generate():
|
|
597
|
+
for chunk in resp.iter_content(chunk_size=1024):
|
|
598
|
+
if chunk:
|
|
599
|
+
yield chunk
|
|
600
|
+
|
|
601
|
+
return Response(
|
|
602
|
+
generate(),
|
|
603
|
+
mimetype='text/event-stream',
|
|
604
|
+
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'},
|
|
605
|
+
)
|
|
606
|
+
except Exception as exc:
|
|
607
|
+
logger.debug(f'Canvas SSE not available: {exc}')
|
|
608
|
+
return jsonify({'error': 'Canvas SSE not available'}), 503
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _safe_canvas_path(base: str, user_path: str) -> Path | None:
|
|
612
|
+
"""Resolve user_path inside base, rejecting path traversal."""
|
|
613
|
+
try:
|
|
614
|
+
base_p = Path(base).resolve()
|
|
615
|
+
resolved = (base_p / user_path).resolve()
|
|
616
|
+
if base_p == resolved or base_p in resolved.parents:
|
|
617
|
+
return resolved
|
|
618
|
+
except Exception:
|
|
619
|
+
pass
|
|
620
|
+
return None
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@canvas_bp.route('/pages/<path:path>')
|
|
624
|
+
def canvas_pages_proxy(path):
|
|
625
|
+
"""Serve files from Canvas pages directory.
|
|
626
|
+
|
|
627
|
+
Access control:
|
|
628
|
+
- If CANVAS_REQUIRE_AUTH=true: pages with is_public=False require a valid Clerk session token.
|
|
629
|
+
- Default (self-hosted): all pages served without auth.
|
|
630
|
+
"""
|
|
631
|
+
try:
|
|
632
|
+
# Auth check — only when explicitly enabled (opt-in for self-hosted deployments)
|
|
633
|
+
# Skip auth for non-HTML assets (images, icons, CSS) — they're embedded resources
|
|
634
|
+
_is_html = path.endswith('.html')
|
|
635
|
+
if CANVAS_REQUIRE_AUTH and _is_html:
|
|
636
|
+
page_id = Path(path).stem
|
|
637
|
+
manifest = load_canvas_manifest()
|
|
638
|
+
page_meta = manifest.get('pages', {}).get(page_id, {})
|
|
639
|
+
is_public = page_meta.get('is_public', False)
|
|
640
|
+
if not is_public:
|
|
641
|
+
from services.auth import get_token_from_request, verify_clerk_token
|
|
642
|
+
token = get_token_from_request()
|
|
643
|
+
has_cookie = bool(request.cookies.get('__session'))
|
|
644
|
+
has_header = bool(request.headers.get('Authorization', '').startswith('Bearer '))
|
|
645
|
+
logger.info('[canvas-auth] page=%s cookie=%s header=%s token=%s',
|
|
646
|
+
path, has_cookie, has_header, bool(token))
|
|
647
|
+
user_id = verify_clerk_token(token) if token else None
|
|
648
|
+
if not user_id:
|
|
649
|
+
logger.warning('[canvas-auth] DENIED page=%s (no valid token)', path)
|
|
650
|
+
if request.headers.get('Accept', '').startswith('text/html'):
|
|
651
|
+
return redirect('/?redirect=/pages/' + path)
|
|
652
|
+
return 'Unauthorized', 401
|
|
653
|
+
|
|
654
|
+
# P7-T3 security: prevent path traversal
|
|
655
|
+
resolved = _safe_canvas_path(str(CANVAS_PAGES_DIR), path)
|
|
656
|
+
if resolved is None:
|
|
657
|
+
return 'Invalid path', 400
|
|
658
|
+
if resolved.exists():
|
|
659
|
+
# HTML files need custom processing (script stripping, CSS/error injection)
|
|
660
|
+
if path.endswith('.html'):
|
|
661
|
+
with open(resolved, 'rb') as f:
|
|
662
|
+
content = f.read()
|
|
663
|
+
# Strip Tailwind CDN — it's a JS runtime that breaks in sandboxed iframes.
|
|
664
|
+
# Other CDN scripts (Mermaid, etc.) are allowed through and controlled by CSP.
|
|
665
|
+
import re as _re
|
|
666
|
+
content_str = content.decode('utf-8', errors='replace')
|
|
667
|
+
_stripped = _re.sub(
|
|
668
|
+
r'<script\s+[^>]*src\s*=\s*["\']https?://cdn\.tailwindcss\.com[^"\']*["\'][^>]*>\s*</script>',
|
|
669
|
+
'<!-- tailwind CDN stripped — use inline styles instead -->',
|
|
670
|
+
content_str,
|
|
671
|
+
flags=_re.IGNORECASE,
|
|
672
|
+
)
|
|
673
|
+
content = _stripped.encode('utf-8')
|
|
674
|
+
|
|
675
|
+
# Inject base dark-theme fallback + padding for UI chrome clearance.
|
|
676
|
+
# Edge tabs are 44px wide on left+right — safe area is 52px each side.
|
|
677
|
+
# CSS custom props let fixed/absolute elements also honour the safe area.
|
|
678
|
+
_base_css = (
|
|
679
|
+
b'<style id="canvas-base-styles">'
|
|
680
|
+
b':root{'
|
|
681
|
+
b'--canvas-safe-top:0px;'
|
|
682
|
+
b'--canvas-safe-right:52px;'
|
|
683
|
+
b'--canvas-safe-bottom:0px;'
|
|
684
|
+
b'--canvas-safe-left:52px;}'
|
|
685
|
+
b'html,body{'
|
|
686
|
+
b'padding-left:20px!important;'
|
|
687
|
+
b'padding-right:20px!important;'
|
|
688
|
+
b'box-sizing:border-box!important;'
|
|
689
|
+
b'color:#e2e8f0;'
|
|
690
|
+
b'background:#0a0a0a;}'
|
|
691
|
+
b'h1,h2,h3,h4{color:#fff;}'
|
|
692
|
+
b'a{color:#fb923c;}'
|
|
693
|
+
b'</style>'
|
|
694
|
+
)
|
|
695
|
+
# Inject error bridge — posts JS errors back to parent for debugging
|
|
696
|
+
_error_bridge = (
|
|
697
|
+
b'<script id="canvas-error-bridge">'
|
|
698
|
+
b"window.onerror=function(msg,src,line,col,err){"
|
|
699
|
+
b"window.parent.postMessage({type:'canvas-error',"
|
|
700
|
+
b"error:msg,source:src,line:line,col:col},'*');"
|
|
701
|
+
b"};"
|
|
702
|
+
b"window.addEventListener('unhandledrejection',function(e){"
|
|
703
|
+
b"window.parent.postMessage({type:'canvas-error',"
|
|
704
|
+
b"error:'Unhandled promise: '+e.reason},'*');"
|
|
705
|
+
b"});"
|
|
706
|
+
b'</script>'
|
|
707
|
+
)
|
|
708
|
+
# Inject nav() and speak() helpers into every page
|
|
709
|
+
_nav_helpers = (
|
|
710
|
+
b'<script id="canvas-nav-helpers">'
|
|
711
|
+
b'if(!window.nav){window.nav=function(p){'
|
|
712
|
+
b'window.parent.postMessage({type:"canvas-action",action:"navigate",page:p},"*");};}'
|
|
713
|
+
b'if(!window.speak){window.speak=function(t){'
|
|
714
|
+
b'window.parent.postMessage({type:"canvas-action",action:"speak",text:t},"*");};}'
|
|
715
|
+
b'</script>'
|
|
716
|
+
)
|
|
717
|
+
# Inject auth token bridge — parent pushes fresh Clerk JWT,
|
|
718
|
+
# canvas pages use it via authFetch() or _canvasAuthToken
|
|
719
|
+
_auth_bridge = (
|
|
720
|
+
b'<script id="canvas-auth-bridge">'
|
|
721
|
+
b'window._canvasAuthToken=null;'
|
|
722
|
+
b'window.addEventListener("message",function(e){'
|
|
723
|
+
b'if(e.data&&e.data.type==="auth-token"){'
|
|
724
|
+
b'window._canvasAuthToken=e.data.token;}});'
|
|
725
|
+
b'window.authFetch=function(url,opts){'
|
|
726
|
+
b'opts=opts||{};'
|
|
727
|
+
b'if(window._canvasAuthToken){'
|
|
728
|
+
b'opts.headers=Object.assign(opts.headers||{},{"Authorization":"Bearer "+window._canvasAuthToken});}'
|
|
729
|
+
b'return fetch(url,opts);};'
|
|
730
|
+
b'window.parent.postMessage({type:"canvas-action",action:"request-auth-token"},"*");'
|
|
731
|
+
b'</script>'
|
|
732
|
+
)
|
|
733
|
+
_inject = _base_css + _error_bridge
|
|
734
|
+
if b'</head>' in content:
|
|
735
|
+
content = content.replace(b'</head>', _inject + b'</head>', 1)
|
|
736
|
+
else:
|
|
737
|
+
content = _inject + content
|
|
738
|
+
# Inject nav/speak helpers + auth bridge before </body>
|
|
739
|
+
_body_inject = _nav_helpers + _auth_bridge
|
|
740
|
+
if b'</body>' in content:
|
|
741
|
+
content = content.replace(b'</body>', _body_inject + b'</body>', 1)
|
|
742
|
+
else:
|
|
743
|
+
content += _body_inject
|
|
744
|
+
resp = Response(content, mimetype='text/html')
|
|
745
|
+
resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
|
746
|
+
resp.headers['Pragma'] = 'no-cache'
|
|
747
|
+
resp.headers['Expires'] = '0'
|
|
748
|
+
# Canvas-specific CSP: allow inline scripts (interactive pages)
|
|
749
|
+
# but block ALL outbound connections to prevent data exfiltration
|
|
750
|
+
# from prompt-injected scripts. postMessage to parent is still
|
|
751
|
+
# allowed (canvas-action bridge uses it).
|
|
752
|
+
resp.headers['Content-Security-Policy'] = (
|
|
753
|
+
"default-src 'none'; "
|
|
754
|
+
"script-src 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' https://cdn.jsdelivr.net https://games.jam-bot.com blob:; "
|
|
755
|
+
"style-src 'unsafe-inline' https://games.jam-bot.com; "
|
|
756
|
+
"img-src 'self' data: blob: https:; "
|
|
757
|
+
"media-src 'self' blob:; "
|
|
758
|
+
"font-src 'self'; "
|
|
759
|
+
"connect-src 'self' https://games.jam-bot.com; "
|
|
760
|
+
"worker-src blob:; "
|
|
761
|
+
"frame-src 'self' https://*.jam-bot.com"
|
|
762
|
+
)
|
|
763
|
+
return resp
|
|
764
|
+
else:
|
|
765
|
+
# Non-HTML files: use send_file for proper range request support
|
|
766
|
+
# (required for video/audio streaming playback)
|
|
767
|
+
resp = send_file(
|
|
768
|
+
resolved,
|
|
769
|
+
conditional=True,
|
|
770
|
+
max_age=3600,
|
|
771
|
+
)
|
|
772
|
+
# Tell Cloudflare CDN to cache media files explicitly
|
|
773
|
+
resp.headers['CDN-Cache-Control'] = 'public, max-age=86400'
|
|
774
|
+
resp.headers['Accept-Ranges'] = 'bytes'
|
|
775
|
+
return resp
|
|
776
|
+
return 'Page not found', 404
|
|
777
|
+
except Exception as exc:
|
|
778
|
+
logger.error(f'Canvas pages proxy error: {exc}')
|
|
779
|
+
return 'Internal server error', 500
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
@canvas_bp.route('/images/<path:path>')
|
|
783
|
+
def canvas_images_proxy(path):
|
|
784
|
+
"""Serve files from Canvas images directory."""
|
|
785
|
+
try:
|
|
786
|
+
# P7-T3 security: prevent path traversal
|
|
787
|
+
resolved = _safe_canvas_path('/var/www/canvas-display/images', path)
|
|
788
|
+
if resolved is None:
|
|
789
|
+
return 'Invalid path', 400
|
|
790
|
+
if resolved.exists():
|
|
791
|
+
return send_file(resolved)
|
|
792
|
+
return 'Image not found', 404
|
|
793
|
+
except Exception as exc:
|
|
794
|
+
logger.error(f'Canvas images proxy error: {exc}')
|
|
795
|
+
return 'Internal server error', 500
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
# Dev server proxy for website preview in canvas
|
|
799
|
+
WEBSITE_DEV_PORT = int(os.getenv('WEBSITE_DEV_PORT', '15050'))
|
|
800
|
+
|
|
801
|
+
@canvas_bp.route('/website-dev', methods=['GET', 'POST', 'PUT', 'DELETE'], strict_slashes=False)
|
|
802
|
+
@canvas_bp.route('/website-dev/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE'])
|
|
803
|
+
def website_dev_proxy(path=''):
|
|
804
|
+
"""Proxy requests to the local website dev server (for HTTPS canvas compatibility)."""
|
|
805
|
+
import re as re_module
|
|
806
|
+
try:
|
|
807
|
+
dev_url = f'http://localhost:{WEBSITE_DEV_PORT}/{path}'
|
|
808
|
+
if request.method == 'GET':
|
|
809
|
+
resp = http_requests.get(dev_url, params=request.args, timeout=30, stream=True)
|
|
810
|
+
elif request.method == 'POST':
|
|
811
|
+
resp = http_requests.post(dev_url, json=request.get_json(silent=True), data=request.get_data(), timeout=30, stream=True)
|
|
812
|
+
elif request.method == 'PUT':
|
|
813
|
+
resp = http_requests.put(dev_url, json=request.get_json(silent=True), data=request.get_data(), timeout=30, stream=True)
|
|
814
|
+
elif request.method == 'DELETE':
|
|
815
|
+
resp = http_requests.delete(dev_url, timeout=30, stream=True)
|
|
816
|
+
else:
|
|
817
|
+
return 'Method not allowed', 405
|
|
818
|
+
|
|
819
|
+
content_type = resp.headers.get('content-type', '')
|
|
820
|
+
|
|
821
|
+
# For HTML responses, rewrite absolute URLs to go through proxy
|
|
822
|
+
if 'text/html' in content_type:
|
|
823
|
+
content = resp.content.decode('utf-8', errors='replace')
|
|
824
|
+
# Rewrite absolute URLs: src="/..." -> src="/website-dev/..."
|
|
825
|
+
content = re_module.sub(r'(src|href|action)=("|\')/(?!website-dev)', r'\1=\2/website-dev/', content)
|
|
826
|
+
return Response(content.encode('utf-8'), status=resp.status_code, content_type=content_type)
|
|
827
|
+
|
|
828
|
+
def generate():
|
|
829
|
+
for chunk in resp.iter_content(chunk_size=8192):
|
|
830
|
+
if chunk:
|
|
831
|
+
yield chunk
|
|
832
|
+
|
|
833
|
+
# Forward content type and other relevant headers
|
|
834
|
+
excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection']
|
|
835
|
+
headers = [(k, v) for k, v in resp.headers.items() if k.lower() not in excluded_headers]
|
|
836
|
+
|
|
837
|
+
return Response(generate(), status=resp.status_code, headers=headers)
|
|
838
|
+
except Exception as exc:
|
|
839
|
+
logger.error(f'Website dev proxy error: {exc}')
|
|
840
|
+
return 'Dev server unavailable', 503
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
# ---------------------------------------------------------------------------
|
|
844
|
+
# OpenClaw Control UI proxy — serves the built-in dashboard behind Clerk auth
|
|
845
|
+
# ---------------------------------------------------------------------------
|
|
846
|
+
|
|
847
|
+
@canvas_bp.route('/openclaw-ui/')
|
|
848
|
+
@canvas_bp.route('/openclaw-ui/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
|
|
849
|
+
def openclaw_ui_proxy(path=''):
|
|
850
|
+
"""Proxy the OpenClaw Control UI behind Clerk auth.
|
|
851
|
+
|
|
852
|
+
Routes all HTTP requests to the internal openclaw gateway container which
|
|
853
|
+
serves the built-in dashboard SPA at its basePath (/openclaw-ui).
|
|
854
|
+
Clerk auth is enforced by the require_auth() before_request handler —
|
|
855
|
+
this path is NOT in the public prefixes.
|
|
856
|
+
"""
|
|
857
|
+
target_url = f'http://openclaw:18789/openclaw-ui/{path}'
|
|
858
|
+
|
|
859
|
+
try:
|
|
860
|
+
kwargs = dict(params=request.args, timeout=30, stream=True)
|
|
861
|
+
if request.method in ('POST', 'PUT', 'PATCH'):
|
|
862
|
+
kwargs['data'] = request.get_data()
|
|
863
|
+
if request.content_type:
|
|
864
|
+
kwargs['headers'] = {'Content-Type': request.content_type}
|
|
865
|
+
|
|
866
|
+
resp = getattr(http_requests, request.method.lower())(target_url, **kwargs)
|
|
867
|
+
|
|
868
|
+
def generate():
|
|
869
|
+
for chunk in resp.iter_content(chunk_size=8192):
|
|
870
|
+
if chunk:
|
|
871
|
+
yield chunk
|
|
872
|
+
|
|
873
|
+
# Strip headers that interfere with iframe/proxy rendering
|
|
874
|
+
excluded_headers = [
|
|
875
|
+
'content-encoding', 'content-length', 'transfer-encoding',
|
|
876
|
+
'connection', 'x-frame-options',
|
|
877
|
+
]
|
|
878
|
+
headers = [(k, v) for k, v in resp.headers.items()
|
|
879
|
+
if k.lower() not in excluded_headers]
|
|
880
|
+
|
|
881
|
+
return Response(generate(), status=resp.status_code, headers=headers)
|
|
882
|
+
except Exception as exc:
|
|
883
|
+
logger.error(f'OpenClaw UI proxy error: {exc}')
|
|
884
|
+
return 'OpenClaw Control UI unavailable', 503
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
@canvas_bp.route('/canvas-session/<path:path>', methods=['GET', 'POST'])
|
|
888
|
+
def canvas_session_proxy(path):
|
|
889
|
+
"""Proxy Canvas session API requests."""
|
|
890
|
+
_default_session = {
|
|
891
|
+
'id': 'default',
|
|
892
|
+
'stats': {'imageCount': 0, 'pageCount': 0, 'dataCount': 0, 'commandCount': 0},
|
|
893
|
+
'outputs': {'images': [], 'pages': [], 'data': [], 'commands': []},
|
|
894
|
+
'timestamp': '',
|
|
895
|
+
}
|
|
896
|
+
try:
|
|
897
|
+
if request.method == 'GET':
|
|
898
|
+
resp = http_requests.get(f'http://localhost:{CANVAS_SESSION_PORT}/api/session/{path}', timeout=5)
|
|
899
|
+
else:
|
|
900
|
+
resp = http_requests.post(
|
|
901
|
+
f'http://localhost:{CANVAS_SESSION_PORT}/api/session/{path}',
|
|
902
|
+
json=request.get_json(),
|
|
903
|
+
headers={'Content-Type': 'application/json'},
|
|
904
|
+
timeout=5,
|
|
905
|
+
)
|
|
906
|
+
try:
|
|
907
|
+
return jsonify(resp.json()), resp.status_code
|
|
908
|
+
except Exception:
|
|
909
|
+
return jsonify(_default_session), 200
|
|
910
|
+
except Exception as exc:
|
|
911
|
+
logger.error(f'Canvas session proxy error: {exc}')
|
|
912
|
+
return jsonify(_default_session), 200
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
@canvas_bp.route('/api/canvas/context', methods=['POST'])
|
|
916
|
+
def update_canvas_route():
|
|
917
|
+
"""Receive canvas context from frontend — what page is being displayed."""
|
|
918
|
+
data = request.get_json() or {}
|
|
919
|
+
page_path = data.get('page', '')
|
|
920
|
+
title = data.get('title', '')
|
|
921
|
+
content_summary = data.get('content_summary', '')
|
|
922
|
+
update_canvas_context(page_path, title, content_summary)
|
|
923
|
+
return jsonify({'status': 'ok', 'current_page': page_path})
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
@canvas_bp.route('/api/canvas/context', methods=['GET'])
|
|
927
|
+
def get_canvas_route():
|
|
928
|
+
"""Get current canvas context."""
|
|
929
|
+
return jsonify(canvas_context)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
@canvas_bp.route('/api/canvas/manifest', methods=['GET'])
|
|
933
|
+
def get_canvas_manifest():
|
|
934
|
+
"""Get full canvas manifest with all pages and categories.
|
|
935
|
+
|
|
936
|
+
Auto-syncs with the filesystem (throttled to once per 60s) so that
|
|
937
|
+
pages written directly by agents appear without a manual sync call.
|
|
938
|
+
Pass ?sync=1 to force an immediate sync (bypasses throttle).
|
|
939
|
+
"""
|
|
940
|
+
global _last_sync_time
|
|
941
|
+
force_sync = request.args.get('sync') == '1'
|
|
942
|
+
now = time.time()
|
|
943
|
+
if force_sync or now - _last_sync_time >= _SYNC_THROTTLE_SECONDS:
|
|
944
|
+
_last_sync_time = now
|
|
945
|
+
manifest = sync_canvas_manifest()
|
|
946
|
+
else:
|
|
947
|
+
manifest = load_canvas_manifest()
|
|
948
|
+
response = jsonify(manifest)
|
|
949
|
+
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
|
950
|
+
response.headers['Pragma'] = 'no-cache'
|
|
951
|
+
response.headers['Expires'] = '0'
|
|
952
|
+
return response
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
@canvas_bp.route('/api/canvas/manifest/sync', methods=['POST'])
|
|
956
|
+
def sync_manifest():
|
|
957
|
+
"""Sync manifest with pages directory — adds new pages, removes deleted."""
|
|
958
|
+
manifest = sync_canvas_manifest()
|
|
959
|
+
return jsonify({
|
|
960
|
+
'status': 'ok',
|
|
961
|
+
'pages_count': len(manifest['pages']),
|
|
962
|
+
'categories_count': len(manifest['categories']),
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
@canvas_bp.route('/api/canvas/manifest/page/<page_id>', methods=['GET', 'PATCH', 'DELETE'])
|
|
967
|
+
def handle_page_metadata(page_id):
|
|
968
|
+
"""Get, update, or delete page metadata."""
|
|
969
|
+
manifest = load_canvas_manifest()
|
|
970
|
+
|
|
971
|
+
if page_id not in manifest['pages']:
|
|
972
|
+
return jsonify({'error': 'Page not found'}), 404
|
|
973
|
+
|
|
974
|
+
if request.method == 'GET':
|
|
975
|
+
return jsonify(manifest['pages'][page_id])
|
|
976
|
+
|
|
977
|
+
if request.method == 'DELETE':
|
|
978
|
+
page = manifest['pages'][page_id]
|
|
979
|
+
filename = page.get('filename')
|
|
980
|
+
page_title = page.get('display_name', page_id)
|
|
981
|
+
logger.info(f'Deleting canvas page: {page_title} ({filename})')
|
|
982
|
+
|
|
983
|
+
old_category = page.get('category')
|
|
984
|
+
if old_category and old_category in manifest['categories']:
|
|
985
|
+
if page_id in manifest['categories'][old_category].get('pages', []):
|
|
986
|
+
manifest['categories'][old_category]['pages'].remove(page_id)
|
|
987
|
+
if page_id in manifest.get('uncategorized', []):
|
|
988
|
+
manifest['uncategorized'].remove(page_id)
|
|
989
|
+
if page_id in manifest.get('recently_viewed', []):
|
|
990
|
+
manifest['recently_viewed'].remove(page_id)
|
|
991
|
+
|
|
992
|
+
del manifest['pages'][page_id]
|
|
993
|
+
|
|
994
|
+
# Clear canvas_context if this was the current page
|
|
995
|
+
global canvas_context
|
|
996
|
+
current_page = canvas_context.get('current_page') or ''
|
|
997
|
+
if filename and current_page.endswith(filename):
|
|
998
|
+
canvas_context['current_page'] = None
|
|
999
|
+
canvas_context['current_title'] = None
|
|
1000
|
+
canvas_context['page_content'] = None
|
|
1001
|
+
logger.info('Cleared canvas context (deleted page was current)')
|
|
1002
|
+
|
|
1003
|
+
# Refresh all_pages list
|
|
1004
|
+
try:
|
|
1005
|
+
if CANVAS_PAGES_DIR.exists():
|
|
1006
|
+
pages = sorted(CANVAS_PAGES_DIR.glob('*.html'), key=lambda p: p.stat().st_mtime, reverse=True)[:30]
|
|
1007
|
+
canvas_context['all_pages'] = [
|
|
1008
|
+
{'name': p.name, 'title': p.stem.replace('-', ' '), 'mtime': p.stat().st_mtime}
|
|
1009
|
+
for p in pages
|
|
1010
|
+
]
|
|
1011
|
+
except Exception as exc:
|
|
1012
|
+
logger.warning(f'Failed to refresh all_pages: {exc}')
|
|
1013
|
+
|
|
1014
|
+
# Archive the file (rename to .bak)
|
|
1015
|
+
if filename:
|
|
1016
|
+
filepath = CANVAS_PAGES_DIR / filename
|
|
1017
|
+
try:
|
|
1018
|
+
if filepath.exists():
|
|
1019
|
+
bak_path = filepath.with_suffix('.bak')
|
|
1020
|
+
counter = 1
|
|
1021
|
+
while bak_path.exists():
|
|
1022
|
+
bak_path = filepath.with_name(f'{filepath.stem}.bak.{counter}')
|
|
1023
|
+
counter += 1
|
|
1024
|
+
filepath.rename(bak_path)
|
|
1025
|
+
logger.info(f'Archived canvas page: {filename} -> {bak_path.name}')
|
|
1026
|
+
except Exception as exc:
|
|
1027
|
+
logger.warning(f'Failed to archive file {filename}: {exc}')
|
|
1028
|
+
|
|
1029
|
+
save_canvas_manifest(manifest)
|
|
1030
|
+
_notify_brain('canvas_page_deleted', page_id=page_id, title=page_title, filename=filename)
|
|
1031
|
+
|
|
1032
|
+
try:
|
|
1033
|
+
http_requests.post(
|
|
1034
|
+
f'http://localhost:{CANVAS_SSE_PORT}/clear-display',
|
|
1035
|
+
json={'path': f'/pages/{filename}'},
|
|
1036
|
+
timeout=2,
|
|
1037
|
+
)
|
|
1038
|
+
except Exception as exc:
|
|
1039
|
+
logger.debug(f'Could not clear canvas display: {exc}')
|
|
1040
|
+
|
|
1041
|
+
return jsonify({'status': 'ok', 'message': 'Page archived', 'page_id': page_id, 'title': page_title})
|
|
1042
|
+
|
|
1043
|
+
# PATCH — update metadata
|
|
1044
|
+
data = request.get_json() or {}
|
|
1045
|
+
page = manifest['pages'][page_id]
|
|
1046
|
+
|
|
1047
|
+
# Detect agent requests (X-Agent-Key header) vs admin requests (Clerk JWT)
|
|
1048
|
+
_agent_api_key = os.getenv('AGENT_API_KEY', '').strip()
|
|
1049
|
+
is_agent_request = bool(_agent_api_key and request.headers.get('X-Agent-Key') == _agent_api_key)
|
|
1050
|
+
|
|
1051
|
+
# Guard: locked pages — agent cannot change is_public on locked pages.
|
|
1052
|
+
# Admin (Clerk-authenticated) can still change anything, including unlocking.
|
|
1053
|
+
if 'is_public' in data and page.get('is_locked', False) and is_agent_request:
|
|
1054
|
+
return jsonify({
|
|
1055
|
+
'error': 'This page is locked. Visibility can only be changed from the admin dashboard.',
|
|
1056
|
+
'is_locked': True,
|
|
1057
|
+
}), 403
|
|
1058
|
+
|
|
1059
|
+
# Guard: agent cannot lock/unlock pages — only admin can.
|
|
1060
|
+
if 'is_locked' in data and is_agent_request:
|
|
1061
|
+
return jsonify({
|
|
1062
|
+
'error': 'Page lock status can only be changed from the admin dashboard.',
|
|
1063
|
+
}), 403
|
|
1064
|
+
|
|
1065
|
+
# Guard: reject is_public=True if page was created less than 30 seconds ago.
|
|
1066
|
+
# Prevents agents from making pages public immediately on creation.
|
|
1067
|
+
if data.get('is_public') is True:
|
|
1068
|
+
created_str = page.get('created', '')
|
|
1069
|
+
if created_str:
|
|
1070
|
+
try:
|
|
1071
|
+
created_dt = datetime.fromisoformat(created_str)
|
|
1072
|
+
age_seconds = (datetime.now() - created_dt).total_seconds()
|
|
1073
|
+
if age_seconds < 30:
|
|
1074
|
+
return jsonify({
|
|
1075
|
+
'error': 'Cannot make a page public within 30 seconds of creation. '
|
|
1076
|
+
'Wait a moment and try again.',
|
|
1077
|
+
'age_seconds': round(age_seconds, 1),
|
|
1078
|
+
}), 429
|
|
1079
|
+
except (ValueError, TypeError):
|
|
1080
|
+
pass # malformed date — allow through
|
|
1081
|
+
|
|
1082
|
+
for field in ['display_name', 'description', 'category', 'tags', 'starred', 'is_public', 'is_locked', 'icon']:
|
|
1083
|
+
if field in data:
|
|
1084
|
+
old_category = page.get('category')
|
|
1085
|
+
page[field] = data[field]
|
|
1086
|
+
|
|
1087
|
+
if field == 'category' and old_category != data[field]:
|
|
1088
|
+
if old_category and old_category in manifest['categories']:
|
|
1089
|
+
if page_id in manifest['categories'][old_category].get('pages', []):
|
|
1090
|
+
manifest['categories'][old_category]['pages'].remove(page_id)
|
|
1091
|
+
if old_category == 'uncategorized' and page_id in manifest.get('uncategorized', []):
|
|
1092
|
+
manifest['uncategorized'].remove(page_id)
|
|
1093
|
+
|
|
1094
|
+
new_cat = data[field]
|
|
1095
|
+
if new_cat not in manifest['categories']:
|
|
1096
|
+
manifest['categories'][new_cat] = {
|
|
1097
|
+
'name': new_cat.title(),
|
|
1098
|
+
'icon': CATEGORY_ICONS.get(new_cat, '📄'),
|
|
1099
|
+
'color': CATEGORY_COLORS.get(new_cat, '#4a9eff'),
|
|
1100
|
+
'pages': [],
|
|
1101
|
+
}
|
|
1102
|
+
if page_id not in manifest['categories'][new_cat]['pages']:
|
|
1103
|
+
manifest['categories'][new_cat]['pages'].append(page_id)
|
|
1104
|
+
|
|
1105
|
+
save_canvas_manifest(manifest)
|
|
1106
|
+
return jsonify({'status': 'ok', 'page': page})
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
@canvas_bp.route('/api/canvas/manifest/category', methods=['GET', 'POST', 'PATCH'])
|
|
1110
|
+
def handle_category():
|
|
1111
|
+
"""List, create, or update categories."""
|
|
1112
|
+
manifest = load_canvas_manifest()
|
|
1113
|
+
|
|
1114
|
+
if request.method == 'GET':
|
|
1115
|
+
return jsonify(manifest.get('categories', {}))
|
|
1116
|
+
|
|
1117
|
+
if request.method == 'POST':
|
|
1118
|
+
data = request.get_json() or {}
|
|
1119
|
+
cat_id = data.get('id', '').lower().replace(' ', '-')
|
|
1120
|
+
if not cat_id:
|
|
1121
|
+
return jsonify({'error': 'Category ID required'}), 400
|
|
1122
|
+
manifest['categories'][cat_id] = {
|
|
1123
|
+
'name': data.get('name', cat_id.title()),
|
|
1124
|
+
'icon': data.get('icon', '📄'),
|
|
1125
|
+
'color': data.get('color', '#4a9eff'),
|
|
1126
|
+
'pages': [],
|
|
1127
|
+
}
|
|
1128
|
+
save_canvas_manifest(manifest)
|
|
1129
|
+
return jsonify({'status': 'ok', 'category': manifest['categories'][cat_id]})
|
|
1130
|
+
|
|
1131
|
+
# PATCH
|
|
1132
|
+
data = request.get_json() or {}
|
|
1133
|
+
cat_id = data.get('id')
|
|
1134
|
+
if not cat_id or cat_id not in manifest['categories']:
|
|
1135
|
+
return jsonify({'error': 'Category not found'}), 404
|
|
1136
|
+
for field in ['name', 'icon', 'color']:
|
|
1137
|
+
if field in data:
|
|
1138
|
+
manifest['categories'][cat_id][field] = data[field]
|
|
1139
|
+
save_canvas_manifest(manifest)
|
|
1140
|
+
return jsonify({'status': 'ok', 'category': manifest['categories'][cat_id]})
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
@canvas_bp.route('/api/canvas/manifest/access/<page_id>', methods=['POST'])
|
|
1144
|
+
def track_access(page_id):
|
|
1145
|
+
"""Track page access (for recently viewed and access count)."""
|
|
1146
|
+
track_page_access(page_id)
|
|
1147
|
+
return jsonify({'status': 'ok'})
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
@canvas_bp.route('/api/canvas/pages', methods=['POST'])
|
|
1151
|
+
def create_canvas_page():
|
|
1152
|
+
"""
|
|
1153
|
+
Save a new canvas page from HTML content.
|
|
1154
|
+
POST /api/canvas/pages
|
|
1155
|
+
Body: {"filename": "my-page.html", "html": "<html>...</html>", "title": "My Page"}
|
|
1156
|
+
Returns: {"filename": "my-page.html", "page_id": "my-page", "url": "/pages/my-page.html"}
|
|
1157
|
+
"""
|
|
1158
|
+
try:
|
|
1159
|
+
data = request.get_json()
|
|
1160
|
+
if not data or 'html' not in data:
|
|
1161
|
+
return jsonify({'error': 'Missing html content'}), 400
|
|
1162
|
+
|
|
1163
|
+
html_content = data['html']
|
|
1164
|
+
title = data.get('title', 'Canvas Page')
|
|
1165
|
+
|
|
1166
|
+
# Derive filename from title if not provided
|
|
1167
|
+
raw_filename = data.get('filename', '')
|
|
1168
|
+
if not raw_filename:
|
|
1169
|
+
slug = re.sub(r'[^a-z0-9]+', '-', title.lower()).strip('-')
|
|
1170
|
+
raw_filename = f'{slug}.html'
|
|
1171
|
+
|
|
1172
|
+
# Guard: protected system pages cannot be overwritten via this API.
|
|
1173
|
+
# desktop.html and file-explorer.html are OS infrastructure — their HTML
|
|
1174
|
+
# is maintained by admins, not agents. State is in the manifest description.
|
|
1175
|
+
_PROTECTED_PAGES = {'desktop.html', 'file-explorer.html'}
|
|
1176
|
+
if Path(raw_filename).name in _PROTECTED_PAGES:
|
|
1177
|
+
return jsonify({
|
|
1178
|
+
'error': f'{Path(raw_filename).name} is a protected system page and cannot be overwritten. '
|
|
1179
|
+
'To update desktop icons or layout, use the desktop UI or ask the admin.',
|
|
1180
|
+
}), 403
|
|
1181
|
+
|
|
1182
|
+
# Sanitize: strip directory traversal, ensure .html
|
|
1183
|
+
filename = Path(raw_filename).name
|
|
1184
|
+
if not filename.endswith('.html'):
|
|
1185
|
+
filename += '.html'
|
|
1186
|
+
|
|
1187
|
+
CANVAS_PAGES_DIR.mkdir(parents=True, exist_ok=True)
|
|
1188
|
+
filepath = CANVAS_PAGES_DIR / filename
|
|
1189
|
+
|
|
1190
|
+
filepath.write_text(html_content, encoding='utf-8')
|
|
1191
|
+
logger.info(f'Canvas page saved: {filename} ({len(html_content)} bytes)')
|
|
1192
|
+
|
|
1193
|
+
page_meta = add_page_to_manifest(filename, title, content=html_content[:500])
|
|
1194
|
+
_notify_brain('canvas_page_created', filename=filename, title=title)
|
|
1195
|
+
|
|
1196
|
+
return jsonify({
|
|
1197
|
+
'filename': filename,
|
|
1198
|
+
'page_id': Path(filename).stem,
|
|
1199
|
+
'url': f'/pages/{filename}',
|
|
1200
|
+
'title': title,
|
|
1201
|
+
'category': page_meta.get('category', 'uncategorized'),
|
|
1202
|
+
})
|
|
1203
|
+
except Exception as exc:
|
|
1204
|
+
logger.error(f'Canvas page create error: {exc}')
|
|
1205
|
+
return jsonify({'error': 'Canvas page creation failed'}), 500
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
# ---------------------------------------------------------------------------
|
|
1209
|
+
# System page data API — serves JSON from _data/ inside canvas-pages dir
|
|
1210
|
+
# Both openclaw and openvoiceui containers mount canvas-pages, so _data/
|
|
1211
|
+
# is the shared bridge for system page data (autopilot stats, inbox, etc.)
|
|
1212
|
+
# ---------------------------------------------------------------------------
|
|
1213
|
+
_CANVAS_DATA_DIR = CANVAS_PAGES_DIR / '_data'
|
|
1214
|
+
|
|
1215
|
+
@canvas_bp.route('/api/canvas/data/<path:filename>', methods=['GET'])
|
|
1216
|
+
def canvas_data(filename):
|
|
1217
|
+
"""Serve JSON data files for system canvas pages.
|
|
1218
|
+
|
|
1219
|
+
Reads from canvas-pages/_data/ directory.
|
|
1220
|
+
Returns empty {} if file doesn't exist yet (graceful empty state).
|
|
1221
|
+
"""
|
|
1222
|
+
if not filename.endswith('.json'):
|
|
1223
|
+
return jsonify({'error': 'only .json files'}), 400
|
|
1224
|
+
resolved = _safe_canvas_path(str(_CANVAS_DATA_DIR), filename)
|
|
1225
|
+
if resolved and resolved.exists() and resolved.is_file():
|
|
1226
|
+
try:
|
|
1227
|
+
return Response(resolved.read_bytes(), mimetype='application/json',
|
|
1228
|
+
headers={'Cache-Control': 'no-cache'})
|
|
1229
|
+
except Exception as exc:
|
|
1230
|
+
logger.error(f'canvas_data read error: {exc}')
|
|
1231
|
+
return jsonify({}), 200
|
|
1232
|
+
return jsonify({}), 200
|
|
1233
|
+
|
|
1234
|
+
@canvas_bp.route('/api/canvas/data/<path:filename>', methods=['POST'])
|
|
1235
|
+
def canvas_data_write(filename):
|
|
1236
|
+
"""Write JSON data from canvas pages (e.g. approval actions)."""
|
|
1237
|
+
if not filename.endswith('.json'):
|
|
1238
|
+
return jsonify({'error': 'only .json files'}), 400
|
|
1239
|
+
data = request.get_json(silent=True)
|
|
1240
|
+
if data is None:
|
|
1241
|
+
return jsonify({'error': 'invalid json'}), 400
|
|
1242
|
+
resolved = _safe_canvas_path(str(_CANVAS_DATA_DIR), filename)
|
|
1243
|
+
if resolved is None:
|
|
1244
|
+
return jsonify({'error': 'invalid path'}), 400
|
|
1245
|
+
try:
|
|
1246
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
1247
|
+
resolved.write_text(json.dumps(data, indent=2), encoding='utf-8')
|
|
1248
|
+
return jsonify({'ok': True})
|
|
1249
|
+
except Exception as exc:
|
|
1250
|
+
logger.error(f'canvas_data write error: {exc}')
|
|
1251
|
+
return jsonify({'error': str(exc)}), 500
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
@canvas_bp.route('/api/canvas/mtime/<path:filename>', methods=['GET'])
|
|
1255
|
+
def canvas_mtime(filename):
|
|
1256
|
+
"""Return last modified time of a canvas page (frontend uses to detect changes)."""
|
|
1257
|
+
resolved = _safe_canvas_path(str(CANVAS_PAGES_DIR), filename)
|
|
1258
|
+
if resolved is None or not resolved.exists() or not resolved.is_file():
|
|
1259
|
+
return jsonify({'error': 'not found'}), 404
|
|
1260
|
+
mtime = resolved.stat().st_mtime
|
|
1261
|
+
return jsonify({'mtime': mtime, 'filename': filename})
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
# ---------------------------------------------------------------------------
|
|
1265
|
+
# Canvas Page Version History
|
|
1266
|
+
# ---------------------------------------------------------------------------
|
|
1267
|
+
|
|
1268
|
+
@canvas_bp.route('/api/canvas/versions/<page_id>', methods=['GET'])
|
|
1269
|
+
def get_page_versions(page_id):
|
|
1270
|
+
"""List all saved versions of a canvas page.
|
|
1271
|
+
GET /api/canvas/versions/my-dashboard
|
|
1272
|
+
Returns: {"page_id": "my-dashboard", "versions": [...], "count": N}
|
|
1273
|
+
"""
|
|
1274
|
+
versions = list_versions(page_id)
|
|
1275
|
+
return jsonify({
|
|
1276
|
+
'page_id': page_id,
|
|
1277
|
+
'versions': versions,
|
|
1278
|
+
'count': len(versions),
|
|
1279
|
+
})
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
@canvas_bp.route('/api/canvas/versions/<page_id>/<int:timestamp>', methods=['GET'])
|
|
1283
|
+
def preview_version(page_id, timestamp):
|
|
1284
|
+
"""Preview a specific version's HTML content.
|
|
1285
|
+
GET /api/canvas/versions/my-dashboard/1709510400
|
|
1286
|
+
Returns the HTML content directly.
|
|
1287
|
+
"""
|
|
1288
|
+
content = get_version_content(page_id, timestamp)
|
|
1289
|
+
if content is None:
|
|
1290
|
+
return jsonify({'error': 'Version not found'}), 404
|
|
1291
|
+
return Response(content, mimetype='text/html')
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
@canvas_bp.route('/api/canvas/versions/<page_id>/<int:timestamp>/restore', methods=['POST'])
|
|
1295
|
+
def restore_page_version(page_id, timestamp):
|
|
1296
|
+
"""Restore a canvas page to a previous version.
|
|
1297
|
+
POST /api/canvas/versions/my-dashboard/1709510400/restore
|
|
1298
|
+
Saves the current version before restoring.
|
|
1299
|
+
"""
|
|
1300
|
+
success = restore_version(page_id, timestamp)
|
|
1301
|
+
if not success:
|
|
1302
|
+
return jsonify({'error': 'Version not found or restore failed'}), 404
|
|
1303
|
+
|
|
1304
|
+
# Update manifest modified time
|
|
1305
|
+
manifest = load_canvas_manifest()
|
|
1306
|
+
if page_id in manifest.get('pages', {}):
|
|
1307
|
+
manifest['pages'][page_id]['modified'] = datetime.now().isoformat()
|
|
1308
|
+
save_canvas_manifest(manifest)
|
|
1309
|
+
|
|
1310
|
+
return jsonify({
|
|
1311
|
+
'status': 'ok',
|
|
1312
|
+
'page_id': page_id,
|
|
1313
|
+
'restored_from': timestamp,
|
|
1314
|
+
'message': f'Page restored to version from {time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp))}',
|
|
1315
|
+
})
|