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)

+ +
@@ -1213,7 +1318,46 @@ function wireCollapse(toggleId, bodyId, caretId){ localStorage.setItem('wsai_hideNoise', hideNoise ? '1':'0'); applyTurnFilter(); }; + // 모델 설정: STT 크기 / LLM 모델 실시간 전환. + wireCollapse('modelToggle','modelBody','modelCaret'); + initModels(); })(); +const STT_LABEL = {tiny:'tiny (가장 빠름)', base:'base', small:'small (기본)', + medium:'medium (정확도↑)', 'large-v3':'large-v3 (최고 정확도)'}; +const LLM_LABEL = {'claude-haiku-4-5':'Haiku 4.5 (가장 빠름)', + 'claude-sonnet-4-5':'Sonnet 4.5 (고품질·조금 느림)'}; +function fillSel(sel, options, current, labels){ + sel.innerHTML=''; + options.forEach(o=>{ const el=document.createElement('option'); + el.value=o; el.textContent=(labels[o]||o); sel.appendChild(el); }); + if(current) sel.value=current; +} +async function initModels(){ + let j; try{ j=await (await fetch('/api/models')).json(); }catch(e){ return; } + if(!j||!j.ok) return; + if(j.stt.enabled){ fillSel($('mSTT'), j.stt.options, j.stt.current, STT_LABEL); + $('mSTTstat').textContent='현재: '+(j.stt.current||'?')+(j.stt.device?(' · '+j.stt.device):''); } + else { $('mSTT').disabled=$('mSTTapply').disabled=true; $('mSTTstat').textContent='STT 비활성'; } + if(j.llm.enabled){ fillSel($('mLLM'), j.llm.options, j.llm.current, LLM_LABEL); + $('mLLMstat').textContent='현재: '+(LLM_LABEL[j.llm.current]||j.llm.current||'?'); } + else { $('mLLM').disabled=$('mLLMapply').disabled=true; $('mLLMstat').textContent='LLM 비활성(echo 모드)'; } + $('mSTTapply').onclick = async ()=>{ + const m=$('mSTT').value; $('mSTTstat').textContent='전환 중… (첫 다운로드면 수 분 걸릴 수 있어요)'; + try{ const r=await fetch('/api/models/stt',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({model:m})}); const jj=await r.json(); + if(jj.ok){ toast('STT 모델 전환: '+m); $('mSTTstat').textContent='전환됨: '+m+' · 로딩은 백그라운드로 진행됩니다'; } + else { $('mSTTstat').textContent='실패: '+(jj.error||''); } + }catch(e){ $('mSTTstat').textContent='오류: '+e; } + }; + $('mLLMapply').onclick = async ()=>{ + const m=$('mLLM').value; + try{ const r=await fetch('/api/models/llm',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({model:m})}); const jj=await r.json(); + if(jj.ok){ toast('LLM 모델 변경: '+(LLM_LABEL[m]||m)); $('mLLMstat').textContent='현재: '+(LLM_LABEL[m]||m)+' · 다음 답변부터'; } + else { $('mLLMstat').textContent='실패: '+(jj.error||''); } + }catch(e){ $('mLLMstat').textContent='오류: '+e; } + }; +} // --- 봇 목소리(TTS) 조절: 감정별 슬라이더 → 미리듣기 → 봇 적용 -------------- # let ttsInited = false;