diff --git a/tests/test_monitor.py b/tests/test_monitor.py index a19d5e7..8b12adf 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -37,7 +37,7 @@ def test_monitor_records_turn_with_timed_steps(): assert turn["total_ms"] >= 0 # step-by-step: every stage is named and timed names = [s["name"] for s in turn["steps"]] - assert names == ["화면 맥락", "두뇌(생각)", "응답(TTS/전송)"] + assert names == ["화면 맥락", "LLM(생각)", "응답(TTS/전송)"] assert all(s["ok"] is True for s in turn["steps"]) assert all(s["ms"] >= 0 for s in turn["steps"]) @@ -66,7 +66,7 @@ def test_monitor_marks_errors(): turn = snap["turns"][0] assert turn["status"] == "error" - brain_step = next(s for s in turn["steps"] if s["name"] == "두뇌(생각)") + brain_step = next(s for s in turn["steps"] if s["name"] == "LLM(생각)") assert brain_step["ok"] is False assert "boom" in brain_step["error"] assert snap["status"]["errors_total"] >= 1 diff --git a/wsai/__main__.py b/wsai/__main__.py index 6f402e2..4ef44c4 100644 --- a/wsai/__main__.py +++ b/wsai/__main__.py @@ -100,6 +100,28 @@ def _run_stt_test(host: str, port: int) -> None: dash.stop() +def _apply_persisted_state(stt, tts, brain) -> None: + """Apply dashboard settings saved to the state store (STT/LLM model + TTS + controls) onto freshly-built backends, so they survive a restart.""" + from . import state_store + st = state_store.load() + models = st.get("models") or {} + if models.get("stt") and stt is not None: + stt.model = str(models["stt"]) + if models.get("llm") and brain is not None: + brain.model = str(models["llm"]) + tj = st.get("tts") or {} + base = tj.get("base") or {} + for k in ("speed", "word_gap", "sentence_gap", "pitch"): + if base.get(k) is not None and tts is not None: + try: + setattr(tts, k, float(base[k])) + except (TypeError, ValueError): + pass + if tts is not None and isinstance(tj.get("overrides"), dict): + tts.emotion_overrides = {k: dict(v) for k, v in tj["overrides"].items() if isinstance(v, dict)} + + def _run_voice_server(host: str, port: int) -> None: """Serve the STT+TTS voice-turn endpoint that the Discord bot (dave/bot.mjs) calls: it POSTs a captured utterance wav and gets back the reply wav to play @@ -129,6 +151,10 @@ def _run_voice_server(host: str, port: int) -> None: monitor = Monitor() stt = WhisperSTT() tts = MeloTTS() + # Restore persisted dashboard settings (models + TTS controls) BEFORE warmup + # so the worker loads the last-chosen model. Bot lists/toggles are restored + # inside BotControl. Applied before dash.warm() so nothing reloads twice. + _apply_persisted_state(stt, tts, brain) dash = Dashboard(monitor, host=host, port=port, stt=stt, tts=tts, brain=brain) dash.start() monitor.set_components({"source": "none", "vision": "none", "stt": "whisper", diff --git a/wsai/bot_control.py b/wsai/bot_control.py index 08fe30b..fa05463 100644 --- a/wsai/bot_control.py +++ b/wsai/bot_control.py @@ -31,10 +31,13 @@ class BotControl: self._cmd_id = 0 # Per-guild listen filter. Empty whitelist => listen to everyone; # blacklist always excludes. Users and roles both supported. - self._lists: dict[str, dict[str, Any]] = {} + # Restored from the persistent state store so it survives a restart. + from . import state_store + st = state_store.load() + self._lists: dict[str, dict[str, Any]] = st.get("lists") or {} # Bot behaviour settings the dashboard toggles and the bot reads on each # report. bargeIn: stop the bot's current TTS the moment a user speaks. - self._settings: dict[str, Any] = {"bargeIn": True} + self._settings: dict[str, Any] = {"bargeIn": True, **(st.get("botSettings") or {})} # -- bot behaviour settings ------------------------------------------ # def get_settings(self) -> dict[str, Any]: @@ -45,7 +48,10 @@ class BotControl: with self._lock: if "bargeIn" in data: self._settings["bargeIn"] = bool(data["bargeIn"]) - return dict(self._settings) + out = dict(self._settings) + from . import state_store + state_store.patch("botSettings", out) + return out # -- whitelist / blacklist (per guild) ------------------------------- # @staticmethod @@ -72,6 +78,9 @@ class BotControl: ] with self._lock: self._lists[guild_id] = clean + snapshot = {g: dict(v) for g, v in self._lists.items()} + from . import state_store + state_store.patch("lists", snapshot) # persist per-guild lists across restarts return clean # -- bot -> dashboard (state push) ----------------------------------- # diff --git a/wsai/dashboard.py b/wsai/dashboard.py index a96d3ed..f14203a 100644 --- a/wsai/dashboard.py +++ b/wsai/dashboard.py @@ -526,6 +526,20 @@ class Dashboard: "pitch": float(getattr(t, "pitch", 0.0)), } + def _persist_tts(self) -> None: + from . import state_store + state_store.patch("tts", { + "base": self._tts_base(), + "overrides": dict(getattr(self.tts, "emotion_overrides", {}) or {}), + }) + + def _persist_models(self) -> None: + from . import state_store + state_store.patch("models", { + "stt": getattr(self.stt, "model", None), + "llm": getattr(self.brain, "model", None), + }) + def tts_settings(self) -> dict: """Base (공통) controls + the per-emotion overrides + the emotion list (canonical key + Korean label) so the dashboard can offer per-emotion @@ -541,11 +555,20 @@ class Dashboard: } def set_tts_settings(self, data: dict) -> dict: - """Apply controls. Without ``emotion`` (or emotion == "base"), set the - base/공통 values on the live TTS instance. With a specific emotion, store - (or, if ``reset``, clear) that emotion's override.""" + """Apply controls. ``emotion`` selects the target: + - "all" → set base to the values AND clear every per-emotion override, + so ALL emotions speak with these values (전체변경). + - "" / "base" → set the base/공통 values on the live TTS instance. + - a specific emotion → store (or, if ``reset``, clear) its override.""" emotion = data.get("emotion") - if not emotion or emotion == "base": + if emotion == "all": + for key, (lo, hi, _step) in self._TTS_RANGES.items(): + v = data.get(key) + if v is not None: + setattr(self.tts, key, float(max(lo, min(hi, float(v))))) + if getattr(self.tts, "emotion_overrides", None): + self.tts.emotion_overrides.clear() # all emotions inherit the new base + elif not emotion or emotion == "base": for key, (lo, hi, _step) in self._TTS_RANGES.items(): v = data.get(key) if v is not None: @@ -558,6 +581,7 @@ class Dashboard: store.pop(emotion, None) else: store[emotion] = self._tts_overrides(data) + self._persist_tts() return self.tts_settings() def _tts_overrides(self, data: dict) -> dict: @@ -627,6 +651,7 @@ class Dashboard: # Reload+warm in the background; don't block the HTTP response. asyncio.run_coroutine_threadsafe(self._warm_stt_bg(), self._loop) self.monitor.log("info", f"STT 모델 전환 시작: {model} (로딩 중…)", cat="MODEL") + self._persist_models() return {**self.models_settings()["stt"], "changed": changed} async def _warm_stt_bg(self) -> None: @@ -648,6 +673,7 @@ class Dashboard: self.brain.model = model if changed: self.monitor.log("info", f"LLM 모델 변경: {model} (다음 답변부터 적용)", cat="MODEL") + self._persist_models() return {**self.models_settings()["llm"], "changed": changed} @staticmethod @@ -952,6 +978,10 @@ PAGE = r""" .lst-item:last-child{border-bottom:none} .lst-item .nm{flex:1} .lst-item .rl{color:var(--muted);font-size:11px} + .role-h{cursor:pointer;user-select:none} + .role-h .rcaret{display:inline-block;width:11px;color:var(--muted);font-weight:400} + .role-members{background:#0c141d;border-top:1px solid #16202b} + .role-members .lst-item{padding-left:26px} .mini{padding:3px 8px;font-size:11.5px;border-radius:7px;cursor:pointer;border:1px solid var(--line);background:#173042;color:var(--fg)} .mini.w{border-color:#1f5236;color:#9ff0bd} .mini.b{border-color:#5c2530;color:#ffb3bb} @@ -1030,6 +1060,7 @@ PAGE = r"""
+
봇: 연결 안 됨 @@ -1101,7 +1132,6 @@ PAGE = r"""
-
대화 로그 검색 ' + '' + +'
' +'
' +'
' +'
화이트리스트 (그 대상만 청취)
' @@ -1726,15 +1762,39 @@ async function openLists(){ const has=(arr,id)=>(arr||[]).some(x=>x.id===id); function add(kind,item){ const k=LKEY[kind]; if(!has(lists[k],item.id)) lists[k].push(item); renderChips(); } function rm(k,id){ lists[k]=(lists[k]||[]).filter(x=>x.id!==id); renderChips(); } + const NO_RES='
결과 없음 · 봇이 아는 멤버/역할만 검색됩니다
'; + function memberRow(m){ + return '
'+esc(m.name)+(m.bot?' (봇)':'')+'' + +'' + +'
'; + } function renderResults(){ const type=$('lstType').value, q=$('lstSearch').value.trim().toLowerCase(); - const src = type==='user' ? (g.members||[]) : (g.roles||[]); - const rows = src.filter(x=>!q || (x.name||'').toLowerCase().includes(q)).slice(0,100); - $('lstResults').innerHTML = rows.length ? rows.map(x=> - '
'+esc(x.name)+(x.bot?' (봇)':'')+'' - +'' - +'
' - ).join('') : '
결과 없음 · 봇이 아는 멤버/역할만 검색됩니다
'; + const members=(g.members||[]); const byId={}; members.forEach(m=>byId[m.id]=m); + const match=(n)=>!q||((n||'').toLowerCase().includes(q)); + let html=''; + if(type==='user' || type==='bot'){ + const rows=members.filter(m=>(type==='bot'?m.bot:!m.bot)&&match(m.name)).slice(0,300); + html = rows.length ? rows.map(memberRow).join('') : NO_RES; + } else if(type==='voice'){ + const vm=((botState&&botState.members)||[]).map(m=>({id:m.id,name:m.name,bot:!!(byId[m.id]&&byId[m.id].bot)})); + const rows=vm.filter(m=>match(m.name)).slice(0,300); + html = rows.length ? rows.map(memberRow).join('') + : '
봇이 통화방에 없거나 참여자가 없습니다
'; + } else { // role: 역할 등록 + 펼치면 역할 아래 유저(봇 포함) 등록 + const roles=(g.roles||[]).filter(r=>match(r.name)).slice(0,300); + html = roles.length ? roles.map(r=>{ + const rid=esc(r.id), rmem=members.filter(m=>(m.roleIds||[]).includes(r.id)); + return '
' + +'
▸ '+esc(r.name)+' ('+rmem.length+'명)' + +'' + +'
' + +'
'; + }).join('') : NO_RES; + } + $('lstResults').innerHTML = html; } const chip=(k,cls,x)=>''+esc(x.name)+' '; const empty='비어있음'; @@ -1743,7 +1803,13 @@ async function openLists(){ $('chipsB').innerHTML = [...lists.blacklistUsers.map(x=>chip('blacklistUsers','b',x)),...lists.blacklistRoles.map(x=>chip('blacklistRoles','b',x))].join('') || empty; } $('lstType').onchange=renderResults; $('lstSearch').oninput=renderResults; - $('lstResults').onclick=(e)=>{ const b=e.target.closest('.mini'); if(!b)return; add(b.dataset.k,{id:b.dataset.id,name:b.dataset.nm}); }; + $('lstResults').onclick=(e)=>{ + const rh=e.target.closest('.role-h'); + if(rh){ const box=$('lstResults').querySelector('.role-members[data-rid="'+rh.dataset.rid+'"]'); + if(box){ const open=box.style.display==='none'; box.style.display=open?'block':'none'; + const c=rh.querySelector('.rcaret'); if(c) c.textContent=open?'▾':'▸'; } return; } + const b=e.target.closest('.mini'); if(!b)return; add(b.dataset.k,{id:b.dataset.id,name:b.dataset.nm}); + }; $('chipsW').onclick=$('chipsB').onclick=(e)=>{ const b=e.target.closest('button'); if(!b)return; rm(b.dataset.k,b.dataset.id); }; $('lstSave').onclick=async()=>{ try{ await fetch('/api/bot/lists',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({guildId,lists})}); toast('청취 필터 저장됨 · 봇에 곧 반영'); closeModal(); } diff --git a/wsai/state_store.py b/wsai/state_store.py new file mode 100644 index 0000000..b57e5e3 --- /dev/null +++ b/wsai/state_store.py @@ -0,0 +1,61 @@ +"""Tiny JSON state store so dashboard/bot settings survive a restart. + +Persists things the user configures on the dashboard — per-guild listen lists, +bot behaviour toggles, TTS controls, and the chosen STT/LLM models — to a single +JSON file so a service or container restart keeps them. + +Path: ``WSAI_STATE_FILE`` env, else ``~/.config/wsai/state.json``. For a +container deployment, mount that path (or point the env at a mounted volume) to +keep the file across ``docker restart``. + +Pure stdlib, thread-safe, best-effort: a read/write failure never raises into +the caller (the dashboard must keep working even if the disk is unwritable). +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +from typing import Any + +log = logging.getLogger("wsai.state") + +_LOCK = threading.Lock() + + +def path() -> str: + return os.environ.get("WSAI_STATE_FILE") or os.path.expanduser("~/.config/wsai/state.json") + + +def load() -> dict[str, Any]: + try: + with open(path(), encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except FileNotFoundError: + return {} + except Exception as exc: # noqa: BLE001 — corrupt/unreadable state must not crash startup + log.warning("state load failed (%s): %s", path(), exc) + return {} + + +def save(state: dict[str, Any]) -> None: + p = path() + try: + with _LOCK: + os.makedirs(os.path.dirname(p), exist_ok=True) + tmp = p + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(state, f, ensure_ascii=False, indent=2) + os.replace(tmp, p) # atomic + except Exception as exc: # noqa: BLE001 + log.warning("state save failed (%s): %s", p, exc) + + +def patch(key: str, value: Any) -> None: + """Read-modify-write one top-level key.""" + s = load() + s[key] = value + save(s)