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