openvoiceui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/.env.example +104 -0
  2. package/Dockerfile +30 -0
  3. package/LICENSE +21 -0
  4. package/README.md +638 -0
  5. package/SETUP.md +360 -0
  6. package/app.py +232 -0
  7. package/auto-approve-devices.js +111 -0
  8. package/cli/index.js +372 -0
  9. package/config/__init__.py +4 -0
  10. package/config/default.yaml +43 -0
  11. package/config/flags.yaml +67 -0
  12. package/config/loader.py +203 -0
  13. package/config/providers.yaml +71 -0
  14. package/config/speech_normalization.yaml +182 -0
  15. package/config/theme.json +4 -0
  16. package/data/greetings.json +25 -0
  17. package/default-pages/ai-image-creator.html +915 -0
  18. package/default-pages/bulk-image-uploader.html +492 -0
  19. package/default-pages/desktop.html +2865 -0
  20. package/default-pages/file-explorer.html +854 -0
  21. package/default-pages/interactive-map.html +655 -0
  22. package/default-pages/style-guide.html +1005 -0
  23. package/default-pages/website-setup.html +1623 -0
  24. package/deploy/openclaw/Dockerfile +46 -0
  25. package/deploy/openvoiceui.service +30 -0
  26. package/deploy/setup-nginx.sh +50 -0
  27. package/deploy/setup-sudo.sh +306 -0
  28. package/deploy/skill-runner/Dockerfile +19 -0
  29. package/deploy/skill-runner/requirements.txt +14 -0
  30. package/deploy/skill-runner/server.py +269 -0
  31. package/deploy/supertonic/Dockerfile +22 -0
  32. package/deploy/supertonic/server.py +79 -0
  33. package/docker-compose.pinokio.yml +11 -0
  34. package/docker-compose.yml +59 -0
  35. package/greetings.json +25 -0
  36. package/index.html +65 -0
  37. package/inject-device-identity.js +142 -0
  38. package/package.json +82 -0
  39. package/profiles/default.json +114 -0
  40. package/profiles/manager.py +354 -0
  41. package/profiles/schema.json +337 -0
  42. package/prompts/voice-system-prompt.md +149 -0
  43. package/providers/__init__.py +39 -0
  44. package/providers/base.py +63 -0
  45. package/providers/llm/__init__.py +12 -0
  46. package/providers/llm/base.py +71 -0
  47. package/providers/llm/clawdbot_provider.py +112 -0
  48. package/providers/llm/zai_provider.py +115 -0
  49. package/providers/registry.py +320 -0
  50. package/providers/stt/__init__.py +12 -0
  51. package/providers/stt/base.py +58 -0
  52. package/providers/stt/webspeech_provider.py +49 -0
  53. package/providers/stt/whisper_provider.py +100 -0
  54. package/providers/tts/__init__.py +20 -0
  55. package/providers/tts/base.py +91 -0
  56. package/providers/tts/groq_provider.py +74 -0
  57. package/providers/tts/supertonic_provider.py +72 -0
  58. package/requirements.txt +38 -0
  59. package/routes/__init__.py +10 -0
  60. package/routes/admin.py +515 -0
  61. package/routes/canvas.py +1315 -0
  62. package/routes/chat.py +51 -0
  63. package/routes/conversation.py +2158 -0
  64. package/routes/elevenlabs_hybrid.py +306 -0
  65. package/routes/greetings.py +98 -0
  66. package/routes/icons.py +279 -0
  67. package/routes/image_gen.py +364 -0
  68. package/routes/instructions.py +190 -0
  69. package/routes/music.py +838 -0
  70. package/routes/onboarding.py +43 -0
  71. package/routes/pi.py +62 -0
  72. package/routes/profiles.py +215 -0
  73. package/routes/report_issue.py +68 -0
  74. package/routes/static_files.py +533 -0
  75. package/routes/suno.py +664 -0
  76. package/routes/theme.py +81 -0
  77. package/routes/transcripts.py +199 -0
  78. package/routes/vision.py +348 -0
  79. package/routes/workspace.py +288 -0
  80. package/server.py +1510 -0
  81. package/services/__init__.py +1 -0
  82. package/services/auth.py +143 -0
  83. package/services/canvas_versioning.py +239 -0
  84. package/services/db_pool.py +107 -0
  85. package/services/gateway.py +16 -0
  86. package/services/gateway_manager.py +333 -0
  87. package/services/gateways/__init__.py +12 -0
  88. package/services/gateways/base.py +110 -0
  89. package/services/gateways/compat.py +264 -0
  90. package/services/gateways/openclaw.py +1134 -0
  91. package/services/health.py +100 -0
  92. package/services/memory_client.py +455 -0
  93. package/services/paths.py +26 -0
  94. package/services/speech_normalizer.py +285 -0
  95. package/services/tts.py +270 -0
  96. package/setup-config.js +262 -0
  97. package/sounds/air_horn.mp3 +0 -0
  98. package/sounds/bruh.mp3 +0 -0
  99. package/sounds/crowd_cheer.mp3 +0 -0
  100. package/sounds/gunshot.mp3 +0 -0
  101. package/sounds/impact.mp3 +0 -0
  102. package/sounds/lets_go.mp3 +0 -0
  103. package/sounds/record_stop.mp3 +0 -0
  104. package/sounds/rewind.mp3 +0 -0
  105. package/sounds/sad_trombone.mp3 +0 -0
  106. package/sounds/scratch_long.mp3 +0 -0
  107. package/sounds/yeah.mp3 +0 -0
  108. package/src/adapters/ClawdBotAdapter.js +264 -0
  109. package/src/adapters/_template.js +133 -0
  110. package/src/adapters/elevenlabs-classic.js +841 -0
  111. package/src/adapters/elevenlabs-hybrid.js +812 -0
  112. package/src/adapters/hume-evi.js +676 -0
  113. package/src/admin.html +1339 -0
  114. package/src/app.js +8802 -0
  115. package/src/core/Config.js +173 -0
  116. package/src/core/EmotionEngine.js +307 -0
  117. package/src/core/EventBridge.js +180 -0
  118. package/src/core/EventBus.js +117 -0
  119. package/src/core/VoiceSession.js +607 -0
  120. package/src/face/BaseFace.js +259 -0
  121. package/src/face/EyeFace.js +208 -0
  122. package/src/face/HaloSmokeFace.js +509 -0
  123. package/src/face/manifest.json +27 -0
  124. package/src/face/previews/eyes.svg +16 -0
  125. package/src/face/previews/orb.svg +29 -0
  126. package/src/features/MusicPlayer.js +620 -0
  127. package/src/features/Soundboard.js +128 -0
  128. package/src/providers/DeepgramSTT.js +472 -0
  129. package/src/providers/DeepgramStreamingSTT.js +766 -0
  130. package/src/providers/GroqSTT.js +559 -0
  131. package/src/providers/TTSPlayer.js +323 -0
  132. package/src/providers/WebSpeechSTT.js +479 -0
  133. package/src/providers/tts/BaseTTSProvider.js +81 -0
  134. package/src/providers/tts/HumeProvider.js +77 -0
  135. package/src/providers/tts/SupertonicProvider.js +174 -0
  136. package/src/providers/tts/index.js +140 -0
  137. package/src/shell/adapter-registry.js +154 -0
  138. package/src/shell/caller-bridge.js +35 -0
  139. package/src/shell/camera-bridge.js +28 -0
  140. package/src/shell/canvas-bridge.js +32 -0
  141. package/src/shell/commercial-bridge.js +44 -0
  142. package/src/shell/face-bridge.js +44 -0
  143. package/src/shell/music-bridge.js +60 -0
  144. package/src/shell/orchestrator.js +233 -0
  145. package/src/shell/profile-discovery.js +303 -0
  146. package/src/shell/sounds-bridge.js +28 -0
  147. package/src/shell/transcript-bridge.js +61 -0
  148. package/src/shell/waveform-bridge.js +33 -0
  149. package/src/styles/base.css +2862 -0
  150. package/src/styles/face.css +417 -0
  151. package/src/styles/pi-overrides.css +89 -0
  152. package/src/styles/theme-dark.css +67 -0
  153. package/src/test-tts.html +175 -0
  154. package/src/ui/AppShell.js +544 -0
  155. package/src/ui/ProfileSwitcher.js +228 -0
  156. package/src/ui/SessionControl.js +240 -0
  157. package/src/ui/face/FacePicker.js +195 -0
  158. package/src/ui/face/FaceRenderer.js +309 -0
  159. package/src/ui/settings/PlaylistEditor.js +366 -0
  160. package/src/ui/settings/SettingsPanel.css +684 -0
  161. package/src/ui/settings/SettingsPanel.js +419 -0
  162. package/src/ui/settings/TTSVoicePreview.js +210 -0
  163. package/src/ui/themes/ThemeManager.js +213 -0
  164. package/src/ui/visualizers/BaseVisualizer.js +29 -0
  165. package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
  166. package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
  167. package/static/emulators/jsdos/js-dos.css +1 -0
  168. package/static/emulators/jsdos/js-dos.js +22 -0
  169. package/static/favicon.svg +55 -0
  170. package/static/icons/apple-touch-icon.png +0 -0
  171. package/static/icons/favicon-32.png +0 -0
  172. package/static/icons/icon-192.png +0 -0
  173. package/static/icons/icon-512.png +0 -0
  174. package/static/install.html +449 -0
  175. package/static/manifest.json +26 -0
  176. package/static/sw.js +21 -0
  177. package/tts_providers/__init__.py +136 -0
  178. package/tts_providers/base_provider.py +319 -0
  179. package/tts_providers/groq_provider.py +155 -0
  180. package/tts_providers/hume_provider.py +226 -0
  181. package/tts_providers/providers_config.json +119 -0
  182. package/tts_providers/qwen3_provider.py +371 -0
  183. package/tts_providers/resemble_provider.py +315 -0
  184. package/tts_providers/supertonic_provider.py +557 -0
  185. package/tts_providers/supertonic_tts.py +399 -0
@@ -0,0 +1,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
+ })