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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
176
bridge/settings_web.py
Normal 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()})
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user