feat(dashboard): default STT=medium, apply feedback+loading, log categories+filters
- Default STT model changed small -> medium (WhisperSTT default + service env; medium pre-cached). UI labels/hints updated. - Model 적용 buttons: if the picked model == current, flash "변경사항 없음" for 3s then show the current model again; if it actually changes, STT shows a live loading panel that polls /api/models until the worker is ready and logs completion to the event log (set_stt_model/set_llm_model now return `changed`). - Event/error log gains categories (READY, CONNECT, MODEL, TTS, VOICE, FILTER, SETTING, TURN, BRAIN, VISION, PIPELINE, …): Monitor.log takes an optional `cat`, call sites tagged, and each line shows a coloured category chip. A bot (re)connection now logs a CONNECT event. - Log search extended: filter by 레벨(type), 종류(category, auto-populated), and 시간(time range) in addition to free text. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -82,11 +82,11 @@ def _run_stt_test(host: str, port: int) -> None:
|
||||
monitor.set_components({"source": "none", "vision": "none", "stt": "whisper",
|
||||
"brain": "none", "tts": "none"})
|
||||
monitor.set_status(running=True, listening=False)
|
||||
monitor.log("info", "STT 인식 테스트 서버 시작 — GPU 워밍업 중…")
|
||||
monitor.log("info", "STT 인식 테스트 서버 시작 — GPU 워밍업 중…", cat="READY")
|
||||
print("\n STT 워밍업 중… (모델 로드 + CUDA 예열)")
|
||||
dash.warm() # load + warm the GPU worker so the first recognition is instant
|
||||
dev = getattr(stt, "resolved_device", None) or "?"
|
||||
monitor.log("info", f"STT 준비 완료 (device={dev}). 녹음/파일 업로드로 인식하세요.")
|
||||
monitor.log("info", f"STT 준비 완료 (device={dev}). 녹음/파일 업로드로 인식하세요.", cat="READY")
|
||||
|
||||
shown = host if host not in ("0.0.0.0", "") else _lan_ip()
|
||||
print(f"\n 음성 인식 테스트 사이트: http://{shown}:{port} (STT device: {dev})")
|
||||
@@ -134,12 +134,12 @@ def _run_voice_server(host: str, port: int) -> None:
|
||||
monitor.set_components({"source": "none", "vision": "none", "stt": "whisper",
|
||||
"brain": brain_name, "tts": "melo"})
|
||||
monitor.set_status(running=True, listening=False)
|
||||
monitor.log("info", "디스코드 음성 서버 시작 — STT+TTS GPU 워밍업 중…")
|
||||
monitor.log("info", "디스코드 음성 서버 시작 — STT+TTS GPU 워밍업 중…", cat="READY")
|
||||
print("\n STT+TTS 워밍업 중… (모델 로드 + CUDA 예열)")
|
||||
dash.warm()
|
||||
sdev = getattr(stt, "resolved_device", None) or "?"
|
||||
monitor.set_status(listening=True)
|
||||
monitor.log("info", f"음성 서버 준비 완료 (STT device={sdev}). 디스코드 봇 연결 대기.")
|
||||
monitor.log("info", f"음성 서버 준비 완료 (STT device={sdev}). 디스코드 봇 연결 대기.", cat="READY")
|
||||
|
||||
shown = host if host not in ("0.0.0.0", "") else _lan_ip()
|
||||
print(f"\n 음성 서버 준비 완료 (STT device: {sdev}, 두뇌: {brain_name})")
|
||||
|
||||
@@ -18,7 +18,7 @@ stays usable directly.
|
||||
Env:
|
||||
WSAI_WHISPER_PYTHON interpreter with faster-whisper installed
|
||||
(default: /home/claude/jarvis-stt/whisper312/bin/python)
|
||||
WSAI_WHISPER_MODEL model size/name (default: small)
|
||||
WSAI_WHISPER_MODEL model size/name (default: medium)
|
||||
WSAI_WHISPER_DEVICE cpu | cuda | auto (default auto: GPU if present,
|
||||
else CPU; the worker falls back to CPU if CUDA fails)
|
||||
WSAI_WHISPER_LANGUAGE forced language, e.g. ko (default ko; "" = autodetect)
|
||||
@@ -67,7 +67,7 @@ class WhisperSTT:
|
||||
audio_source: AsyncIterator[str] | None = None,
|
||||
) -> None:
|
||||
self.python = python or os.environ.get("WSAI_WHISPER_PYTHON", _DEFAULT_PYTHON)
|
||||
self.model = model or os.environ.get("WSAI_WHISPER_MODEL", "small")
|
||||
self.model = model or os.environ.get("WSAI_WHISPER_MODEL", "medium")
|
||||
self.device = device or os.environ.get("WSAI_WHISPER_DEVICE", "auto")
|
||||
# "" means autodetect; a real code like "ko" forces the language.
|
||||
env_lang = os.environ.get("WSAI_WHISPER_LANGUAGE", "ko")
|
||||
|
||||
@@ -232,7 +232,7 @@ def _make_handler(dash: "Dashboard"):
|
||||
return
|
||||
prompt_store.set_persona(prompt)
|
||||
monitor.log("info", "봇 프롬프트가 수정되었습니다" if prompt.strip()
|
||||
else "봇 프롬프트가 기본값으로 초기화되었습니다")
|
||||
else "봇 프롬프트가 기본값으로 초기화되었습니다", cat="PROMPT")
|
||||
body = json.dumps({
|
||||
"ok": True,
|
||||
"prompt": prompt_store.get_persona(self._default_persona()),
|
||||
@@ -253,7 +253,11 @@ def _make_handler(dash: "Dashboard"):
|
||||
except (ValueError, AttributeError):
|
||||
self._send_json({"ok": False, "error": "invalid JSON"}, 400)
|
||||
return
|
||||
was_connected = dash.bot.state().get("connected")
|
||||
dash.bot.report(data)
|
||||
if not was_connected: # transition disconnected -> connected
|
||||
who = data.get("botTag") or data.get("username") or data.get("identity") or ""
|
||||
monitor.log("info", f"디스코드 봇 연결됨{(' · ' + who) if who else ''}", cat="CONNECT")
|
||||
# Hand the bot any queued commands + the current listen filters +
|
||||
# behaviour settings in the same round trip so it does not have to
|
||||
# poll extra endpoints.
|
||||
@@ -273,10 +277,10 @@ def _make_handler(dash: "Dashboard"):
|
||||
channel_id = (data.get("channelId") or "").strip()
|
||||
if channel_id and guild_id:
|
||||
cid = dash.bot.enqueue({"type": "join", "guildId": guild_id, "channelId": channel_id})
|
||||
monitor.log("info", f"음성채널 참여 요청 (guild={guild_id} channel={channel_id})")
|
||||
monitor.log("info", f"음성채널 참여 요청 (guild={guild_id} channel={channel_id})", cat="VOICE")
|
||||
else:
|
||||
cid = dash.bot.enqueue({"type": "leave"})
|
||||
monitor.log("info", "음성채널 나가기 요청")
|
||||
monitor.log("info", "음성채널 나가기 요청", cat="VOICE")
|
||||
self._send_json({"ok": True, "commandId": cid})
|
||||
|
||||
def _handle_bot_lists(self) -> None:
|
||||
@@ -291,7 +295,7 @@ def _make_handler(dash: "Dashboard"):
|
||||
self._send_json({"ok": False, "error": str(exc)}, 400)
|
||||
return
|
||||
saved = dash.bot.set_lists(guild_id, data.get("lists") or {})
|
||||
monitor.log("info", f"청취 화이트/블랙리스트 업데이트 (guild={guild_id})")
|
||||
monitor.log("info", f"청취 화이트/블랙리스트 업데이트 (guild={guild_id})", cat="FILTER")
|
||||
self._send_json({"ok": True, "guildId": guild_id, "lists": saved})
|
||||
|
||||
def _handle_model_switch(self, which: str) -> None:
|
||||
@@ -323,7 +327,7 @@ def _make_handler(dash: "Dashboard"):
|
||||
self._send_json({"ok": False, "error": "invalid JSON"}, 400)
|
||||
return
|
||||
settings = dash.bot.set_settings(data)
|
||||
monitor.log("info", "봇 설정 변경: " + json.dumps(settings, ensure_ascii=False))
|
||||
monitor.log("info", "봇 설정 변경: " + json.dumps(settings, ensure_ascii=False), cat="SETTING")
|
||||
self._send_json({"ok": True, "settings": settings})
|
||||
|
||||
def _handle_tts_settings_get(self) -> None:
|
||||
@@ -349,7 +353,8 @@ def _make_handler(dash: "Dashboard"):
|
||||
tgt = data.get("emotion") or "base"
|
||||
monitor.log("info", f"봇 목소리 파라미터 적용 ({tgt}): base="
|
||||
+ json.dumps(res["base"], ensure_ascii=False)
|
||||
+ " overrides=" + json.dumps(res["overrides"], ensure_ascii=False))
|
||||
+ " overrides=" + json.dumps(res["overrides"], ensure_ascii=False),
|
||||
cat="TTS")
|
||||
self._send_json(res)
|
||||
|
||||
def _handle_tts_preview(self) -> None:
|
||||
@@ -609,20 +614,22 @@ class Dashboard:
|
||||
raise ValueError("model required")
|
||||
if model not in self.STT_OPTIONS:
|
||||
raise ValueError(f"unknown STT model: {model}")
|
||||
if model != self.stt.model:
|
||||
changed = model != self.stt.model
|
||||
if changed:
|
||||
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"]
|
||||
self.monitor.log("info", f"STT 모델 전환 시작: {model} (로딩 중…)", cat="MODEL")
|
||||
return {**self.models_settings()["stt"], "changed": changed}
|
||||
|
||||
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', '?')})")
|
||||
f"(device={getattr(self.stt, 'resolved_device', '?')})", cat="READY")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.monitor.log("error", f"STT 모델 로드 실패({self.stt.model}): {exc}")
|
||||
self.monitor.log("error", f"STT 모델 로드 실패({self.stt.model}): {exc}", cat="MODEL")
|
||||
|
||||
def set_llm_model(self, model: str) -> dict:
|
||||
"""Switch the Claude model live — applied on the next reply (no reload)."""
|
||||
@@ -631,9 +638,11 @@ class Dashboard:
|
||||
model = str(model).strip()
|
||||
if model not in self.LLM_OPTIONS:
|
||||
raise ValueError(f"unknown LLM model: {model}")
|
||||
changed = model != self.brain.model
|
||||
self.brain.model = model
|
||||
self.monitor.log("info", f"LLM 모델 변경: {model} (다음 답변부터 적용)")
|
||||
return self.models_settings()["llm"]
|
||||
if changed:
|
||||
self.monitor.log("info", f"LLM 모델 변경: {model} (다음 답변부터 적용)", cat="MODEL")
|
||||
return {**self.models_settings()["llm"], "changed": changed}
|
||||
|
||||
def voice_turn(self, audio_bytes: bytes, speaker: str = "",
|
||||
guild: str = "", channel: str = "") -> dict:
|
||||
@@ -718,7 +727,7 @@ class Dashboard:
|
||||
self.monitor.add_claude_usage(u.get("input", 0), u.get("output", 0))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("brain failed")
|
||||
self.monitor.log("error", f"두뇌 응답 실패: {exc}")
|
||||
self.monitor.log("error", f"두뇌 응답 실패: {exc}", cat="BRAIN")
|
||||
blob = f"{getattr(exc, 'status_code', '')} {exc}".lower()
|
||||
if "529" in blob or "overload" in blob:
|
||||
# Transient server overload survived the SDK retries.
|
||||
@@ -858,6 +867,12 @@ PAGE = r"""<!DOCTYPE html>
|
||||
.cfgrow{display:flex;gap:9px;align-items:center;font-size:13px;color:var(--fg);cursor:pointer;padding:4px 0}
|
||||
.cfgrow input[type=checkbox]{width:16px;height:16px;accent-color:#3aa0ff;cursor:pointer}
|
||||
.cfghint{color:var(--muted);font-weight:400;font-size:12px}
|
||||
.mload{display:flex;align-items:center;gap:9px;margin-top:10px;padding:8px 12px;
|
||||
background:#0f2333;border:1px solid #234a63;border-radius:10px;font-size:13px;color:var(--fg)}
|
||||
.mload .bar{flex:1;height:6px;border-radius:4px;background:#12304a;overflow:hidden;position:relative}
|
||||
.spin{width:15px;height:15px;border:2px solid #2a4a63;border-top-color:#3aa0ff;border-radius:50%;
|
||||
display:inline-block;animation:spin 0.8s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.sttrow{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
||||
.btn{background:#173042;border:1px solid #234a63;color:var(--fg);border-radius:10px;padding:8px 14px;font-size:13px;cursor:pointer}
|
||||
.btn:hover{background:#1d3d54}
|
||||
@@ -962,6 +977,19 @@ PAGE = r"""<!DOCTYPE html>
|
||||
.logline.info .lv{color:var(--accent)}
|
||||
.logline.error .lv{color:var(--err)}
|
||||
.logline.warn .lv{color:var(--warn)}
|
||||
.lc-tag{flex:0 0 auto;min-width:62px;text-align:center;font-size:10px;font-weight:700;
|
||||
letter-spacing:.3px;padding:1px 7px;border-radius:9px;background:#1b2b3a;color:#8fb3d6;
|
||||
border:1px solid #24405a}
|
||||
.lc-READY{background:#12331f;color:#7fe0a0;border-color:#1f5a34}
|
||||
.lc-CONNECT{background:#12283f;color:#7fb8ff;border-color:#22507f}
|
||||
.lc-MODEL{background:#2a2340;color:#c3a6ff;border-color:#463a7a}
|
||||
.lc-TTS{background:#0f2f33;color:#6fe0d8;border-color:#1f5a5a}
|
||||
.lc-VOICE{background:#33280f;color:#e0c06f;border-color:#5a481f}
|
||||
.lc-FILTER{background:#301a2a;color:#e79fd0;border-color:#5a2545}
|
||||
.lc-SETTING{background:#22303a;color:#9fc7e0;border-color:#2f5568}
|
||||
.lc-TURN,.lc-ERROR{background:#3a1520;color:#ffb3bb;border-color:#7a2531}
|
||||
.lc-BRAIN,.lc-VISION{background:#2a2340;color:#c3a6ff;border-color:#463a7a}
|
||||
.lc-WARN{background:#332a10;color:#ffd98a;border-color:#5a4a1f}
|
||||
.logline .lm{flex:1;color:var(--fg);white-space:pre-wrap;word-break:break-word}
|
||||
.logline.error .lm{color:#ffb3bb}
|
||||
.logline .lacts{opacity:0;display:flex;gap:4px}
|
||||
@@ -1052,7 +1080,10 @@ PAGE = r"""<!DOCTYPE html>
|
||||
<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 id="mLoad" class="mload" style="display:none">
|
||||
<span class="spin"></span><span id="mLoadText">로딩 중…</span>
|
||||
</div>
|
||||
<p class="cfghint" style="margin:8px 0 0">STT는 전환 시 모델을 다시 로드합니다(처음 medium/large-v3는 다운로드로 수 분 걸릴 수 있어요). LLM은 다음 답변부터 즉시 적용됩니다. 서비스 재시작 시 기본값(medium · Haiku)으로 돌아갑니다.</p>
|
||||
</div>
|
||||
</section>
|
||||
<div class="demobar" id="demobar" style="display:none"></div>
|
||||
@@ -1081,11 +1112,19 @@ PAGE = r"""<!DOCTYPE html>
|
||||
<div class="logbar">
|
||||
<button id="logToggle" class="logtoggle">▾ 이벤트 / 오류 로그</button>
|
||||
<input id="logSearch" class="logsearch" placeholder="로그 검색 (텍스트)">
|
||||
<select id="logLevel" class="logsel">
|
||||
<option value="">전체</option>
|
||||
<option value="error">오류만</option>
|
||||
<option value="warn">경고만</option>
|
||||
<option value="info">정보만</option>
|
||||
<select id="logLevel" class="logsel" title="레벨(타입)">
|
||||
<option value="">레벨 전체</option>
|
||||
<option value="error">오류(ERROR)</option>
|
||||
<option value="warn">경고(WARN)</option>
|
||||
<option value="info">정보(INFO)</option>
|
||||
</select>
|
||||
<select id="logCat" class="logsel" title="이벤트 종류"><option value="">종류 전체</option></select>
|
||||
<select id="logTime" class="logsel" title="시간">
|
||||
<option value="0">전체 시간</option>
|
||||
<option value="5">최근 5분</option>
|
||||
<option value="30">최근 30분</option>
|
||||
<option value="60">최근 1시간</option>
|
||||
<option value="180">최근 3시간</option>
|
||||
</select>
|
||||
<span id="logCount" class="logcount"></span>
|
||||
<button id="logClear" class="btn logbtn">로그 삭제</button>
|
||||
@@ -1243,20 +1282,34 @@ function applyTurnFilter(){
|
||||
|
||||
// --- Bottom terminal log panel: store all events, render filtered ---------- #
|
||||
let logEvents = []; // {id, level, message, wall}
|
||||
function logCat(e){ return (e.cat || (e.level||'info').toUpperCase()); }
|
||||
function logMatches(e){
|
||||
const lv = $('logLevel').value;
|
||||
if(lv && e.level!==lv) return false;
|
||||
const c = $('logCat').value;
|
||||
if(c && logCat(e)!==c) return false;
|
||||
const mins = +$('logTime').value;
|
||||
if(mins && (Date.now()/1000 - (e.wall||0)) > mins*60) return false;
|
||||
const q = $('logSearch').value.trim().toLowerCase();
|
||||
if(q && !((e.message||'').toLowerCase().includes(q) || fmtTime(e.wall).includes(q))) return false;
|
||||
if(q && !((e.message||'').toLowerCase().includes(q) || logCat(e).toLowerCase().includes(q) || fmtTime(e.wall).includes(q))) return false;
|
||||
return true;
|
||||
}
|
||||
// Keep the 종류(카테고리) dropdown in sync with whatever categories appear.
|
||||
function refreshCatOptions(){
|
||||
const sel=$('logCat'); const cur=sel.value;
|
||||
const cats=[...new Set(logEvents.map(logCat))].sort();
|
||||
sel.innerHTML='<option value="">종류 전체</option>'
|
||||
+ cats.map(c=>'<option value="'+c+'">'+esc(c)+'</option>').join('');
|
||||
sel.value = cats.includes(cur) ? cur : '';
|
||||
}
|
||||
function renderLogs(){
|
||||
const body = $('logbody');
|
||||
refreshCatOptions();
|
||||
const shown = logEvents.filter(logMatches);
|
||||
body.innerHTML = shown.map(e =>
|
||||
'<div class="logline '+(e.level||'info')+'" data-id="'+e.id+'">'
|
||||
+ '<span class="lt">'+fmtTime(e.wall)+'</span>'
|
||||
+ '<span class="lv">'+esc(e.level||'info')+'</span>'
|
||||
+ '<span class="lc-tag lc-'+esc(logCat(e))+'">'+esc(logCat(e))+'</span>'
|
||||
+ '<span class="lm">'+esc(e.message)+'</span>'
|
||||
+ '<span class="lacts"><button class="lact" data-act="edit" title="수정">✎</button>'
|
||||
+ '<button class="lact" data-act="del" title="삭제">✕</button></span>'
|
||||
@@ -1329,8 +1382,8 @@ function wireCollapse(toggleId, bodyId, caretId){
|
||||
wireCollapse('modelToggle','modelBody','modelCaret');
|
||||
initModels();
|
||||
})();
|
||||
const STT_LABEL = {tiny:'tiny (가장 빠름)', base:'base', small:'small (기본)',
|
||||
medium:'medium (정확도↑)', 'large-v3':'large-v3 (최고 정확도)'};
|
||||
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){
|
||||
@@ -1349,22 +1402,54 @@ async function initModels(){
|
||||
$('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='전환 중… (첫 다운로드면 수 분 걸릴 수 있어요)';
|
||||
const m=$('mSTT').value;
|
||||
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||''); }
|
||||
if(!jj.ok){ $('mSTTstat').textContent='실패: '+(jj.error||''); return; }
|
||||
if(!jj.stt.changed){ flash3($('mSTTstat'), '변경사항 없음', '현재: '+(jj.stt.current||m)+(jj.stt.device?(' · '+jj.stt.device):'')); return; }
|
||||
pollSTTLoad(m); // 실제 변경 → 아래 로딩창 + 완료 시 이벤트 로그
|
||||
}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||''); }
|
||||
if(!jj.ok){ $('mLLMstat').textContent='실패: '+(jj.error||''); return; }
|
||||
if(!jj.llm.changed){ flash3($('mLLMstat'), '변경사항 없음', '현재: '+(LLM_LABEL[jj.llm.current]||jj.llm.current)); return; }
|
||||
toast('LLM 모델 변경: '+(LLM_LABEL[m]||m));
|
||||
$('mLLMstat').textContent='현재: '+(LLM_LABEL[m]||m)+' · 다음 답변부터';
|
||||
}catch(e){ $('mLLMstat').textContent='오류: '+e; }
|
||||
};
|
||||
}
|
||||
// 3초 동안 임시 메시지를 보여준 뒤 원래(현재 모델) 텍스트로 복귀.
|
||||
function flash3(el, temp, revert){
|
||||
el.textContent = temp;
|
||||
clearTimeout(el._t);
|
||||
el._t = setTimeout(()=>{ el.textContent = revert; }, 3000);
|
||||
}
|
||||
// STT 실제 전환: 아래 로딩창을 띄우고 /api/models를 폴링해 ready가 될 때까지 표시.
|
||||
let sttPollTimer=null;
|
||||
async function pollSTTLoad(target){
|
||||
const load=$('mLoad'), txt=$('mLoadText');
|
||||
load.style.display='flex';
|
||||
$('mSTTstat').textContent='전환 중…';
|
||||
const t0=Date.now();
|
||||
if(sttPollTimer) clearInterval(sttPollTimer);
|
||||
const tick=async ()=>{
|
||||
const sec=Math.round((Date.now()-t0)/1000);
|
||||
txt.textContent = 'STT '+target+' 로딩 중… (경과 '+sec+'초, 처음이면 다운로드로 수 분 걸릴 수 있어요)';
|
||||
try{
|
||||
const j=await (await fetch('/api/models')).json();
|
||||
if(j&&j.ok&&j.stt.current===target&&j.stt.ready){
|
||||
clearInterval(sttPollTimer); sttPollTimer=null;
|
||||
load.style.display='none';
|
||||
$('mSTTstat').textContent='현재: '+target+(j.stt.device?(' · '+j.stt.device):'')+' · 로드 완료';
|
||||
toast('STT 모델 로드 완료: '+target);
|
||||
}
|
||||
}catch(e){}
|
||||
};
|
||||
tick(); sttPollTimer=setInterval(tick, 1500);
|
||||
}
|
||||
|
||||
// --- 봇 목소리(TTS) 조절: 감정별 슬라이더 → 미리듣기 → 봇 적용 -------------- #
|
||||
let ttsInited = false;
|
||||
@@ -1538,6 +1623,8 @@ window.addEventListener('resize', syncDockPad);
|
||||
})();
|
||||
$('logSearch').oninput = renderLogs;
|
||||
$('logLevel').onchange = renderLogs;
|
||||
$('logCat').onchange = renderLogs;
|
||||
$('logTime').onchange = renderLogs;
|
||||
$('logClear').onclick = async ()=>{
|
||||
if(!confirm('로그를 모두 삭제할까요?')) return;
|
||||
try{ await fetch('/api/logs/clear',{method:'POST'}); toast('로그를 삭제했습니다'); }
|
||||
|
||||
@@ -129,7 +129,7 @@ class Turn:
|
||||
# Guarded so the repeated _touch()/finish() calls can't double-count.
|
||||
if self.status == "error" and not self._error_logged:
|
||||
self._error_logged = True
|
||||
self._monitor.log("error", f"대화 #{self.id} 실패: {self.error}")
|
||||
self._monitor.log("error", f"대화 #{self.id} 실패: {self.error}", cat="TURN")
|
||||
self._touch()
|
||||
|
||||
# -- internal --------------------------------------------------------- #
|
||||
@@ -209,12 +209,17 @@ class Monitor:
|
||||
self._status["claude_output_tokens"] += int(output_tokens or 0)
|
||||
self._broadcast({"type": "status", "status": self.status_snapshot()})
|
||||
|
||||
def log(self, level: str, message: str) -> None:
|
||||
"""A free-form lifecycle/error line (startup, disconnect, crash…)."""
|
||||
def log(self, level: str, message: str, cat: str | None = None) -> None:
|
||||
"""A free-form lifecycle/error line (startup, disconnect, crash…).
|
||||
|
||||
``cat`` is a short category tag (READY, CONNECT, MODEL, TTS, VOICE,
|
||||
FILTER, SETTING, TURN, BRAIN, PIPELINE, …) shown as a chip and filterable
|
||||
on the dashboard. Defaults to the upper-cased level when omitted."""
|
||||
with self._lock:
|
||||
self._event_id += 1
|
||||
evt = {"type": "log", "id": self._event_id, "level": level,
|
||||
"message": message, "wall": _now_wall()}
|
||||
"cat": (cat or level.upper()), "message": message,
|
||||
"wall": _now_wall()}
|
||||
self._events.append(evt)
|
||||
if level == "error":
|
||||
self._status["errors_total"] += 1
|
||||
|
||||
@@ -65,7 +65,7 @@ class Pipeline:
|
||||
except Exception as exc: # a single bad frame must not kill the loop
|
||||
log.exception("vision.describe failed")
|
||||
if self.monitor is not None:
|
||||
self.monitor.log("error", f"화면 이해 실패: {exc}")
|
||||
self.monitor.log("error", f"화면 이해 실패: {exc}", cat="VISION")
|
||||
continue
|
||||
await self.context.update(obs)
|
||||
log.debug("screen: %s", obs.text[:120])
|
||||
@@ -122,7 +122,7 @@ class Pipeline:
|
||||
return
|
||||
if self.monitor is not None:
|
||||
self.monitor.set_status(listening=True)
|
||||
self.monitor.log("info", "음성 수신 시작 — 발화 대기 중")
|
||||
self.monitor.log("info", "음성 수신 시작 — 발화 대기 중", cat="PIPELINE")
|
||||
try:
|
||||
async for utt in self.stt.utterances():
|
||||
await self._handle(utt)
|
||||
@@ -157,7 +157,7 @@ class Pipeline:
|
||||
except Exception as exc: # a warm failure must not abort startup
|
||||
log.warning("prewarm %s failed: %s", name, exc)
|
||||
if self.monitor is not None:
|
||||
self.monitor.log("error", f"{name} 예열 실패: {exc}")
|
||||
self.monitor.log("error", f"{name} 예열 실패: {exc}", cat="READY")
|
||||
|
||||
async def run(self) -> None:
|
||||
# A TaskGroup (not bare gather) so that if ONE loop raises, the others
|
||||
@@ -167,7 +167,7 @@ class Pipeline:
|
||||
# still-live loop (close-during-use).
|
||||
if self.monitor is not None:
|
||||
self.monitor.set_status(running=True)
|
||||
self.monitor.log("info", "파이프라인 시작")
|
||||
self.monitor.log("info", "파이프라인 시작", cat="PIPELINE")
|
||||
await self._prewarm()
|
||||
try:
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
@@ -177,12 +177,12 @@ class Pipeline:
|
||||
except* Exception as eg:
|
||||
if self.monitor is not None:
|
||||
for exc in eg.exceptions:
|
||||
self.monitor.log("error", f"루프 예외: {type(exc).__name__}: {exc}")
|
||||
self.monitor.log("error", f"루프 예외: {type(exc).__name__}: {exc}", cat="PIPELINE")
|
||||
raise
|
||||
finally:
|
||||
if self.monitor is not None:
|
||||
self.monitor.set_status(running=False, listening=False)
|
||||
self.monitor.log("info", "파이프라인 종료")
|
||||
self.monitor.log("info", "파이프라인 종료", cat="PIPELINE")
|
||||
await self.aclose()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user