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"""