"""Settings web UI for the Jarvis bridge. A small in-app page (served by the Flask bridge) to change models, language, TTS and the LLM instructions WITHOUT editing files or rebuilding. Writes to the runtime config JSON (JARVIS_CONFIG_PATH) that ``load_settings()`` reads, then restarts the bridge (and TTS worker) via supervisord so changes take effect. Internal-network use only (no auth, per deployment decision). """ from __future__ import annotations import json import os import subprocess import urllib.request from pathlib import Path from typing import Any, Dict # Fields the UI manages. Each maps to a key in the runtime config JSON, with a # label and an input kind for the form. FIELDS = [ ("ollama_chat_model", "LLM 모델", "model"), ("whisper_model", "STT(Whisper) 모델", "select:tiny,base,small,medium,large,large-v3"), ("tts_engine", "TTS 엔진", "select:edge,piper"), ("output_language", "출력 언어 (비우면 사용자 언어)", "text"), ("llm_thinking_enabled", "LLM 사고(thinking) 모드", "bool"), ("agentic_max_turns", "에이전트 최대 반복", "number:1:12:1"), ("llm_instructions", "LLM 추가 지침 (시스템 프롬프트에 덧붙임)", "textarea"), ] _KEYS = [k for k, _, _ in FIELDS] def _config_path() -> Path: p = os.environ.get("JARVIS_CONFIG_PATH") return Path(p).expanduser() if p else (Path.home() / ".config" / "jarvis" / "config.json") def _persist_path() -> Path: """Persistent overrides on the data volume — survive container recreate. entrypoint.sh merges this back onto the env-rendered config at startup.""" return Path(os.environ.get("JARVIS_SETTINGS_PATH") or "/data/jarvis-settings.json") def _read_config() -> Dict[str, Any]: try: return json.loads(_config_path().read_text("utf-8")) except Exception: return {} def _current() -> Dict[str, Any]: cfg = _read_config() out: Dict[str, Any] = {} for k in _KEYS: if k == "output_language": out[k] = cfg.get("output_language", os.environ.get("OUTPUT_LANGUAGE", "")) else: out[k] = cfg.get(k, "") return out def _ollama_models() -> list[str]: base = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/") try: with urllib.request.urlopen(f"{base}/api/tags", timeout=4) as r: data = json.loads(r.read()) return sorted(m.get("name", "") for m in data.get("models", []) if m.get("name")) except Exception: return [] def _coerce(updates: Dict[str, Any]) -> Dict[str, Any]: clean: Dict[str, Any] = {} for k, v in updates.items(): if k not in _KEYS: continue if k == "agentic_max_turns": try: v = int(v) except (TypeError, ValueError): continue elif k == "llm_thinking_enabled": v = str(v).lower() in ("1", "true", "on", "yes") clean[k] = v return clean def _write_merge(path: Path, clean: Dict[str, Any]) -> None: cur: Dict[str, Any] = {} try: cur = json.loads(path.read_text("utf-8")) except Exception: cur = {} cur.update(clean) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(cur, ensure_ascii=False, indent=2), "utf-8") def _save(updates: Dict[str, Any]) -> None: clean = _coerce(updates) # 1) persistent overrides (survive `docker compose up` recreate) _write_merge(_persist_path(), clean) # 2) runtime config so a bridge/worker restart picks it up immediately _write_merge(_config_path(), clean) def _apply() -> str: # Restart the bridge AFTER this response is sent. Detached (new session) so # the bridge being killed mid-restart doesn't drop the restart itself, and # the HTTP client still receives this response. (Edge TTS has no worker.) try: subprocess.Popen( ["sh", "-c", "sleep 1; supervisorctl restart bridge"], start_new_session=True, ) return "1초 후 브리지가 재시작되어 반영됩니다." except Exception as e: # pragma: no cover return str(e) _PAGE = """ Jarvis 설정

⚙️ Jarvis 설정

저장 후 [적용]을 누르면 브리지/TTS가 재시작되며 반영됩니다. (내부망 전용)

""" def register(app) -> None: """Attach the settings routes to the Flask ``app``.""" from flask import request, jsonify, Response @app.get("/settings") def _settings_page(): # noqa: ANN202 html = ( _PAGE.replace("__FIELDS__", json.dumps(FIELDS, ensure_ascii=False)) .replace("__MODELS__", json.dumps(_ollama_models())) .replace("__CUR__", json.dumps(_current(), ensure_ascii=False)) ) return Response(html, mimetype="text/html") @app.get("/api/settings") def _get_settings(): # noqa: ANN202 return jsonify({"ok": True, "settings": _current(), "models": _ollama_models()}) @app.post("/api/settings") def _post_settings(): # noqa: ANN202 data = request.get_json(silent=True) or {} try: _save(data) return jsonify({"ok": True}) except Exception as e: # pragma: no cover return jsonify({"ok": False, "error": str(e)}), 500 @app.post("/api/settings/apply") def _apply_settings(): # noqa: ANN202 return jsonify({"ok": True, "result": _apply()})