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,364 @@
1
+ """
2
+ Image generation proxy — Google Gemini / Imagen / HuggingFace APIs.
3
+ Keeps API keys server-side.
4
+
5
+ POST /api/image-gen
6
+ body: { prompt, images?: [{mime_type, data: base64}], model?, quality?, aspect? }
7
+ returns: { images: [{mime_type, data, url}], text }
8
+ NOTE: every generated image is saved to UPLOADS_DIR on the server immediately
9
+ before the response is returned. `url` is the permanent server path.
10
+
11
+ GET /api/image-gen/saved — load AI designs manifest (server-side, cross-device)
12
+ POST /api/image-gen/saved — append or replace entry in manifest
13
+ DELETE /api/image-gen/saved — remove an entry by url
14
+ """
15
+ import base64
16
+ import json
17
+ import logging
18
+ import os
19
+ import time
20
+ import requests as http
21
+ from flask import Blueprint, jsonify, request
22
+ from services.paths import UPLOADS_DIR
23
+
24
+ logger = logging.getLogger(__name__)
25
+ image_gen_bp = Blueprint('image_gen', __name__)
26
+
27
+ GEMINI_KEY = os.getenv('GEMINI_API_KEY', '')
28
+ GEMINI_BASE = 'https://generativelanguage.googleapis.com/v1beta/models'
29
+ HF_TOKEN = os.getenv('HF_TOKEN', '')
30
+ HF_INFERENCE_BASE = 'https://router.huggingface.co/hf-inference/models'
31
+
32
+ DEFAULT_MODEL = 'nano-banana-pro-preview'
33
+
34
+ # HuggingFace model IDs — prefix with 'hf:' in the frontend
35
+ HF_MODELS = {
36
+ 'black-forest-labs/FLUX.1-schnell',
37
+ 'black-forest-labs/FLUX.1-dev',
38
+ 'stabilityai/stable-diffusion-xl-base-1.0',
39
+ 'stabilityai/stable-diffusion-3.5-large',
40
+ 'stabilityai/stable-diffusion-3.5-large-turbo',
41
+ }
42
+
43
+ # Resolution presets for HF models (actual pixel control)
44
+ HF_QUALITY_SIZES = {
45
+ 'standard': (1024, 1024),
46
+ 'high': (1536, 1536),
47
+ 'ultra': (2048, 2048),
48
+ }
49
+
50
+ # Aspect ratio → width/height multipliers (base is the quality size)
51
+ HF_ASPECT_RATIOS = {
52
+ '1:1': (1.0, 1.0),
53
+ '16:9': (1.33, 0.75),
54
+ '9:16': (0.75, 1.33),
55
+ '4:3': (1.15, 0.87),
56
+ '3:4': (0.87, 1.15),
57
+ '3:2': (1.22, 0.82),
58
+ '2:3': (0.82, 1.22),
59
+ }
60
+
61
+ AI_DESIGNS_MANIFEST = UPLOADS_DIR / 'ai-designs-manifest.json'
62
+
63
+
64
+ def _load_manifest():
65
+ try:
66
+ if AI_DESIGNS_MANIFEST.exists():
67
+ return json.loads(AI_DESIGNS_MANIFEST.read_text())
68
+ except Exception:
69
+ pass
70
+ return []
71
+
72
+
73
+ def _save_manifest(entries):
74
+ UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
75
+ AI_DESIGNS_MANIFEST.write_text(json.dumps(entries, indent=2))
76
+
77
+
78
+ def _save_generated_image(mime_type: str, b64_data: str) -> str:
79
+ """Save a base64-encoded generated image to UPLOADS_DIR. Returns the /uploads/... URL."""
80
+ UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
81
+ ext = mime_type.split('/')[-1] if '/' in mime_type else 'png'
82
+ filename = f'ai-gen-{int(time.time() * 1000)}.{ext}'
83
+ path = UPLOADS_DIR / filename
84
+ path.write_bytes(base64.b64decode(b64_data))
85
+ logger.info('image_gen: saved generated image → %s (%d bytes)', path, path.stat().st_size)
86
+ return f'/uploads/{filename}'
87
+
88
+
89
+ def _generate_gemini(model, prompt, images):
90
+ """generateContent-based models (Gemini, nano-banana, etc.)"""
91
+ parts = []
92
+ for i, img in enumerate(images):
93
+ data = img.get('data')
94
+ if not data:
95
+ logger.warning('image_gen: skipping ref image %d — missing data (client sent empty base64)', i)
96
+ continue
97
+ parts.append({'inline_data': {
98
+ 'mime_type': img.get('mime_type', 'image/png'),
99
+ 'data': data,
100
+ }})
101
+ parts.append({'text': prompt})
102
+ logger.info('image_gen: model=%s images=%d prompt_len=%d', model, len([p for p in parts if 'inline_data' in p]), len(prompt))
103
+
104
+ payload = {
105
+ 'contents': [{'role': 'user', 'parts': parts}],
106
+ 'generationConfig': {'responseModalities': ['IMAGE', 'TEXT']},
107
+ }
108
+ url = f'{GEMINI_BASE}/{model}:generateContent?key={GEMINI_KEY}'
109
+ resp = http.post(url, json=payload, timeout=90)
110
+ resp.raise_for_status()
111
+ result = resp.json()
112
+
113
+ images_out, text_out = [], ''
114
+ for candidate in result.get('candidates', []):
115
+ for part in candidate.get('content', {}).get('parts', []):
116
+ if 'inlineData' in part:
117
+ images_out.append({
118
+ 'mime_type': part['inlineData']['mimeType'],
119
+ 'data': part['inlineData']['data'],
120
+ })
121
+ elif 'text' in part:
122
+ text_out += part['text']
123
+ return images_out, text_out
124
+
125
+
126
+ def _enhance_prompt(idea: str, quality: str = 'standard', style: str = '') -> str:
127
+ """Use Gemini Flash text to turn a rough idea into a detailed merch image prompt."""
128
+ quality_context = {
129
+ 'standard': 'print-quality merch art',
130
+ 'high': 'high-resolution 2K print-quality merch art, sharp fine detail',
131
+ 'ultra': 'ultra-high-resolution 4K print-quality merch art, photorealistic detail, maximum sharpness',
132
+ }.get(quality, 'print-quality merch art')
133
+
134
+ style_instruction = (
135
+ f"Art style: {style}. " if style else
136
+ "Art style: vintage screen-print graphic tee art, bold ink outlines, flat cel-shading with 4-5 solid colors. "
137
+ )
138
+
139
+ system = (
140
+ "You are an art director specializing in apparel graphics for trade and contractor merch. "
141
+ "Turn rough ideas into image generation prompts that produce great t-shirt and hoodie designs.\n\n"
142
+ "T-SHIRT DESIGN RULES — these are the most important:\n"
143
+ "- Design must be ISOLATED: single bold graphic element on a solid black or transparent background. "
144
+ "No scenic backgrounds, no landscapes, no environments behind the subject.\n"
145
+ "- Use 3 to 5 solid bold colors maximum. High contrast. Prints cleanly on dark fabric.\n"
146
+ "- Style should read like: vintage concert tee, old-school band shirt, Harley Davidson apparel, "
147
+ "classic biker patch art, or retro work-wear graphic — NOT a photograph, NOT a painting, NOT a scene.\n"
148
+ "- The subject (character, object, logo) must be large and centered. Readable at a glance.\n"
149
+ "- Bold clean outlines on every element. No fine detail that disappears at shirt scale.\n"
150
+ "- If there is a character, they should look like a mascot or graphic illustration — "
151
+ "detailed face, gear, and posture — NOT a silhouette, NOT an outline-only figure.\n\n"
152
+ "SPRAY FOAM EQUIPMENT — strictly enforced:\n"
153
+ "- Professional spray foam guns ONLY: hose-connected industrial guns (Graco Fusion, PMC, Graco Reactor) "
154
+ "with a thick heated hose trailing back to equipment. Substantial pistol-grip with hose at the rear.\n"
155
+ "- NEVER a consumer canned foam gun (the orange/yellow Great Stuff gun from Home Depot). "
156
+ "If a gun appears in the design, it is always the professional contractor type.\n\n"
157
+ "PROMPT FORMAT — write the prompt to include:\n"
158
+ "1. Subject description (character/object/logo with specific details)\n"
159
+ f"2. {style_instruction}\n"
160
+ "3. Color palette (name 3-5 specific colors, e.g. 'burnt orange, cream white, black, gold')\n"
161
+ "4. Isolated on solid black background\n"
162
+ "5. End with: 'apparel graphic, screen-print style, bold outlines, print-ready, no background'\n\n"
163
+ f"Quality: {quality_context}\n\n"
164
+ "Return ONLY the prompt. No explanation, no quotes, no intro."
165
+ )
166
+
167
+ payload = {
168
+ 'contents': [{'role': 'user', 'parts': [{'text': idea}]}],
169
+ 'systemInstruction': {'parts': [{'text': system}]},
170
+ 'generationConfig': {'maxOutputTokens': 512, 'temperature': 0.9},
171
+ }
172
+ url = f'{GEMINI_BASE}/gemini-2.0-flash:generateContent?key={GEMINI_KEY}'
173
+ resp = http.post(url, json=payload, timeout=30)
174
+ resp.raise_for_status()
175
+ result = resp.json()
176
+
177
+ text = ''
178
+ for candidate in result.get('candidates', []):
179
+ for part in candidate.get('content', {}).get('parts', []):
180
+ if 'text' in part:
181
+ text += part['text']
182
+ return text.strip()
183
+
184
+
185
+ def _generate_huggingface(model_id, prompt, quality='standard', aspect='1:1'):
186
+ """HuggingFace Inference API — FLUX, Stable Diffusion, etc."""
187
+ if not HF_TOKEN:
188
+ raise ValueError('HF_TOKEN not configured on server')
189
+
190
+ base_w, base_h = HF_QUALITY_SIZES.get(quality, (1024, 1024))
191
+ ar_w, ar_h = HF_ASPECT_RATIOS.get(aspect, (1.0, 1.0))
192
+ # Round to nearest 8 (required by most diffusion models)
193
+ width = round(base_w * ar_w / 8) * 8
194
+ height = round(base_h * ar_h / 8) * 8
195
+
196
+ logger.info('image_gen: HF model=%s quality=%s size=%dx%d prompt_len=%d',
197
+ model_id, quality, width, height, len(prompt))
198
+
199
+ payload = {
200
+ 'inputs': prompt,
201
+ 'parameters': {
202
+ 'width': width,
203
+ 'height': height,
204
+ },
205
+ }
206
+
207
+ url = f'{HF_INFERENCE_BASE}/{model_id}'
208
+ headers = {
209
+ 'Authorization': f'Bearer {HF_TOKEN}',
210
+ 'Content-Type': 'application/json',
211
+ }
212
+ resp = http.post(url, json=payload, headers=headers, timeout=120)
213
+ resp.raise_for_status()
214
+
215
+ # HF Inference API returns raw image bytes
216
+ content_type = resp.headers.get('Content-Type', 'image/png')
217
+ mime = content_type.split(';')[0].strip()
218
+ b64_data = base64.b64encode(resp.content).decode('ascii')
219
+
220
+ images_out = [{'mime_type': mime, 'data': b64_data}]
221
+ return images_out, ''
222
+
223
+
224
+ def _generate_imagen(model, prompt, aspect='1:1'):
225
+ """predict-based models (Imagen 4, etc.) — text-to-image only"""
226
+ payload = {
227
+ 'instances': [{'prompt': prompt}],
228
+ 'parameters': {'sampleCount': 1, 'aspectRatio': aspect},
229
+ }
230
+ url = f'{GEMINI_BASE}/{model}:predict?key={GEMINI_KEY}'
231
+ resp = http.post(url, json=payload, timeout=90)
232
+ resp.raise_for_status()
233
+ result = resp.json()
234
+
235
+ images_out = []
236
+ for pred in result.get('predictions', []):
237
+ if 'bytesBase64Encoded' in pred:
238
+ images_out.append({
239
+ 'mime_type': pred.get('mimeType', 'image/png'),
240
+ 'data': pred['bytesBase64Encoded'],
241
+ })
242
+ return images_out, ''
243
+
244
+
245
+ @image_gen_bp.route('/api/image-gen', methods=['POST'])
246
+ def generate_image():
247
+ data = request.get_json(silent=True) or {}
248
+ prompt = (data.get('prompt') or '').strip()
249
+ images = data.get('images', [])
250
+ model = data.get('model') or DEFAULT_MODEL
251
+ quality = (data.get('quality') or 'standard').strip()
252
+ aspect = (data.get('aspect') or '1:1').strip()
253
+
254
+ if not prompt:
255
+ return jsonify({'error': 'prompt is required'}), 400
256
+
257
+ try:
258
+ is_hf = model.startswith('hf:')
259
+ is_imagen = model.startswith('imagen-')
260
+
261
+ if is_hf:
262
+ hf_model_id = model[3:] # strip 'hf:' prefix
263
+ if not HF_TOKEN:
264
+ return jsonify({'error': 'HF_TOKEN not configured on server'}), 503
265
+ imgs_out, text_out = _generate_huggingface(hf_model_id, prompt, quality, aspect)
266
+ elif is_imagen:
267
+ if not GEMINI_KEY:
268
+ return jsonify({'error': 'GEMINI_API_KEY not configured on server'}), 503
269
+ imgs_out, text_out = _generate_imagen(model, prompt, aspect)
270
+ else:
271
+ if not GEMINI_KEY:
272
+ return jsonify({'error': 'GEMINI_API_KEY not configured on server'}), 503
273
+ imgs_out, text_out = _generate_gemini(model, prompt, images)
274
+
275
+ if not imgs_out:
276
+ return jsonify({'error': 'Model returned no image', 'text': text_out}), 502
277
+
278
+ # Save every generated image to disk immediately — before the response leaves the server.
279
+ # The client receives a permanent /uploads/... URL; no client-side upload step needed.
280
+ for img in imgs_out:
281
+ try:
282
+ img['url'] = _save_generated_image(img['mime_type'], img['data'])
283
+ except Exception as save_err:
284
+ logger.error('image_gen: failed to save image to disk: %s', save_err)
285
+ img['url'] = None # client must handle this case
286
+
287
+ return jsonify({'images': imgs_out, 'text': text_out})
288
+
289
+ except http.HTTPError as e:
290
+ body = e.response.text[:400] if e.response else str(e)
291
+ logger.error('image-gen HTTP error: %s', body)
292
+ return jsonify({'error': body}), e.response.status_code if e.response else 502
293
+ except Exception as e:
294
+ logger.exception('image-gen error')
295
+ return jsonify({'error': str(e)}), 500
296
+
297
+
298
+ @image_gen_bp.route('/api/image-gen/enhance', methods=['POST'])
299
+ def enhance_prompt_route():
300
+ """Enhance a rough idea into a detailed sprayfoam merch image prompt using Gemini."""
301
+ if not GEMINI_KEY:
302
+ return jsonify({'error': 'GEMINI_API_KEY not configured on server'}), 503
303
+
304
+ data = request.get_json(silent=True) or {}
305
+ idea = (data.get('idea') or '').strip()
306
+ quality = (data.get('quality') or 'standard').strip()
307
+ style = (data.get('style') or '').strip()
308
+
309
+ if not idea:
310
+ return jsonify({'error': 'idea is required'}), 400
311
+
312
+ try:
313
+ enhanced = _enhance_prompt(idea, quality, style)
314
+ if not enhanced:
315
+ return jsonify({'error': 'LLM returned empty response'}), 502
316
+ logger.info('enhance_prompt: idea_len=%d → prompt_len=%d quality=%s', len(idea), len(enhanced), quality)
317
+ return jsonify({'prompt': enhanced})
318
+ except http.HTTPError as e:
319
+ body = e.response.text[:400] if e.response else str(e)
320
+ logger.error('enhance_prompt HTTP error: %s', body)
321
+ return jsonify({'error': body}), e.response.status_code if e.response else 502
322
+ except Exception as e:
323
+ logger.exception('enhance_prompt error')
324
+ return jsonify({'error': str(e)}), 500
325
+
326
+
327
+ @image_gen_bp.route('/api/image-gen/saved', methods=['GET'])
328
+ def get_saved_designs():
329
+ """Return all saved AI designs — persisted on server, works across devices."""
330
+ return jsonify(_load_manifest())
331
+
332
+
333
+ @image_gen_bp.route('/api/image-gen/saved', methods=['POST'])
334
+ def save_design():
335
+ """Prepend a design entry to the server-side manifest."""
336
+ data = request.get_json(silent=True) or {}
337
+ url = (data.get('url') or '').strip()
338
+ name = (data.get('name') or 'AI Generated').strip()[:80]
339
+ ts = data.get('ts') or 0
340
+
341
+ if not url:
342
+ return jsonify({'error': 'url is required'}), 400
343
+
344
+ entries = _load_manifest()
345
+ # Avoid duplicates — remove existing entry for same URL first
346
+ entries = [e for e in entries if e.get('url') != url]
347
+ entries.insert(0, {'url': url, 'name': name, 'ts': ts})
348
+ _save_manifest(entries)
349
+ logger.info('ai-designs manifest: saved %d entries', len(entries))
350
+ return jsonify({'ok': True, 'count': len(entries)})
351
+
352
+
353
+ @image_gen_bp.route('/api/image-gen/saved', methods=['DELETE'])
354
+ def delete_saved_design():
355
+ """Remove a design entry from the manifest by URL."""
356
+ data = request.get_json(silent=True) or {}
357
+ url = (data.get('url') or '').strip()
358
+ if not url:
359
+ return jsonify({'error': 'url is required'}), 400
360
+
361
+ entries = _load_manifest()
362
+ entries = [e for e in entries if e.get('url') != url]
363
+ _save_manifest(entries)
364
+ return jsonify({'ok': True, 'count': len(entries)})
@@ -0,0 +1,190 @@
1
+ """
2
+ Instructions Blueprint — live agent instruction file editor.
3
+
4
+ GET /api/instructions → list available instruction files
5
+ GET /api/instructions/<name> → read a file's content
6
+ PUT /api/instructions/<name> → write new content to a file
7
+
8
+ Files live in prompts/ (app-level) and the OpenClaw workspace dir (config: paths.openclaw_workspace / env: OPENCLAW_WORKSPACE).
9
+ Changes take effect on the next conversation request — no restart needed.
10
+ """
11
+
12
+ import os
13
+ import logging
14
+ from pathlib import Path
15
+ from flask import Blueprint, jsonify, request
16
+ from config.loader import config
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ instructions_bp = Blueprint('instructions', __name__)
21
+
22
+ # ── File registries ───────────────────────────────────────────────────────────
23
+
24
+ # App-level prompt files (relative to project root)
25
+ _APP_ROOT = Path(__file__).parent.parent
26
+
27
+ APP_FILES = {
28
+ 'voice-system-prompt': {
29
+ 'path': _APP_ROOT / 'prompts' / 'voice-system-prompt.md',
30
+ 'label': 'Voice System Prompt',
31
+ 'description': 'Injected before every user message sent to the OpenClaw Gateway. '
32
+ 'Controls tone, formatting, and behaviour rules.',
33
+ 'scope': 'app',
34
+ 'hot_reload': True,
35
+ },
36
+ }
37
+
38
+ # OpenClaw workspace files (read-only from the voice agent's perspective — agent writes these)
39
+ _OPENCLAW_DIR = Path(
40
+ os.path.expanduser(config.get('paths.openclaw_workspace', '~/.openclaw/workspace'))
41
+ )
42
+ OPENCLAW_FILES = {
43
+ 'soul': {
44
+ 'path': _OPENCLAW_DIR / 'SOUL.md',
45
+ 'label': 'Soul (Core Identity)',
46
+ 'description': "Defines the agent's core identity, personality, and values.",
47
+ 'scope': 'openclaw',
48
+ 'hot_reload': False,
49
+ },
50
+ 'claude': {
51
+ 'path': _OPENCLAW_DIR / 'CLAUDE.md',
52
+ 'label': 'Claude (Capabilities)',
53
+ 'description': "Agent capability definitions and tool access rules.",
54
+ 'scope': 'openclaw',
55
+ 'hot_reload': False,
56
+ },
57
+ 'agents': {
58
+ 'path': _OPENCLAW_DIR / 'AGENTS.md',
59
+ 'label': 'Agents (Sub-agents)',
60
+ 'description': "Sub-agent definitions and delegation rules.",
61
+ 'scope': 'openclaw',
62
+ 'hot_reload': False,
63
+ },
64
+ 'user': {
65
+ 'path': _OPENCLAW_DIR / 'USER.md',
66
+ 'label': 'User (Context)',
67
+ 'description': "Dynamic user context — updated by the agent from conversations.",
68
+ 'scope': 'openclaw',
69
+ 'hot_reload': False,
70
+ },
71
+ 'tools': {
72
+ 'path': _OPENCLAW_DIR / 'TOOLS.md',
73
+ 'label': 'Tools (Available Tools)',
74
+ 'description': "Tool definitions and usage rules.",
75
+ 'scope': 'openclaw',
76
+ 'hot_reload': False,
77
+ },
78
+ 'heartbeat': {
79
+ 'path': _OPENCLAW_DIR / 'HEARTBEAT.md',
80
+ 'label': 'Heartbeat (Status)',
81
+ 'description': "Agent self-monitoring and status tracking.",
82
+ 'scope': 'openclaw',
83
+ 'hot_reload': False,
84
+ },
85
+ }
86
+
87
+ ALL_FILES = {**APP_FILES, **OPENCLAW_FILES}
88
+
89
+ # ── Helpers ───────────────────────────────────────────────────────────────────
90
+
91
+ def _read_file(path: Path) -> tuple[str | None, str | None]:
92
+ """Returns (content, error). error is None on success."""
93
+ try:
94
+ if not path.exists():
95
+ return None, 'file_not_found'
96
+ return path.read_text(encoding='utf-8'), None
97
+ except Exception as e:
98
+ logger.warning(f'[instructions] read {path}: {e}')
99
+ return None, 'Read failed'
100
+
101
+
102
+ def _write_file(path: Path, content: str) -> str | None:
103
+ """Returns error string or None on success."""
104
+ try:
105
+ path.parent.mkdir(parents=True, exist_ok=True)
106
+ path.write_text(content, encoding='utf-8')
107
+ return None
108
+ except Exception as e:
109
+ logger.warning(f'[instructions] write {path}: {e}')
110
+ return 'Write failed'
111
+
112
+
113
+ # ── Routes ────────────────────────────────────────────────────────────────────
114
+
115
+ @instructions_bp.route('/api/instructions', methods=['GET'])
116
+ def list_instructions():
117
+ """List all registered instruction files with metadata."""
118
+ result = []
119
+ for key, meta in ALL_FILES.items():
120
+ content, err = _read_file(meta['path'])
121
+ result.append({
122
+ 'id': key,
123
+ 'label': meta['label'],
124
+ 'description': meta['description'],
125
+ 'scope': meta['scope'],
126
+ 'hot_reload': meta['hot_reload'],
127
+ 'exists': err != 'file_not_found',
128
+ 'error': err if err and err != 'file_not_found' else None,
129
+ 'size': len(content) if content else 0,
130
+ })
131
+ return jsonify({'files': result})
132
+
133
+
134
+ @instructions_bp.route('/api/instructions/<name>', methods=['GET'])
135
+ def get_instruction(name: str):
136
+ """Read a single instruction file."""
137
+ if name not in ALL_FILES:
138
+ return jsonify({'error': 'Unknown file', 'name': name}), 404
139
+
140
+ meta = ALL_FILES[name]
141
+ content, err = _read_file(meta['path'])
142
+
143
+ if err == 'file_not_found':
144
+ return jsonify({
145
+ 'id': name,
146
+ 'label': meta['label'],
147
+ 'scope': meta['scope'],
148
+ 'content': '',
149
+ 'exists': False,
150
+ })
151
+
152
+ if err:
153
+ return jsonify({'error': err}), 500
154
+
155
+ return jsonify({
156
+ 'id': name,
157
+ 'label': meta['label'],
158
+ 'scope': meta['scope'],
159
+ 'hot_reload': meta['hot_reload'],
160
+ 'content': content,
161
+ 'exists': True,
162
+ 'size': len(content),
163
+ })
164
+
165
+
166
+ @instructions_bp.route('/api/instructions/<name>', methods=['PUT'])
167
+ def update_instruction(name: str):
168
+ """Write new content to an instruction file."""
169
+ if name not in ALL_FILES:
170
+ return jsonify({'error': 'Unknown file', 'name': name}), 404
171
+
172
+ body = request.get_json(silent=True) or {}
173
+ content = body.get('content')
174
+ if content is None:
175
+ return jsonify({'error': 'Missing content field'}), 400
176
+
177
+ meta = ALL_FILES[name]
178
+ err = _write_file(meta['path'], content)
179
+ if err:
180
+ return jsonify({'error': err}), 500
181
+
182
+ logger.info(f'[instructions] updated {name} ({len(content)} chars)')
183
+ return jsonify({
184
+ 'ok': True,
185
+ 'id': name,
186
+ 'size': len(content),
187
+ 'hot_reload': meta['hot_reload'],
188
+ 'message': 'Saved. Changes take effect on the next conversation.' if meta['hot_reload']
189
+ else 'Saved. Agent reads this file fresh each session.',
190
+ })