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,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
|
+
})
|