From 6d8ba3ad3ab8566020fbb04af3ea260b395be7d0 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Wed, 26 Aug 2026 23:32:55 +0900 Subject: [PATCH] feat(dashboard): live STT/LLM model switching (small<->medium, Haiku<->Sonnet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "๐Ÿง  ๋ชจ๋ธ (STT ยท LLM)" collapsible panel with two dropdowns: - STT(๊ท€): tiny/base/small/medium/large-v3. Switching swaps WhisperSTT.model, tears the worker down and re-warms it in the BACKGROUND (first switch to a not-yet-downloaded size fetches it, so the HTTP call returns immediately and the next utterance waits for the reload). - LLM(๋‘๋‡Œ): Haiku 4.5 / Sonnet 4.5. Applied on the next reply (no reload). Backend: GET /api/models, POST /api/models/stt, POST /api/models/llm; Dashboard gains models_settings/set_stt_model/set_llm_model. In-memory only (a service restart reverts to the env defaults small / claude-haiku-4-5). Co-Authored-By: Claude Opus 4.7 --- wsai/dashboard.py | 144 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) 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;