diff --git a/wsai/dashboard.py b/wsai/dashboard.py
index e42a1b3..669cffe 100644
--- a/wsai/dashboard.py
+++ b/wsai/dashboard.py
@@ -87,6 +87,8 @@ def _make_handler(dash: "Dashboard"):
self._handle_tts_settings_get()
elif path == "/api/bot/settings":
self._send_json({"ok": True, "settings": dash.bot.get_settings()})
+ elif path == "/api/models":
+ self._send_json(dash.models_settings())
elif path == "/events":
self._stream_events()
else:
@@ -119,6 +121,10 @@ def _make_handler(dash: "Dashboard"):
self._handle_tts_preview()
elif path == "/api/bot/settings":
self._handle_bot_settings_post()
+ elif path == "/api/models/stt":
+ self._handle_model_switch("stt")
+ elif path == "/api/models/llm":
+ self._handle_model_switch("llm")
else:
self._send(404, b"not found", "text/plain; charset=utf-8")
@@ -288,6 +294,25 @@ def _make_handler(dash: "Dashboard"):
monitor.log("info", f"청취 화이트/블랙리스트 업데이트 (guild={guild_id})")
self._send_json({"ok": True, "guildId": guild_id, "lists": saved})
+ def _handle_model_switch(self, which: str) -> None:
+ """Switch the STT size or the LLM model live."""
+ raw = self._read_body()
+ try:
+ data = json.loads(raw.decode("utf-8")) if raw else {}
+ model = data.get("model")
+ except (ValueError, AttributeError):
+ self._send_json({"ok": False, "error": "invalid JSON"}, 400)
+ return
+ try:
+ if which == "stt":
+ res = dash.set_stt_model(model)
+ else:
+ res = dash.set_llm_model(model)
+ except Exception as exc: # noqa: BLE001 — surface the reason to the page
+ self._send_json({"ok": False, "error": f"{type(exc).__name__}: {exc}"}, 400)
+ return
+ self._send_json({"ok": True, which: res})
+
def _handle_bot_settings_post(self) -> None:
"""Save a bot behaviour toggle (e.g. bargeIn). The bot reads the new
value on its next report round-trip."""
@@ -548,6 +573,68 @@ class Dashboard:
pass
return wav
+ # -- live model switching (STT size / LLM model) --------------------- #
+ STT_OPTIONS = ["tiny", "base", "small", "medium", "large-v3"]
+ LLM_OPTIONS = ["claude-haiku-4-5", "claude-sonnet-4-5"]
+
+ def models_settings(self) -> dict:
+ stt = self.stt
+ brain = self.brain
+ return {
+ "ok": True,
+ "stt": {
+ "enabled": stt is not None,
+ "current": getattr(stt, "model", None),
+ "ready": bool(getattr(stt, "_ready", False)),
+ "device": getattr(stt, "resolved_device", None),
+ "options": self.STT_OPTIONS,
+ },
+ "llm": {
+ "enabled": brain is not None,
+ "current": getattr(brain, "model", None),
+ "options": self.LLM_OPTIONS,
+ },
+ }
+
+ def set_stt_model(self, model: str) -> dict:
+ """Switch the whisper model size live. Tears down the current worker and
+ warms the new one in the BACKGROUND so the HTTP call returns fast (the
+ first switch to a not-yet-downloaded size fetches it, which can take a
+ while); the next utterance waits for the reload if it isn't warm yet."""
+ import asyncio
+ if self.stt is None:
+ raise RuntimeError("STT not enabled")
+ model = str(model).strip()
+ if not model:
+ raise ValueError("model required")
+ if model not in self.STT_OPTIONS:
+ raise ValueError(f"unknown STT model: {model}")
+ if model != self.stt.model:
+ self.stt.model = model
+ self._submit(self.stt.aclose()) # drop old worker (fast)
+ # Reload+warm in the background; don't block the HTTP response.
+ asyncio.run_coroutine_threadsafe(self._warm_stt_bg(), self._loop)
+ return self.models_settings()["stt"]
+
+ async def _warm_stt_bg(self) -> None:
+ try:
+ await self.stt.warmup()
+ self.monitor.log("info", f"STT 모델 로드 완료: {self.stt.model} "
+ f"(device={getattr(self.stt, 'resolved_device', '?')})")
+ except Exception as exc: # noqa: BLE001
+ self.monitor.log("error", f"STT 모델 로드 실패({self.stt.model}): {exc}")
+
+ def set_llm_model(self, model: str) -> dict:
+ """Switch the Claude model live — applied on the next reply (no reload)."""
+ if self.brain is None:
+ raise RuntimeError("LLM(brain) not enabled — echo 모드입니다")
+ model = str(model).strip()
+ if model not in self.LLM_OPTIONS:
+ raise ValueError(f"unknown LLM model: {model}")
+ self.brain.model = model
+ self.monitor.log("info", f"LLM 모델 변경: {model} (다음 답변부터 적용)")
+ return self.models_settings()["llm"]
+
def voice_turn(self, audio_bytes: bytes, speaker: str = "",
guild: str = "", channel: str = "") -> dict:
"""One Discord voice turn: decode the uploaded utterance, recognise it
@@ -944,6 +1031,24 @@ PAGE = r"""
잡음 로그 표시 안 함 (들음: (빈 결과) 또는 답변: [잡음]) · 기본: 켜짐
+ ▸ 🧠 모델 (STT · LLM)
+
+