diff --git a/wsai/__main__.py b/wsai/__main__.py index 307ae81..8258d3e 100644 --- a/wsai/__main__.py +++ b/wsai/__main__.py @@ -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})") diff --git a/wsai/backends/whisper.py b/wsai/backends/whisper.py index 265fbd4..c267171 100644 --- a/wsai/backends/whisper.py +++ b/wsai/backends/whisper.py @@ -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") diff --git a/wsai/dashboard.py b/wsai/dashboard.py index 5f1c425..2456d57 100644 --- a/wsai/dashboard.py +++ b/wsai/dashboard.py @@ -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""" .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""" .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""" -

STT는 전환 시 모델을 다시 로드합니다(처음 medium/large-v3는 다운로드로 수 분 걸릴 수 있어요). LLM은 다음 답변부터 즉시 적용됩니다. 서비스 재시작 시 기본값(small · Haiku)으로 돌아갑니다.

+ +

STT는 전환 시 모델을 다시 로드합니다(처음 medium/large-v3는 다운로드로 수 분 걸릴 수 있어요). LLM은 다음 답변부터 즉시 적용됩니다. 서비스 재시작 시 기본값(medium · Haiku)으로 돌아갑니다.

@@ -1081,11 +1112,19 @@ PAGE = r"""
- + + + + + + + @@ -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='' + + cats.map(c=>'').join(''); + sel.value = cats.includes(cur) ? cur : ''; +} function renderLogs(){ const body = $('logbody'); + refreshCatOptions(); const shown = logEvents.filter(logMatches); body.innerHTML = shown.map(e => '
' + ''+fmtTime(e.wall)+'' - + ''+esc(e.level||'info')+'' + + ''+esc(logCat(e))+'' + ''+esc(e.message)+'' + '' + '' @@ -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('로그를 삭제했습니다'); } diff --git a/wsai/monitor.py b/wsai/monitor.py index 8eeebdb..eae7ad1 100644 --- a/wsai/monitor.py +++ b/wsai/monitor.py @@ -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 diff --git a/wsai/pipeline.py b/wsai/pipeline.py index 0c40cd3..da0b105 100644 --- a/wsai/pipeline.py +++ b/wsai/pipeline.py @@ -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: