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,533 @@
1
+ """
2
+ routes/static_files.py — Static Asset Serving Blueprint (P2-T8)
3
+
4
+ Extracted from server.py during Phase 2 blueprint split.
5
+ Registers routes:
6
+ GET /sounds/<path:filepath> — sound effect files
7
+ GET /uploads/<path:filename> — uploaded user files
8
+ GET /src/<path:filepath> — frontend JS/CSS source modules
9
+ GET /known_faces/<name>/<filename> — face recognition photos
10
+ GET /api/dj-sound — DJ soundboard API (list/play)
11
+ """
12
+
13
+ import logging
14
+ import random
15
+ import re
16
+ from pathlib import Path
17
+
18
+ from flask import Blueprint, Response, jsonify, request, send_file
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Blueprint
24
+ # ---------------------------------------------------------------------------
25
+
26
+ static_files_bp = Blueprint('static_files', __name__)
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Paths
30
+ # ---------------------------------------------------------------------------
31
+
32
+ from services.paths import APP_ROOT, SOUNDS_DIR, UPLOADS_DIR, KNOWN_FACES_DIR, STATIC_DIR
33
+
34
+ BASE_DIR = APP_ROOT
35
+
36
+ UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # DJ Sounds catalogue
40
+ # ---------------------------------------------------------------------------
41
+
42
+ DJ_SOUNDS = {
43
+ 'air_horn': {
44
+ 'description': 'Classic stadium air horn - ba ba baaaa!',
45
+ 'when_to_use': 'Before drops, hype moments, celebrating wins, hip-hop DJ style'
46
+ },
47
+ 'scratch_long': {
48
+ 'description': 'Extended DJ scratch solo - wicka wicka',
49
+ 'when_to_use': 'Transitions, hip-hop moments, showing off DJ skills'
50
+ },
51
+ 'rewind': {
52
+ 'description': 'DJ rewind - pull up selecta!',
53
+ 'when_to_use': 'Going back, replaying something fire, dancehall pull-ups'
54
+ },
55
+ 'record_stop': {
56
+ 'description': 'Record stopping abruptly',
57
+ 'when_to_use': 'Stopping everything, dramatic pause, cutting the music'
58
+ },
59
+ 'impact': {
60
+ 'description': 'Punchy cinematic impact hit',
61
+ 'when_to_use': 'Punctuating statements, transitions, emphasis'
62
+ },
63
+ 'crowd_cheer': {
64
+ 'description': 'Nightclub crowd cheering and going wild',
65
+ 'when_to_use': 'Big wins, amazing moments, festival energy, applause'
66
+ },
67
+ 'crowd_hype': {
68
+ 'description': 'Hyped up rave crowd losing their minds',
69
+ 'when_to_use': 'Peak energy moments, party atmosphere'
70
+ },
71
+ 'yeah': {
72
+ 'description': 'Hype man YEAH! vocal shot',
73
+ 'when_to_use': 'Hyping up, agreement, energy boost'
74
+ },
75
+ 'lets_go': {
76
+ 'description': 'LETS GO! vocal chant',
77
+ 'when_to_use': 'Starting something, getting pumped, motivation'
78
+ },
79
+ 'laser': {
80
+ 'description': 'Retro arcade laser zap - pew pew',
81
+ 'when_to_use': 'Sci-fi moments, gaming references, 80s vibes'
82
+ },
83
+ 'gunshot': {
84
+ 'description': 'Dancehall gunshot sound - gun finger!',
85
+ 'when_to_use': 'Reggae/dancehall vibes, shooting down bad ideas'
86
+ },
87
+ 'bruh': {
88
+ 'description': 'Classic bruh sound effect',
89
+ 'when_to_use': 'Facepalm moments, disappointment, when someone says something dumb'
90
+ },
91
+ 'sad_trombone': {
92
+ 'description': 'Sad trombone wah wah wah - womp womp',
93
+ 'when_to_use': 'Fails, disappointments, when things go wrong'
94
+ },
95
+ }
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Routes
99
+ # ---------------------------------------------------------------------------
100
+
101
+ def _safe_path(base_dir: Path, *parts) -> Path | None:
102
+ """
103
+ Resolve a path within base_dir, rejecting any traversal outside it.
104
+ Returns the resolved Path on success, or None if traversal is detected.
105
+ """
106
+ try:
107
+ resolved = (base_dir / Path(*parts)).resolve()
108
+ base_resolved = base_dir.resolve()
109
+ if resolved == base_resolved or base_resolved in resolved.parents:
110
+ return resolved
111
+ except Exception:
112
+ pass
113
+ return None
114
+
115
+
116
+ @static_files_bp.route('/sounds/<path:filepath>')
117
+ def serve_sound(filepath):
118
+ """Serve sound effect files (including subdirectories like DJ-clips/)"""
119
+ sound_path = _safe_path(SOUNDS_DIR, filepath)
120
+ if sound_path is None:
121
+ return jsonify({"error": "Invalid path"}), 400
122
+ if sound_path.exists():
123
+ return send_file(sound_path, mimetype='audio/mpeg')
124
+ return jsonify({"error": "Sound not found"}), 404
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Upload constants & helpers
129
+ # ---------------------------------------------------------------------------
130
+
131
+ # Hard limit enforced before writing to disk (100 MB)
132
+ _MAX_UPLOAD_BYTES = 100 * 1024 * 1024
133
+
134
+ # Maximum characters returned to the AI as content_preview
135
+ _MAX_PREVIEW_CHARS = 6000
136
+
137
+ # Only block executables — accept everything else (weird exports, unknown formats, etc.)
138
+ _BLOCKED_EXTENSIONS = {
139
+ '.exe', '.bat', '.cmd', '.com', '.scr', '.pif',
140
+ '.msi', '.dll', '.sys', '.vbs', '.vbe', '.wsh',
141
+ '.wsf', '.ps1', '.sh', '.cpl', '.inf', '.reg',
142
+ }
143
+
144
+ # Control characters to strip from extracted text (keeps \t \n \r)
145
+ _CTRL_RE = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]')
146
+
147
+
148
+ def _sanitize_text(text: str) -> str:
149
+ """Strip control chars, collapse excessive blank lines, cap at _MAX_PREVIEW_CHARS."""
150
+ text = _CTRL_RE.sub('', text)
151
+ text = re.sub(r'\n{4,}', '\n\n\n', text) # no more than 3 consecutive blank lines
152
+ return text[:_MAX_PREVIEW_CHARS].strip()
153
+
154
+
155
+ def _extract_pdf(path: Path) -> str:
156
+ """Extract text from a PDF using pypdf. Returns sanitized string."""
157
+ from pypdf import PdfReader
158
+ reader = PdfReader(str(path))
159
+ pages = []
160
+ for i, page in enumerate(reader.pages):
161
+ try:
162
+ text = page.extract_text() or ''
163
+ pages.append(text)
164
+ except Exception:
165
+ pages.append(f'[Page {i + 1}: extraction failed]')
166
+ return _sanitize_text('\n\n'.join(pages))
167
+
168
+
169
+ def _extract_docx(path: Path) -> str:
170
+ """Extract text from a .docx using python-docx. Returns sanitized string."""
171
+ from docx import Document
172
+ doc = Document(str(path))
173
+ parts = []
174
+ for para in doc.paragraphs:
175
+ t = para.text.strip()
176
+ if t:
177
+ parts.append(t)
178
+ for table in doc.tables:
179
+ for row in table.rows:
180
+ cells = [c.text.strip() for c in row.cells if c.text.strip()]
181
+ if cells:
182
+ parts.append(' | '.join(cells))
183
+ return _sanitize_text('\n'.join(parts))
184
+
185
+
186
+ def _extract_xlsx(path: Path) -> str:
187
+ """Extract cell values from a .xlsx using openpyxl. Returns sanitized string."""
188
+ import openpyxl
189
+ wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True)
190
+ parts = []
191
+ try:
192
+ for sheet in wb.worksheets:
193
+ parts.append(f'[Sheet: {sheet.title}]')
194
+ for row in sheet.iter_rows(values_only=True):
195
+ cells = [str(c) for c in row if c is not None and str(c).strip()]
196
+ if cells:
197
+ parts.append('\t'.join(cells))
198
+ finally:
199
+ wb.close()
200
+ return _sanitize_text('\n'.join(parts))
201
+
202
+
203
+ def _extract_pptx(path: Path) -> str:
204
+ """Extract text from a .pptx using python-pptx. Returns sanitized string."""
205
+ from pptx import Presentation
206
+ prs = Presentation(str(path))
207
+ parts = []
208
+ for i, slide in enumerate(prs.slides, 1):
209
+ slide_texts = []
210
+ for shape in slide.shapes:
211
+ if shape.has_text_frame:
212
+ for para in shape.text_frame.paragraphs:
213
+ t = para.text.strip()
214
+ if t:
215
+ slide_texts.append(t)
216
+ if slide_texts:
217
+ parts.append(f'[Slide {i}]')
218
+ parts.extend(slide_texts)
219
+ return _sanitize_text('\n'.join(parts))
220
+
221
+
222
+ def _call_skill_runner(path: Path, original_name: str) -> str | None:
223
+ """
224
+ Try to extract document text via the shared skill-runner service.
225
+ Returns extracted text on success, None if the service is unavailable.
226
+ Falls back gracefully so local extractors can take over.
227
+ """
228
+ try:
229
+ import requests
230
+ with open(path, 'rb') as fh:
231
+ resp = requests.post(
232
+ 'http://skill-runner:8900/extract',
233
+ files={'file': (original_name, fh)},
234
+ data={'filename': original_name},
235
+ timeout=30,
236
+ )
237
+ if resp.ok:
238
+ data = resp.json()
239
+ return data.get('text', '')
240
+ logger.warning('skill-runner /extract returned %d for %s', resp.status_code, original_name)
241
+ except Exception as exc:
242
+ logger.debug('skill-runner unavailable, using local extractors: %s', exc)
243
+ return None
244
+
245
+
246
+ @static_files_bp.route('/api/upload', methods=['POST'])
247
+ def upload_file():
248
+ """Accept a file upload from the text panel and save to uploads/."""
249
+ import mimetypes
250
+ import uuid
251
+
252
+ if 'file' not in request.files:
253
+ return jsonify({'error': 'No file provided'}), 400
254
+
255
+ f = request.files['file']
256
+ if not f.filename:
257
+ return jsonify({'error': 'Empty filename'}), 400
258
+
259
+ # --- Sanitize filename, validate extension ---
260
+ original_name = Path(f.filename).name
261
+ ext = Path(original_name).suffix.lower()
262
+ if ext in _BLOCKED_EXTENSIONS:
263
+ return jsonify({'error': f'File type "{ext}" is not allowed'}), 415
264
+
265
+ # --- Size check before writing to disk ---
266
+ # Seek to end to get byte length without reading into memory
267
+ f.stream.seek(0, 2)
268
+ file_size = f.stream.tell()
269
+ f.stream.seek(0)
270
+ if file_size > _MAX_UPLOAD_BYTES:
271
+ return jsonify({'error': 'File too large (100 MB max)'}), 413
272
+
273
+ # --- Save with UUID filename (no original name on disk) ---
274
+ safe_name = f"{uuid.uuid4().hex}{ext}"
275
+ dest = UPLOADS_DIR / safe_name
276
+ UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
277
+ f.save(str(dest))
278
+
279
+ mime = f.mimetype or mimetypes.guess_type(original_name)[0] or ''
280
+ is_image = mime.startswith('image/')
281
+
282
+ result = {
283
+ 'original_name': original_name,
284
+ 'path': str(dest),
285
+ 'filename': safe_name,
286
+ 'url': f'/uploads/{safe_name}',
287
+ }
288
+
289
+ if is_image:
290
+ result['type'] = 'image'
291
+ return jsonify(result)
292
+
293
+ result['type'] = 'file'
294
+
295
+ # --- Extract readable content by type ---
296
+ _BINARY_EXTS = {'.pdf', '.docx', '.xlsx', '.pptx'}
297
+
298
+ try:
299
+ if ext in _BINARY_EXTS:
300
+ # Try shared skill-runner first (preferred — keeps this container lean)
301
+ text = _call_skill_runner(dest, original_name)
302
+
303
+ # Fall back to local extractors if skill-runner unavailable
304
+ if text is None:
305
+ if ext == '.pdf':
306
+ text = _extract_pdf(dest)
307
+ elif ext == '.docx':
308
+ text = _extract_docx(dest)
309
+ elif ext == '.xlsx':
310
+ text = _extract_xlsx(dest)
311
+ elif ext == '.pptx':
312
+ text = _extract_pptx(dest)
313
+
314
+ if text:
315
+ result['content_preview'] = text
316
+ result['extracted_type'] = ext.lstrip('.')
317
+ else:
318
+ result['extraction_error'] = (
319
+ f'Could not extract text from {ext} file. '
320
+ 'Install skill-runner or document packages to enable this.'
321
+ )
322
+
323
+ else:
324
+ # Plain text / code / CSV — read directly
325
+ text_types = {'text/', 'application/json', 'application/xml', 'application/javascript'}
326
+ if any(mime.startswith(t) for t in text_types) or ext in {
327
+ '.txt', '.md', '.csv', '.log',
328
+ '.py', '.js', '.ts', '.json', '.yaml', '.yml',
329
+ '.html', '.css',
330
+ }:
331
+ raw = dest.read_text(errors='replace')
332
+ result['content_preview'] = _sanitize_text(raw)
333
+
334
+ except Exception as exc:
335
+ logger.warning('Document extraction failed for %s: %s', original_name, exc)
336
+ result['extraction_error'] = f'Could not extract text from {ext} file'
337
+
338
+ return jsonify(result)
339
+
340
+
341
+ @static_files_bp.route('/static/emulators/<path:filepath>')
342
+ def serve_emulator(filepath):
343
+ """Serve bundled emulator files (js-dos, etc.) from /app/static/emulators/."""
344
+ emulators_dir = STATIC_DIR / 'emulators'
345
+ path = _safe_path(emulators_dir, filepath)
346
+ if path is None:
347
+ return jsonify({"error": "Invalid path"}), 400
348
+ if not path.exists():
349
+ return jsonify({"error": "File not found"}), 404
350
+ mime_types = {'.js': 'application/javascript', '.css': 'text/css', '.wasm': 'application/wasm'}
351
+ mime = mime_types.get(path.suffix, 'application/octet-stream')
352
+ response = send_file(path, mimetype=mime)
353
+ response.headers['Cache-Control'] = 'public, max-age=86400'
354
+ response.headers['Access-Control-Allow-Origin'] = '*'
355
+ return response
356
+
357
+
358
+ @static_files_bp.route('/uploads/<path:filename>')
359
+ def serve_upload(filename):
360
+ """Serve uploaded files (path traversal guarded)."""
361
+ upload_path = _safe_path(UPLOADS_DIR, filename)
362
+ if upload_path is None:
363
+ return jsonify({"error": "Invalid path"}), 400
364
+ if not upload_path.exists():
365
+ return jsonify({"error": "File not found"}), 404
366
+ return send_file(upload_path)
367
+
368
+
369
+ @static_files_bp.route('/src/<path:filepath>')
370
+ def serve_src(filepath):
371
+ """Serve frontend source files (JS, CSS modules)"""
372
+ # P7-T3 security: prevent path traversal (same guard used by serve_sound)
373
+ src_path = _safe_path(APP_ROOT / 'src', filepath)
374
+ if src_path is None:
375
+ return jsonify({"error": "Invalid path"}), 400
376
+ if not src_path.exists():
377
+ return jsonify({"error": "File not found"}), 404
378
+
379
+ mime_types = {
380
+ '.js': 'application/javascript',
381
+ '.css': 'text/css',
382
+ '.html': 'text/html',
383
+ '.json': 'application/json',
384
+ }
385
+ mime_type = mime_types.get(src_path.suffix.lower(), 'text/plain')
386
+ resp = send_file(src_path, mimetype=mime_type)
387
+ resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
388
+ return resp
389
+
390
+
391
+ @static_files_bp.route('/known_faces/<name>/<filename>')
392
+ def serve_face_photo(name, filename):
393
+ """Serve face photos for the My Face section"""
394
+ photo_path = _safe_path(KNOWN_FACES_DIR, name, filename)
395
+ if photo_path is None:
396
+ return jsonify({"error": "Invalid path"}), 400
397
+ if photo_path.exists():
398
+ return send_file(photo_path)
399
+ return jsonify({"error": "Photo not found"}), 404
400
+
401
+
402
+ @static_files_bp.route('/api/dj-sound', methods=['GET'])
403
+ def handle_dj_sound():
404
+ """
405
+ DJ Soundboard endpoint.
406
+ Query params:
407
+ - action: 'list' or 'play'
408
+ - sound: sound name (e.g., 'air_horn', 'scratch', 'siren_rise')
409
+ Returns sound info or triggers playback.
410
+ """
411
+ action = request.args.get('action', 'list')
412
+ sound = request.args.get('sound', '')
413
+
414
+ if action == 'list':
415
+ sounds_list = [
416
+ {
417
+ 'name': name,
418
+ 'description': info['description'],
419
+ 'when_to_use': info['when_to_use'],
420
+ 'available': (SOUNDS_DIR / f"{name}.mp3").exists(),
421
+ }
422
+ for name, info in DJ_SOUNDS.items()
423
+ ]
424
+ return jsonify({
425
+ 'action': 'list',
426
+ 'sounds': sounds_list,
427
+ 'count': len(sounds_list),
428
+ 'response': (
429
+ f"Soundboard loaded! {len(sounds_list)} effects ready. "
430
+ "I got air horns, sirens, scratches, crowd effects, and more!"
431
+ ),
432
+ })
433
+
434
+ if action == 'play':
435
+ if not sound:
436
+ sound = random.choice(list(DJ_SOUNDS.keys()))
437
+
438
+ sound_lower = sound.lower().replace(' ', '_').replace('-', '_')
439
+
440
+ matched = next(
441
+ (name for name in DJ_SOUNDS if sound_lower in name or name in sound_lower),
442
+ None,
443
+ )
444
+ if not matched:
445
+ matched = next(
446
+ (name for name in DJ_SOUNDS
447
+ if any(word in name for word in sound_lower.split('_'))),
448
+ None,
449
+ )
450
+
451
+ if not matched:
452
+ return jsonify({
453
+ 'action': 'error',
454
+ 'response': (
455
+ f"No sound matching '{sound}'. "
456
+ "Try: air_horn, siren, scratch, applause, bass_drop, rewind..."
457
+ ),
458
+ })
459
+
460
+ sound_file = SOUNDS_DIR / f"{matched}.mp3"
461
+ if not sound_file.exists():
462
+ return jsonify({
463
+ 'action': 'error',
464
+ 'response': f"Sound file for '{matched}' not found. Need to generate it first!",
465
+ })
466
+
467
+ info = DJ_SOUNDS[matched]
468
+ return jsonify({
469
+ 'action': 'play',
470
+ 'sound': matched,
471
+ 'description': info['description'],
472
+ 'url': f"/sounds/{matched}.mp3",
473
+ 'response': f"*{info['description'].upper()}* 🎵",
474
+ })
475
+
476
+ return jsonify({'error': 'Unknown action'}), 400
477
+
478
+
479
+ @static_files_bp.route('/manifest.json')
480
+ def serve_manifest():
481
+ """PWA Web App Manifest — dynamically injects CLIENT_NAME for per-tenant PWA identity"""
482
+ import json as _json, os as _os
483
+ client_name = _os.environ.get("CLIENT_NAME", "").strip()
484
+ path = STATIC_DIR / 'manifest.json'
485
+ manifest = _json.loads(path.read_text())
486
+ if client_name:
487
+ manifest["name"] = client_name
488
+ manifest["short_name"] = client_name
489
+ resp = Response(
490
+ _json.dumps(manifest, indent=2),
491
+ mimetype='application/manifest+json'
492
+ )
493
+ resp.headers['Cache-Control'] = 'public, max-age=86400'
494
+ return resp
495
+
496
+
497
+ @static_files_bp.route('/sw.js')
498
+ def serve_sw():
499
+ """PWA Service Worker — must be served from root scope"""
500
+ path = STATIC_DIR / 'sw.js'
501
+ resp = send_file(path, mimetype='application/javascript')
502
+ resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
503
+ resp.headers['Service-Worker-Allowed'] = '/'
504
+ return resp
505
+
506
+
507
+ @static_files_bp.route('/static/icons/<filename>')
508
+ def serve_icon(filename):
509
+ """PWA icons"""
510
+ icon_path = _safe_path(STATIC_DIR / 'icons', filename)
511
+ if icon_path is None or not icon_path.exists():
512
+ return jsonify({"error": "Icon not found"}), 404
513
+ return send_file(icon_path, mimetype='image/png')
514
+
515
+
516
+ @static_files_bp.route('/install')
517
+ def serve_install():
518
+ """PWA install landing page"""
519
+ path = STATIC_DIR / 'install.html'
520
+ resp = send_file(path, mimetype='text/html')
521
+ resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
522
+ return resp
523
+
524
+
525
+ @static_files_bp.route('/admin')
526
+ def serve_admin():
527
+ """Serve the OpenUI admin dashboard"""
528
+ admin_path = APP_ROOT / 'src' / 'admin.html'
529
+ if not admin_path.exists():
530
+ return jsonify({"error": "Admin dashboard not found"}), 404
531
+ resp = send_file(admin_path, mimetype='text/html')
532
+ resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
533
+ return resp