My Voices - Top bar with search, filter toggle (active-count badge), synth-source drawer, group and table buttons; filters/sort in a collapsible labelled panel; one-line sort header with select-all/none checkbox - Groups chip list is a collapsed accordion; bottom bar is Refresh/Sync/Import plus one Actions (N) menu (Edit / Audio / Share / Danger), disabled until voices are checked; touch targets stay 44px - Copy selected works again (copyText was no longer exported), shows "N voice names copied to clipboard" and falls back to a copy dialog when the async clipboard is unavailable TTS / engines - Health probe works for /v1 card URLs and, behind a LiteLLM gateway, checks the selected model's own upstream so stopped containers are not reported as available - Unhiding a voice publishes a missing entry to the engine's voices.json (no more silent default-voice fallback) - Status-bar TTS picker also drives the My Voices and STT-test selectors; Use as TTS refreshes the status bar Includes the engine connect/disconnect, custom TTS, STT footer and magazine auth work that was already in the working tree, with their tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
171 lines
6.8 KiB
Python
171 lines
6.8 KiB
Python
"""Settings, TTS routes, routing log, server logs, and voice design presets endpoints."""
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, HTTPException, Request, Response
|
|
|
|
from core.config import _load_settings, _save_settings, _normalize_settings, _SETTINGS_KEYS, _ensure_external_api_key
|
|
from core.routing import _load_tts_routes, _save_tts_routes
|
|
from core.constants import _log_buffer, _LOG_BUFFER_MAX, _routing_log, _ROUTING_LOG_MAX
|
|
from core.presets import _load_design_presets, _save_design_presets
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
_SECRET_SETTING_KEYS = {
|
|
"groq_api_key", "whisper_api_key", "tts_api_key", "voice_design_api_key",
|
|
"elevenlabs_api_key", "llm_api_key", "engine_api_keys", "external_api_key",
|
|
"vision_ocr_api_key", "vision_ocr_fallback_api_key",
|
|
}
|
|
_SECRET_MASK = "__configured__"
|
|
|
|
|
|
@router.get("/api/settings")
|
|
async def get_settings(request: Request, response: Response):
|
|
s = _load_settings()
|
|
# Lazily generated on first read, not at server startup — a fresh
|
|
# install shows a real usable key in Settings immediately without a
|
|
# migration step, whether or not the gate is actually turned on yet.
|
|
s["external_api_key"] = _ensure_external_api_key()
|
|
# Never return provider credentials to browser JavaScript. The mask lets
|
|
# the Settings UI indicate that a value exists without making it usable in
|
|
# client-side requests or browser storage.
|
|
for key in _SECRET_SETTING_KEYS:
|
|
value = s.get(key)
|
|
if key == "engine_api_keys" and isinstance(value, dict):
|
|
s[key] = {name: _SECRET_MASK for name, secret in value.items() if secret}
|
|
elif value:
|
|
s[key] = _SECRET_MASK
|
|
else:
|
|
s.pop(key, None)
|
|
response.headers["Cache-Control"] = "no-store, no-cache, max-age=0"
|
|
response.headers["Pragma"] = "no-cache"
|
|
return s
|
|
|
|
|
|
@router.post("/api/settings/regenerate-api-key")
|
|
async def regenerate_api_key(response: Response):
|
|
import secrets
|
|
s = _load_settings()
|
|
s["external_api_key"] = secrets.token_urlsafe(32)
|
|
_save_settings(s)
|
|
response.headers["Cache-Control"] = "no-store, no-cache, max-age=0"
|
|
response.headers["Pragma"] = "no-cache"
|
|
return {"external_api_key": s["external_api_key"]}
|
|
|
|
|
|
@router.post("/api/settings")
|
|
async def post_settings(request: Request):
|
|
data = await request.json()
|
|
if not isinstance(data, dict):
|
|
raise HTTPException(400, "JSON object is required")
|
|
if "custom_engine_cards" in data:
|
|
from core.provider_health import validate_provider_params
|
|
cards = data["custom_engine_cards"]
|
|
if not isinstance(cards, list) or any(not isinstance(card, dict) for card in cards):
|
|
raise HTTPException(400, "Custom engine cards must be a list of objects")
|
|
for card in cards:
|
|
try:
|
|
validate_provider_params(card.get("params", {}))
|
|
except ValueError as exc:
|
|
raise HTTPException(400, str(exc)) from None
|
|
s = _load_settings()
|
|
previous_cards = {str(card.get("id") or card.get("name") or ""): card for card in s.get("custom_engine_cards", []) if isinstance(card, dict)}
|
|
previous_maps = {key: dict(s.get(key) or {}) for key in ("engine_api_keys", "engine_models", "engine_local_urls")}
|
|
clear = {str(k) for k in (data.get("clear_secrets") or [])} if isinstance(data.get("clear_secrets"), list) else set()
|
|
for key, value in data.items():
|
|
if key not in _SETTINGS_KEYS:
|
|
continue
|
|
if key in _SECRET_SETTING_KEYS:
|
|
if value == _SECRET_MASK:
|
|
continue
|
|
if key == "engine_api_keys" and isinstance(value, dict):
|
|
old = s.get(key) if isinstance(s.get(key), dict) else {}
|
|
merged = dict(old)
|
|
for name, secret in value.items():
|
|
if secret and secret != _SECRET_MASK:
|
|
merged[name] = secret
|
|
for name in clear:
|
|
merged.pop(name, None)
|
|
s[key] = merged
|
|
continue
|
|
if value in (None, "") and key not in clear:
|
|
continue
|
|
s[key] = value
|
|
if isinstance(data.get("custom_engine_cards"), list):
|
|
for card in data["custom_engine_cards"]:
|
|
if not isinstance(card, dict):
|
|
continue
|
|
identity = str(card.get("id") or card.get("name") or "")
|
|
previous = previous_cards.get(identity)
|
|
if not previous:
|
|
continue
|
|
old_name = str(previous.get("name") or "")
|
|
for key in previous_maps:
|
|
source = "dc-" + old_name if key == "engine_local_urls" else old_name
|
|
target = "dc-" + identity if key == "engine_local_urls" else identity
|
|
values = dict(s.get(key) or {})
|
|
if key == "engine_api_keys" and (identity in clear or old_name in clear):
|
|
continue
|
|
if target not in values:
|
|
if target in previous_maps[key]:
|
|
values[target] = previous_maps[key][target]
|
|
elif source in previous_maps[key]:
|
|
values[target] = values.get(source, previous_maps[key][source])
|
|
s[key] = values
|
|
s = _normalize_settings(s)
|
|
_save_settings(s)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/api/tts-routes")
|
|
async def get_tts_routes():
|
|
return {"routes": _load_tts_routes()}
|
|
|
|
|
|
@router.post("/api/tts-routes")
|
|
async def post_tts_routes(request: Request):
|
|
data = await request.json()
|
|
routes = data.get("routes", data) if isinstance(data, dict) else data
|
|
if not isinstance(routes, list):
|
|
raise HTTPException(400, "routes must be a list")
|
|
_save_tts_routes(routes)
|
|
return {"ok": True, "routes": _load_tts_routes()}
|
|
|
|
|
|
@router.get("/api/tts-routing-log")
|
|
async def get_tts_routing_log(limit: int = 60):
|
|
limit = max(1, min(int(limit or 60), _ROUTING_LOG_MAX))
|
|
return {"items": _routing_log[:limit], "max": _ROUTING_LOG_MAX}
|
|
|
|
|
|
@router.delete("/api/tts-routing-log")
|
|
async def clear_tts_routing_log():
|
|
_routing_log.clear()
|
|
return {"ok": True, "items": []}
|
|
|
|
|
|
@router.get("/api/logs")
|
|
async def get_server_logs(limit: int = 200):
|
|
limit = max(1, min(int(limit or 200), _LOG_BUFFER_MAX))
|
|
return {"items": _log_buffer[:limit], "max": _LOG_BUFFER_MAX}
|
|
|
|
|
|
@router.delete("/api/logs")
|
|
async def clear_server_logs():
|
|
_log_buffer.clear()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/api/voice-design-presets")
|
|
async def get_voice_design_presets():
|
|
return _load_design_presets()
|
|
|
|
|
|
@router.post("/api/voice-design-presets")
|
|
async def post_voice_design_presets(request: Request):
|
|
data = await request.json()
|
|
if not isinstance(data, dict):
|
|
raise HTTPException(400, "Expected a preset object")
|
|
_save_design_presets(data)
|
|
return {"ok": True, "presets": _load_design_presets()}
|