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 = """
+ +저장 후 [적용]을 누르면 브리지/TTS가 재시작되며 반영됩니다. (내부망 전용)
+ +