feat(dashboard): live STT/LLM model switching (small<->medium, Haiku<->Sonnet)

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 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-08-26 23:32:55 +09:00
parent b0272d2171
commit 6d8ba3ad3a

View File

@@ -87,6 +87,8 @@ def _make_handler(dash: "Dashboard"):
self._handle_tts_settings_get() self._handle_tts_settings_get()
elif path == "/api/bot/settings": elif path == "/api/bot/settings":
self._send_json({"ok": True, "settings": dash.bot.get_settings()}) self._send_json({"ok": True, "settings": dash.bot.get_settings()})
elif path == "/api/models":
self._send_json(dash.models_settings())
elif path == "/events": elif path == "/events":
self._stream_events() self._stream_events()
else: else:
@@ -119,6 +121,10 @@ def _make_handler(dash: "Dashboard"):
self._handle_tts_preview() self._handle_tts_preview()
elif path == "/api/bot/settings": elif path == "/api/bot/settings":
self._handle_bot_settings_post() 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: else:
self._send(404, b"not found", "text/plain; charset=utf-8") 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})") monitor.log("info", f"청취 화이트/블랙리스트 업데이트 (guild={guild_id})")
self._send_json({"ok": True, "guildId": guild_id, "lists": saved}) 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: def _handle_bot_settings_post(self) -> None:
"""Save a bot behaviour toggle (e.g. bargeIn). The bot reads the new """Save a bot behaviour toggle (e.g. bargeIn). The bot reads the new
value on its next report round-trip.""" value on its next report round-trip."""
@@ -548,6 +573,68 @@ class Dashboard:
pass pass
return wav 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 = "", def voice_turn(self, audio_bytes: bytes, speaker: str = "",
guild: str = "", channel: str = "") -> dict: guild: str = "", channel: str = "") -> dict:
"""One Discord voice turn: decode the uploaded utterance, recognise it """One Discord voice turn: decode the uploaded utterance, recognise it
@@ -944,6 +1031,24 @@ PAGE = r"""<!DOCTYPE html>
<span>잡음 로그 표시 안 함 <b class="cfghint">(들음: (빈 결과) 또는 답변: [잡음]) · 기본: 켜짐</b></span></label> <span>잡음 로그 표시 안 함 <b class="cfghint">(들음: (빈 결과) 또는 답변: [잡음]) · 기본: 켜짐</b></span></label>
</div> </div>
</section> </section>
<section class="sttbox" id="modelbox">
<h2 id="modelToggle" class="collap"><span id="modelCaret">▸</span> 🧠 모델 (STT · LLM)</h2>
<div id="modelBody" style="display:none">
<div class="sttrow">
<label style="font-size:12.5px;color:var(--muted)">STT(귀)
<select id="mSTT" class="ttssel"></select></label>
<button id="mSTTapply" class="btn">적용</button>
<span id="mSTTstat" class="sttstat"></span>
</div>
<div class="sttrow" style="margin-top:8px">
<label style="font-size:12.5px;color:var(--muted)">LLM(두뇌)
<select id="mLLM" class="ttssel"></select></label>
<button id="mLLMapply" class="btn">적용</button>
<span id="mLLMstat" class="sttstat"></span>
</div>
<p class="cfghint" style="margin:8px 0 0">STT는 전환 시 모델을 다시 로드합니다(처음 medium/large-v3는 다운로드로 수 분 걸릴 수 있어요). LLM은 다음 답변부터 즉시 적용됩니다. 서비스 재시작 시 기본값(small · Haiku)으로 돌아갑니다.</p>
</div>
</section>
<div class="demobar" id="demobar" style="display:none"></div> <div class="demobar" id="demobar" style="display:none"></div>
<div class="comp" id="comp"></div> <div class="comp" id="comp"></div>
<div class="tfilter" id="tfilter"> <div class="tfilter" id="tfilter">
@@ -1213,7 +1318,46 @@ function wireCollapse(toggleId, bodyId, caretId){
localStorage.setItem('wsai_hideNoise', hideNoise ? '1':'0'); localStorage.setItem('wsai_hideNoise', hideNoise ? '1':'0');
applyTurnFilter(); 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) 조절: 감정별 슬라이더 → 미리듣기 → 봇 적용 -------------- # // --- 봇 목소리(TTS) 조절: 감정별 슬라이더 → 미리듣기 → 봇 적용 -------------- #
let ttsInited = false; let ttsInited = false;