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,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Onboarding state blueprint.
|
|
3
|
+
|
|
4
|
+
GET /api/onboarding/state → read onboarding state from runtime dir
|
|
5
|
+
POST /api/onboarding/state → write onboarding state to runtime dir
|
|
6
|
+
|
|
7
|
+
State file lives at RUNTIME_DIR/onboarding-state.json (bind-mounted volume
|
|
8
|
+
in JamBot — persists across containers and devices). Falls back to localStorage
|
|
9
|
+
on the frontend when these endpoints return 404 or fail (safe for non-JamBot).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from flask import Blueprint, jsonify, request
|
|
15
|
+
from services.paths import RUNTIME_DIR
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
onboarding_bp = Blueprint('onboarding', __name__)
|
|
20
|
+
|
|
21
|
+
STATE_FILE = RUNTIME_DIR / 'onboarding-state.json'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@onboarding_bp.route('/api/onboarding/state', methods=['GET'])
|
|
25
|
+
def get_state():
|
|
26
|
+
try:
|
|
27
|
+
if STATE_FILE.exists():
|
|
28
|
+
return jsonify(json.loads(STATE_FILE.read_text()))
|
|
29
|
+
return jsonify(None)
|
|
30
|
+
except Exception as e:
|
|
31
|
+
logger.error('onboarding get_state error: %s', e)
|
|
32
|
+
return jsonify({'error': str(e)}), 500
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@onboarding_bp.route('/api/onboarding/state', methods=['POST'])
|
|
36
|
+
def save_state():
|
|
37
|
+
try:
|
|
38
|
+
data = request.get_json(silent=True) or {}
|
|
39
|
+
STATE_FILE.write_text(json.dumps(data))
|
|
40
|
+
return jsonify({'ok': True})
|
|
41
|
+
except Exception as e:
|
|
42
|
+
logger.error('onboarding save_state error: %s', e)
|
|
43
|
+
return jsonify({'error': str(e)}), 500
|
package/routes/pi.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
routes/pi.py — Raspberry Pi optimized page serving
|
|
3
|
+
|
|
4
|
+
Purely additive — register with:
|
|
5
|
+
from routes.pi import pi_bp
|
|
6
|
+
app.register_blueprint(pi_bp)
|
|
7
|
+
|
|
8
|
+
UA detection targets (ARM architecture strings in User-Agent):
|
|
9
|
+
aarch64 — Pi 4, Pi 5, Pi Zero 2 W (64-bit ARM)
|
|
10
|
+
armv7l — Pi 3, Pi 2 (32-bit ARM)
|
|
11
|
+
armv6l — Pi Zero, Pi 1 (ARMv6)
|
|
12
|
+
|
|
13
|
+
The before_app_request hook fires on every request but only acts on
|
|
14
|
+
GET / from ARM browsers, redirecting them transparently to /pi which
|
|
15
|
+
serves index-pi.html — the performance-optimized variant.
|
|
16
|
+
|
|
17
|
+
Desktop/non-ARM browsers are completely unaffected.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import pathlib
|
|
22
|
+
|
|
23
|
+
from flask import Blueprint, Response, redirect, request, url_for
|
|
24
|
+
|
|
25
|
+
pi_bp = Blueprint('pi', __name__)
|
|
26
|
+
|
|
27
|
+
BASE_DIR = pathlib.Path(__file__).parent.parent
|
|
28
|
+
|
|
29
|
+
_PI_UA_MARKERS = ('aarch64', 'armv7l', 'armv6l')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_pi_ua(ua: str) -> bool:
|
|
33
|
+
"""Return True if the User-Agent string indicates an ARM/Pi browser."""
|
|
34
|
+
return any(marker in (ua or '').lower() for marker in _PI_UA_MARKERS)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pi_bp.before_app_request
|
|
38
|
+
def redirect_pi_browsers():
|
|
39
|
+
"""Redirect ARM browsers visiting / to the Pi-optimized /pi page."""
|
|
40
|
+
if request.method == 'GET' and request.path == '/':
|
|
41
|
+
if _is_pi_ua(request.headers.get('User-Agent', '')):
|
|
42
|
+
return redirect(url_for('pi.serve_pi_index'), code=302)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pi_bp.route('/pi')
|
|
46
|
+
def serve_pi_index():
|
|
47
|
+
"""
|
|
48
|
+
Serve index-pi.html with the same AGENT_CONFIG injection used by
|
|
49
|
+
the main serve_index() in server.py.
|
|
50
|
+
"""
|
|
51
|
+
html = (BASE_DIR / 'index-pi.html').read_text()
|
|
52
|
+
|
|
53
|
+
server_url = os.environ.get('AGENT_SERVER_URL', '').strip().rstrip('/')
|
|
54
|
+
if server_url:
|
|
55
|
+
config_block = f'<script>window.AGENT_CONFIG={{serverUrl:"{server_url}"}};</script>'
|
|
56
|
+
else:
|
|
57
|
+
config_block = '<script>window.AGENT_CONFIG={serverUrl:window.location.origin};</script>'
|
|
58
|
+
|
|
59
|
+
html = html.replace('</head>', f' {config_block}\n</head>', 1)
|
|
60
|
+
resp = Response(html, mimetype='text/html')
|
|
61
|
+
resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
|
62
|
+
return resp
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
routes/profiles.py — Agent Profile API Blueprint (P5-T4)
|
|
3
|
+
|
|
4
|
+
Endpoints:
|
|
5
|
+
GET /api/profiles — list all profiles (summary)
|
|
6
|
+
GET /api/profiles/active — get the currently active profile (full)
|
|
7
|
+
GET /api/profiles/<id> — get a single profile by id (full)
|
|
8
|
+
POST /api/profiles — create a new profile
|
|
9
|
+
POST /api/profiles/activate — activate a profile by id
|
|
10
|
+
PUT /api/profiles/<id> — partial-update an existing profile
|
|
11
|
+
DELETE /api/profiles/<id> — delete a profile (default profile protected)
|
|
12
|
+
|
|
13
|
+
ADR-002: Profile storage as JSON files.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from flask import Blueprint, jsonify, request
|
|
20
|
+
|
|
21
|
+
from profiles.manager import get_profile_manager, Profile
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
profiles_bp = Blueprint("profiles", __name__)
|
|
26
|
+
|
|
27
|
+
_DEFAULT_PROFILE = "default"
|
|
28
|
+
from services.paths import ACTIVE_PROFILE_FILE as _ACTIVE_PROFILE_FILE
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_active_profile_id() -> str:
|
|
32
|
+
"""Read persisted active profile from disk, fall back to default."""
|
|
33
|
+
try:
|
|
34
|
+
if _ACTIVE_PROFILE_FILE.exists():
|
|
35
|
+
saved = _ACTIVE_PROFILE_FILE.read_text().strip()
|
|
36
|
+
if saved:
|
|
37
|
+
return saved
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
logger.warning("Could not read .active-profile: %s", exc)
|
|
40
|
+
return _DEFAULT_PROFILE
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _save_active_profile_id(profile_id: str) -> None:
|
|
44
|
+
"""Persist active profile to disk so it survives service restarts."""
|
|
45
|
+
try:
|
|
46
|
+
_ACTIVE_PROFILE_FILE.write_text(profile_id)
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
logger.warning("Could not save .active-profile: %s", exc)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Load persisted selection on startup (survives service restarts)
|
|
52
|
+
_active_profile_id = _load_active_profile_id()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# GET /api/profiles
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
@profiles_bp.route("/api/profiles", methods=["GET"])
|
|
60
|
+
def list_profiles():
|
|
61
|
+
"""Return summary list of all profiles plus the active profile id."""
|
|
62
|
+
manager = get_profile_manager()
|
|
63
|
+
return jsonify({
|
|
64
|
+
"profiles": manager.list_profiles(),
|
|
65
|
+
"active": _active_profile_id,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# GET /api/profiles/active
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
@profiles_bp.route("/api/profiles/active", methods=["GET"])
|
|
74
|
+
def get_active_profile():
|
|
75
|
+
"""Return the full currently active profile object."""
|
|
76
|
+
manager = get_profile_manager()
|
|
77
|
+
profile = manager.get_profile(_active_profile_id)
|
|
78
|
+
if not profile:
|
|
79
|
+
return jsonify({"error": f"Active profile '{_active_profile_id}' not found"}), 404
|
|
80
|
+
return jsonify(profile.to_dict())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# GET /api/profiles/<profile_id>
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
@profiles_bp.route("/api/profiles/<profile_id>", methods=["GET"])
|
|
88
|
+
def get_profile(profile_id):
|
|
89
|
+
"""Return a single profile by id."""
|
|
90
|
+
manager = get_profile_manager()
|
|
91
|
+
profile = manager.get_profile(profile_id)
|
|
92
|
+
if not profile or profile.id != profile_id:
|
|
93
|
+
return jsonify({"error": f"Profile '{profile_id}' not found"}), 404
|
|
94
|
+
return jsonify(profile.to_dict())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# POST /api/profiles — create
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
@profiles_bp.route("/api/profiles", methods=["POST"])
|
|
102
|
+
def create_profile():
|
|
103
|
+
"""
|
|
104
|
+
Create a new profile from a JSON body.
|
|
105
|
+
|
|
106
|
+
Required fields: id, name, system_prompt, llm.provider, voice.tts_provider
|
|
107
|
+
Returns 201 with the created profile on success, 400 on validation error,
|
|
108
|
+
409 if a profile with the same id already exists.
|
|
109
|
+
"""
|
|
110
|
+
manager = get_profile_manager()
|
|
111
|
+
data = request.get_json(silent=True)
|
|
112
|
+
if not data:
|
|
113
|
+
return jsonify({"error": "Request body must be JSON"}), 400
|
|
114
|
+
|
|
115
|
+
errors = manager.validate_profile(data)
|
|
116
|
+
if errors:
|
|
117
|
+
return jsonify({"errors": errors}), 400
|
|
118
|
+
|
|
119
|
+
profile_id = data["id"]
|
|
120
|
+
if manager.profile_exists(profile_id):
|
|
121
|
+
return jsonify({"error": f"Profile '{profile_id}' already exists. Use PUT to update."}), 409
|
|
122
|
+
|
|
123
|
+
profile = Profile.from_dict(data)
|
|
124
|
+
if not manager.save_profile(profile):
|
|
125
|
+
return jsonify({"error": "Failed to save profile"}), 500
|
|
126
|
+
|
|
127
|
+
logger.info("Created profile: %s", profile_id)
|
|
128
|
+
return jsonify(profile.to_dict()), 201
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# POST /api/profiles/activate
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
@profiles_bp.route("/api/profiles/activate", methods=["POST"])
|
|
136
|
+
def activate_profile():
|
|
137
|
+
"""
|
|
138
|
+
Activate a profile.
|
|
139
|
+
|
|
140
|
+
Request body: {"profile_id": "default"}
|
|
141
|
+
Response: {"ok": true, "active": "default", "profile": {...}}
|
|
142
|
+
"""
|
|
143
|
+
global _active_profile_id
|
|
144
|
+
|
|
145
|
+
data = request.get_json(silent=True) or {}
|
|
146
|
+
profile_id = data.get("profile_id", "").strip()
|
|
147
|
+
|
|
148
|
+
if not profile_id:
|
|
149
|
+
return jsonify({"ok": False, "error": "Missing 'profile_id'"}), 400
|
|
150
|
+
|
|
151
|
+
manager = get_profile_manager()
|
|
152
|
+
profile = manager.get_profile(profile_id)
|
|
153
|
+
if not profile or profile.id != profile_id:
|
|
154
|
+
return jsonify({"ok": False, "error": f"Profile '{profile_id}' not found"}), 404
|
|
155
|
+
|
|
156
|
+
_active_profile_id = profile_id
|
|
157
|
+
_save_active_profile_id(profile_id)
|
|
158
|
+
logger.info("Profile activated: %s", profile_id)
|
|
159
|
+
|
|
160
|
+
return jsonify({
|
|
161
|
+
"ok": True,
|
|
162
|
+
"active": _active_profile_id,
|
|
163
|
+
"profile": profile.to_dict(),
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# PUT /api/profiles/<profile_id> — partial update
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
@profiles_bp.route("/api/profiles/<profile_id>", methods=["PUT"])
|
|
172
|
+
def update_profile(profile_id):
|
|
173
|
+
"""
|
|
174
|
+
Partially update an existing profile. Only the supplied fields are changed.
|
|
175
|
+
Sub-objects (llm, voice, etc.) are merged one level deep.
|
|
176
|
+
Returns 200 with the updated profile on success.
|
|
177
|
+
"""
|
|
178
|
+
manager = get_profile_manager()
|
|
179
|
+
if not manager.profile_exists(profile_id):
|
|
180
|
+
return jsonify({"error": f"Profile '{profile_id}' not found"}), 404
|
|
181
|
+
|
|
182
|
+
data = request.get_json(silent=True)
|
|
183
|
+
if not data:
|
|
184
|
+
return jsonify({"error": "Request body must be JSON"}), 400
|
|
185
|
+
|
|
186
|
+
# Prevent changing the id via update body
|
|
187
|
+
data.pop("id", None)
|
|
188
|
+
|
|
189
|
+
updated = manager.apply_partial_update(profile_id, data)
|
|
190
|
+
if updated is None:
|
|
191
|
+
return jsonify({"error": "Failed to update profile"}), 500
|
|
192
|
+
|
|
193
|
+
logger.info("Updated profile: %s", profile_id)
|
|
194
|
+
return jsonify(updated.to_dict())
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# DELETE /api/profiles/<profile_id>
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
@profiles_bp.route("/api/profiles/<profile_id>", methods=["DELETE"])
|
|
202
|
+
def delete_profile(profile_id):
|
|
203
|
+
"""
|
|
204
|
+
Delete a profile. The default profile ('default') cannot be deleted.
|
|
205
|
+
Returns 204 on success, 400 if protected, 404 if not found.
|
|
206
|
+
"""
|
|
207
|
+
manager = get_profile_manager()
|
|
208
|
+
if not manager.profile_exists(profile_id):
|
|
209
|
+
return jsonify({"error": f"Profile '{profile_id}' not found"}), 404
|
|
210
|
+
|
|
211
|
+
if not manager.delete_profile(profile_id):
|
|
212
|
+
return jsonify({"error": f"Cannot delete profile '{profile_id}' (protected)"}), 400
|
|
213
|
+
|
|
214
|
+
logger.info("Deleted profile: %s", profile_id)
|
|
215
|
+
return "", 204
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Issue reporting — user-submitted bug/feedback reports saved to disk.
|
|
3
|
+
|
|
4
|
+
POST /api/report-issue — save an issue report
|
|
5
|
+
GET /api/report-issues — list recent reports
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from flask import Blueprint, jsonify, request
|
|
13
|
+
|
|
14
|
+
from services.paths import RUNTIME_DIR
|
|
15
|
+
|
|
16
|
+
report_issue_bp = Blueprint('report_issue', __name__)
|
|
17
|
+
|
|
18
|
+
REPORTS_DIR = RUNTIME_DIR / 'issue-reports'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@report_issue_bp.route('/api/report-issue', methods=['POST'])
|
|
22
|
+
def submit_issue():
|
|
23
|
+
data = request.get_json(force=True, silent=True) or {}
|
|
24
|
+
|
|
25
|
+
issue_type = (data.get('type') or 'other').strip()[:50]
|
|
26
|
+
description = (data.get('description') or '').strip()[:2000]
|
|
27
|
+
context = data.get('context') or {}
|
|
28
|
+
|
|
29
|
+
if not description:
|
|
30
|
+
return jsonify({'error': 'Description required'}), 400
|
|
31
|
+
|
|
32
|
+
now = datetime.now()
|
|
33
|
+
report = {
|
|
34
|
+
'ts': now.isoformat(),
|
|
35
|
+
'type': issue_type,
|
|
36
|
+
'description': description,
|
|
37
|
+
'context': context,
|
|
38
|
+
'ua': request.headers.get('User-Agent', ''),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
|
|
43
|
+
date_str = now.strftime('%Y-%m-%d')
|
|
44
|
+
time_str = now.strftime('%H-%M-%S')
|
|
45
|
+
filename = f'{date_str}_{time_str}_{issue_type}.json'
|
|
46
|
+
filepath = REPORTS_DIR / filename
|
|
47
|
+
|
|
48
|
+
# Handle the (unlikely) same-second collision
|
|
49
|
+
if filepath.exists():
|
|
50
|
+
filepath = REPORTS_DIR / f'{date_str}_{time_str}_{issue_type}_2.json'
|
|
51
|
+
|
|
52
|
+
filepath.write_text(json.dumps(report, indent=2))
|
|
53
|
+
|
|
54
|
+
return jsonify({'ok': True, 'saved': filename})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@report_issue_bp.route('/api/report-issues', methods=['GET'])
|
|
58
|
+
def list_issues():
|
|
59
|
+
"""Return last N issue reports, newest first."""
|
|
60
|
+
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
files = sorted(REPORTS_DIR.glob('*.json'), reverse=True)[:50]
|
|
62
|
+
reports = []
|
|
63
|
+
for f in files:
|
|
64
|
+
try:
|
|
65
|
+
reports.append(json.loads(f.read_text()))
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
return jsonify(reports)
|