diff --git a/.env.example b/.env.example index 39acb3a..6a23477 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/bridge/melo_worker.py b/bridge/melo_worker.py index 4da17eb..d1f57b7 100644 --- a/bridge/melo_worker.py +++ b/bridge/melo_worker.py @@ -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 diff --git a/bridge/server.py b/bridge/server.py index 634660b..a8a3fe8 100644 --- a/bridge/server.py +++ b/bridge/server.py @@ -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) diff --git a/bridge/settings_web.py b/bridge/settings_web.py new file mode 100644 index 0000000..042336a --- /dev/null +++ b/bridge/settings_web.py @@ -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 = """ + +Jarvis 설정 +

⚙️ Jarvis 설정

+

저장 후 [적용]을 누르면 브리지/TTS가 재시작되며 반영됩니다. (내부망 전용)

+
+
+
+
+""" + + +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()}) diff --git a/docker-compose.yml b/docker-compose.yml index c76a0fb..ad7223f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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. diff --git a/src/jarvis/reply/engine.py b/src/jarvis/reply/engine.py index 8301def..48098b8 100644 --- a/src/jarvis/reply/engine.py +++ b/src/jarvis/reply/engine.py @@ -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.