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