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)
|
# 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
|
# WHISPER_MODEL=small # small frees VRAM for a bigger LLM; medium=more accurate
|
||||||
# MELO_DEVICE=cuda # cpu if no GPU on the bot host
|
# 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")
|
HOST = os.environ.get("MELO_WORKER_HOST", "127.0.0.1")
|
||||||
PORT = int(os.environ.get("MELO_WORKER_PORT", "8770"))
|
PORT = int(os.environ.get("MELO_WORKER_PORT", "8770"))
|
||||||
LANGUAGE = os.environ.get("MELO_LANGUAGE", "KR")
|
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")
|
DEVICE = os.environ.get("MELO_DEVICE", "cpu")
|
||||||
|
|
||||||
# Model + speaker id are loaded once, guarded by a lock because MeloTTS
|
# 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``
|
try: # package-relative when imported as ``bridge.server``
|
||||||
from bridge.text_utils import split_sentences
|
from bridge.text_utils import split_sentences
|
||||||
from bridge.stt_filter import filter_speech_segments, has_speech
|
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``
|
except ImportError: # script-relative when run as ``bridge/server.py``
|
||||||
from text_utils import split_sentences
|
from text_utils import split_sentences
|
||||||
from stt_filter import filter_speech_segments, has_speech
|
from stt_filter import filter_speech_segments, has_speech
|
||||||
|
import settings_web
|
||||||
|
|
||||||
app = Flask(__name__)
|
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)
|
# 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
|
# Browser-control endpoint a remote bot posts to (real xdotool input runs
|
||||||
# on THIS host). Published on the LAN for the browser-host layout.
|
# 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
|
- "${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
|
# 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
|
# (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.
|
# 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.
|
# 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 = {
|
_SITE_TOKEN_MAP = {
|
||||||
"네이버": "naver", "naver": "naver",
|
"네이버": "naver", "naver": "naver",
|
||||||
"구글": "google", "google": "google",
|
"구글": "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
|
# 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.
|
# instruction loses to those when the query itself is in another language.
|
||||||
_lang_directive = reply_language_directive(
|
_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"),
|
getattr(cfg, "tts_engine", "piper"),
|
||||||
)
|
)
|
||||||
if _lang_directive:
|
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
|
# 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.
|
# 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.
|
# 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";
|
# 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.
|
# bigger models (qwen2.5:7b) otherwise leak Chinese/Cyrillic mid-reply.
|
||||||
|
|||||||
Reference in New Issue
Block a user