feat: settings web UI (models / STT / TTS speed / language / LLM instructions)

Adds /settings (served by the bridge) to change the LLM model (from installed
Ollama models), Whisper model, TTS engine + MeloTTS speed, output language,
agentic max-turns, thinking mode, and free-form LLM instructions — live, with a
'apply' that restarts the bridge + TTS worker. Settings persist to the runtime
config JSON; engine reads output_language + llm_instructions and the TTS worker
reads melo_speed from it. Bridge port publishable for access.
This commit is contained in:
javis-bot
2026-06-15 13:05:46 +09:00
parent 3bdc7d078a
commit 84e435f916
6 changed files with 233 additions and 2 deletions

View File

@@ -187,3 +187,10 @@ COMPOSE_FILE=docker-compose.yml:docker-compose.gpu-linux.yml
# OLLAMA_CHAT_MODEL=qwen2.5:3b # speed (fits easily, faster on 8GB GPUs)
# WHISPER_MODEL=small # small frees VRAM for a bigger LLM; medium=more accurate
# MELO_DEVICE=cuda # cpu if no GPU on the bot host
# --- Settings web UI (http://localhost:8765/settings on the bot host) ---
# To reach it, expose the bridge to the host loopback:
# BRIDGE_HOST=0.0.0.0
# SETTINGS_PUBLISH_BIND=127.0.0.1 # 0.0.0.0 to allow LAN access (no auth)
# Change models / STT / TTS speed / language / LLM instructions live; "적용"
# restarts the bridge + TTS worker so changes take effect.

View File

@@ -36,7 +36,26 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HOST = os.environ.get("MELO_WORKER_HOST", "127.0.0.1")
PORT = int(os.environ.get("MELO_WORKER_PORT", "8770"))
LANGUAGE = os.environ.get("MELO_LANGUAGE", "KR")
SPEED = float(os.environ.get("MELO_SPEED", "1.5"))
def _resolve_speed() -> float:
"""Speaking rate: the settings-UI value (runtime config JSON) wins, else the
MELO_SPEED env, else 1.5. Read at startup; the settings UI restarts this
worker on apply so a new value takes effect."""
try:
cp = os.environ.get("JARVIS_CONFIG_PATH", "/app/config/jarvis.json")
v = json.loads(open(cp, encoding="utf-8").read()).get("melo_speed")
if v is not None:
return float(v)
except Exception:
pass
try:
return float(os.environ.get("MELO_SPEED", "1.5"))
except ValueError:
return 1.5
SPEED = _resolve_speed()
DEVICE = os.environ.get("MELO_DEVICE", "cpu")
# Model + speaker id are loaded once, guarded by a lock because MeloTTS

View File

@@ -52,11 +52,18 @@ from flask import Flask, request, jsonify, Response, stream_with_context
try: # package-relative when imported as ``bridge.server``
from bridge.text_utils import split_sentences
from bridge.stt_filter import filter_speech_segments, has_speech
from bridge import settings_web
except ImportError: # script-relative when run as ``bridge/server.py``
from text_utils import split_sentences
from stt_filter import filter_speech_segments, has_speech
import settings_web
app = Flask(__name__)
# Settings web UI (/settings) — change models/language/TTS/instructions live.
try:
settings_web.register(app)
except Exception as _e: # pragma: no cover - never block the bridge on the UI
print(f"[bridge] settings UI unavailable: {_e}", flush=True)
# ---------------------------------------------------------------------------
# Configuration (env-driven; see .env.example)

176
bridge/settings_web.py Normal file
View File

@@ -0,0 +1,176 @@
"""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:melo,piper"),
("melo_speed", "TTS 속도 (MeloTTS)", "number:0.5:2.5:0.1"),
("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 _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 == "melo_speed":
out[k] = cfg.get("melo_speed", os.environ.get("MELO_SPEED", "1.5"))
elif 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 _save(updates: Dict[str, Any]) -> None:
cfg = _read_config()
for k, v in updates.items():
if k not in _KEYS:
continue
if k == "melo_speed":
try:
v = float(v)
except (TypeError, ValueError):
continue
elif 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")
cfg[k] = v
p = _config_path()
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), "utf-8")
def _apply() -> str:
# Restart the bridge (re-reads config) and the TTS worker (picks up speed).
out = []
for prog in ("melo-worker", "bridge"):
try:
r = subprocess.run(["supervisorctl", "restart", prog], capture_output=True, text=True, timeout=30)
out.append(f"{prog}: {(r.stdout or r.stderr).strip()}")
except Exception as e: # pragma: no cover
out.append(f"{prog}: {e}")
return " | ".join(out)
_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()})

View File

@@ -119,6 +119,9 @@ services:
# Browser-control endpoint a remote bot posts to (real xdotool input runs
# on THIS host). Published on the LAN for the browser-host layout.
- "${CDP_PUBLISH_BIND:-127.0.0.1}:${BROWSER_CONTROL_PORT:-8777}:8777" # control-server
# Settings UI + brain API (bridge). Reach it at http://localhost:8765/settings
# on the bot host. Requires BRIDGE_HOST=0.0.0.0 (set in .env) to forward.
- "${SETTINGS_PUBLISH_BIND:-127.0.0.1}:${BRIDGE_PORT:-8765}:8765" # bridge / settings
# The brain bridge is NOT published: it binds the container's loopback
# (BRIDGE_HOST=127.0.0.1) and is only consumed by the bot in this same
# container, so it needs no host port and stays unreachable off-container.

View File

@@ -826,6 +826,20 @@ def _build_enrichment_context_hint(cfg, recent_messages: list) -> Optional[str]:
# Site tokens (proper nouns, not language patterns) → controlBrowser search site.
def _extra_config(key: str, default=""):
"""Read a key from the runtime config JSON (JARVIS_CONFIG_PATH) for settings
the settings-web UI manages but that aren't on the Settings dataclass
(llm_instructions, output_language override). Cheap + fail-open."""
try:
import json as _json
from pathlib import Path as _Path
p = os.environ.get("JARVIS_CONFIG_PATH")
path = _Path(p).expanduser() if p else (_Path.home() / ".config" / "jarvis" / "config.json")
return _json.loads(path.read_text("utf-8")).get(key, default) or default
except Exception:
return default
_SITE_TOKEN_MAP = {
"네이버": "naver", "naver": "naver",
"구글": "google", "google": "google",
@@ -1683,7 +1697,7 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any],
# it primacy over the persona's "use the user's language" lines — a tail
# instruction loses to those when the query itself is in another language.
_lang_directive = reply_language_directive(
os.environ.get("OUTPUT_LANGUAGE"),
os.environ.get("OUTPUT_LANGUAGE") or _extra_config("output_language", ""),
getattr(cfg, "tts_engine", "piper"),
)
if _lang_directive:
@@ -1768,6 +1782,11 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any],
# else: tools are passed via the native tools API parameter — do not include tools_desc
# here as well, since that confuses the model and causes it to not use tools properly.
# User-defined extra LLM instructions from the settings UI.
_user_instructions = str(_extra_config("llm_instructions", "")).strip()
if _user_instructions:
guidance.append("Additional instructions from the operator:\n" + _user_instructions)
# Recency reinforcement: repeat the language lock at the very END too.
# In a ~5k-token prompt the front-placed rule gets "lost in the middle";
# bigger models (qwen2.5:7b) otherwise leak Chinese/Cyrillic mid-reply.