Files
javis_bot/bridge/settings_web.py
javis-bot f64d76e737
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
feat: use Edge TTS (Korean Hyunsu voice @ +45%) as the default voice
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>
2026-06-23 03:44:15 +09:00

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()})