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:
@@ -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"""<!DOCTYPE html>
|
||||
<span>잡음 로그 표시 안 함 <b class="cfghint">(들음: (빈 결과) 또는 답변: [잡음]) · 기본: 켜짐</b></span></label>
|
||||
</div>
|
||||
</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="comp" id="comp"></div>
|
||||
<div class="tfilter" id="tfilter">
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user