Some checks failed
Release / semantic-release (push) Successful in 31s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / build-linux (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
tests / Unit tests (Linux, Python 3.11) (push) Has been cancelled
The user chose Microsoft Edge TTS, voice ko-KR-HyunsuMultilingualNeural at rate +45% (~1.45x), as the natural Korean voice. Wire it into the bridge and make it the default engine. - bridge/server.py: _edge_synthesize() calls edge-tts and transcodes the MP3 to PCM16 mono WAV with the system ffmpeg (temp file for a correct header); TTS_ENGINE default -> edge; EDGE_TTS_VOICE / EDGE_TTS_RATE env-driven - requirements-bridge.txt: add edge-tts (lightweight; httpx) - compose/.env.example/README: TTS_ENGINE=edge + EDGE_TTS_* knobs; note the online/privacy trade-off (reply text is sent to Microsoft, needs internet) - drop the now-unused MeloTTS build layer (Dockerfile) and melo-worker (supervisord) — edge synthesises in-process, no model/worker baked, slimmer and faster image; settings UI engine list -> edge/piper, restart only bridge Verified on host: edge-tts -> ffmpeg yields a valid 16-bit mono 24kHz WAV; envsubst renders tts_engine=edge; docker build --check + 26 tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
194 lines
8.1 KiB
Python
194 lines
8.1 KiB
Python
"""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 = """<!doctype html><html lang=ko><head><meta charset=utf-8>
|
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
|
<title>Jarvis 설정</title><style>
|
|
body{font-family:system-ui,Segoe UI,Apple SD Gothic Neo,sans-serif;max-width:680px;margin:24px auto;padding:0 16px;color:#222}
|
|
h1{font-size:20px}label{display:block;margin:14px 0 4px;font-weight:600}
|
|
input,select,textarea{width:100%;padding:8px;border:1px solid #ccc;border-radius:8px;font-size:14px;box-sizing:border-box}
|
|
textarea{min-height:90px}.row{margin-bottom:6px}.btns{margin-top:18px;display:flex;gap:8px}
|
|
button{padding:10px 16px;border:0;border-radius:8px;font-size:14px;cursor:pointer}
|
|
.save{background:#2d6cdf;color:#fff}.apply{background:#16a34a;color:#fff}
|
|
#msg{margin-top:12px;color:#16a34a;white-space:pre-wrap}.hint{color:#888;font-weight:400;font-size:12px}
|
|
</style></head><body>
|
|
<h1>⚙️ Jarvis 설정</h1>
|
|
<p class=hint>저장 후 [적용]을 누르면 브리지/TTS가 재시작되며 반영됩니다. (내부망 전용)</p>
|
|
<form id=f></form>
|
|
<div class=btns><button class=save type=button onclick=save()>저장</button>
|
|
<button class=apply type=button onclick=apply()>저장 후 적용(재시작)</button></div>
|
|
<div id=msg></div>
|
|
<script>
|
|
const FIELDS=__FIELDS__, MODELS=__MODELS__, CUR=__CUR__;
|
|
const f=document.getElementById('f');
|
|
for(const [k,label,kind] of FIELDS){
|
|
const id='fld_'+k; let el;
|
|
if(k==='ollama_chat_model' && MODELS.length){
|
|
el=`<select id="${id}">`+MODELS.map(m=>`<option ${m===CUR[k]?'selected':''}>${m}</option>`).join('')+`</select>`;
|
|
} else if(kind.startsWith('select:')){
|
|
el='<select id="'+id+'">'+kind.slice(7).split(',').map(o=>`<option ${o===CUR[k]?'selected':''}>${o}</option>`).join('')+'</select>';
|
|
} else if(kind==='textarea'){
|
|
el=`<textarea id="${id}">${CUR[k]??''}</textarea>`;
|
|
} else if(kind==='bool'){
|
|
el=`<select id="${id}"><option value=false ${!CUR[k]?'selected':''}>off</option><option value=true ${CUR[k]?'selected':''}>on</option></select>`;
|
|
} else if(kind.startsWith('number:')){
|
|
const [mn,mx,st]=kind.slice(7).split(':');
|
|
el=`<input id="${id}" type=number min=${mn} max=${mx} step=${st} value="${CUR[k]??''}">`;
|
|
} else { el=`<input id="${id}" type=text value="${CUR[k]??''}">`; }
|
|
f.insertAdjacentHTML('beforeend',`<div class=row><label>${label}</label>${el}</div>`);
|
|
}
|
|
function collect(){const o={};for(const [k] of FIELDS){o[k]=document.getElementById('fld_'+k).value;}return o;}
|
|
async function post(url){const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(collect())});return r.json();}
|
|
async function save(){const j=await post('/api/settings');document.getElementById('msg').textContent=j.ok?'저장됨':'오류: '+(j.error||'');}
|
|
async function apply(){await post('/api/settings');const j=await fetch('/api/settings/apply',{method:'POST'}).then(r=>r.json());document.getElementById('msg').textContent='적용: '+(j.result||j.error||'');}
|
|
</script></body></html>"""
|
|
|
|
|
|
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()})
|