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",
|
monitor.set_components({"source": "none", "vision": "none", "stt": "whisper",
|
||||||
"brain": "none", "tts": "none"})
|
"brain": "none", "tts": "none"})
|
||||||
monitor.set_status(running=True, listening=False)
|
monitor.set_status(running=True, listening=False)
|
||||||
monitor.log("info", "STT 인식 테스트 서버 시작 — GPU 워밍업 중…")
|
monitor.log("info", "STT 인식 테스트 서버 시작 — GPU 워밍업 중…", cat="READY")
|
||||||
print("\n STT 워밍업 중… (모델 로드 + CUDA 예열)")
|
print("\n STT 워밍업 중… (모델 로드 + CUDA 예열)")
|
||||||
dash.warm() # load + warm the GPU worker so the first recognition is instant
|
dash.warm() # load + warm the GPU worker so the first recognition is instant
|
||||||
dev = getattr(stt, "resolved_device", None) or "?"
|
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()
|
shown = host if host not in ("0.0.0.0", "") else _lan_ip()
|
||||||
print(f"\n 음성 인식 테스트 사이트: http://{shown}:{port} (STT device: {dev})")
|
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",
|
monitor.set_components({"source": "none", "vision": "none", "stt": "whisper",
|
||||||
"brain": brain_name, "tts": "melo"})
|
"brain": brain_name, "tts": "melo"})
|
||||||
monitor.set_status(running=True, listening=False)
|
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 예열)")
|
print("\n STT+TTS 워밍업 중… (모델 로드 + CUDA 예열)")
|
||||||
dash.warm()
|
dash.warm()
|
||||||
sdev = getattr(stt, "resolved_device", None) or "?"
|
sdev = getattr(stt, "resolved_device", None) or "?"
|
||||||
monitor.set_status(listening=True)
|
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()
|
shown = host if host not in ("0.0.0.0", "") else _lan_ip()
|
||||||
print(f"\n 음성 서버 준비 완료 (STT device: {sdev}, 두뇌: {brain_name})")
|
print(f"\n 음성 서버 준비 완료 (STT device: {sdev}, 두뇌: {brain_name})")
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ stays usable directly.
|
|||||||
Env:
|
Env:
|
||||||
WSAI_WHISPER_PYTHON interpreter with faster-whisper installed
|
WSAI_WHISPER_PYTHON interpreter with faster-whisper installed
|
||||||
(default: /home/claude/jarvis-stt/whisper312/bin/python)
|
(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,
|
WSAI_WHISPER_DEVICE cpu | cuda | auto (default auto: GPU if present,
|
||||||
else CPU; the worker falls back to CPU if CUDA fails)
|
else CPU; the worker falls back to CPU if CUDA fails)
|
||||||
WSAI_WHISPER_LANGUAGE forced language, e.g. ko (default ko; "" = autodetect)
|
WSAI_WHISPER_LANGUAGE forced language, e.g. ko (default ko; "" = autodetect)
|
||||||
@@ -67,7 +67,7 @@ class WhisperSTT:
|
|||||||
audio_source: AsyncIterator[str] | None = None,
|
audio_source: AsyncIterator[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.python = python or os.environ.get("WSAI_WHISPER_PYTHON", _DEFAULT_PYTHON)
|
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")
|
self.device = device or os.environ.get("WSAI_WHISPER_DEVICE", "auto")
|
||||||
# "" means autodetect; a real code like "ko" forces the language.
|
# "" means autodetect; a real code like "ko" forces the language.
|
||||||
env_lang = os.environ.get("WSAI_WHISPER_LANGUAGE", "ko")
|
env_lang = os.environ.get("WSAI_WHISPER_LANGUAGE", "ko")
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ def _make_handler(dash: "Dashboard"):
|
|||||||
return
|
return
|
||||||
prompt_store.set_persona(prompt)
|
prompt_store.set_persona(prompt)
|
||||||
monitor.log("info", "봇 프롬프트가 수정되었습니다" if prompt.strip()
|
monitor.log("info", "봇 프롬프트가 수정되었습니다" if prompt.strip()
|
||||||
else "봇 프롬프트가 기본값으로 초기화되었습니다")
|
else "봇 프롬프트가 기본값으로 초기화되었습니다", cat="PROMPT")
|
||||||
body = json.dumps({
|
body = json.dumps({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"prompt": prompt_store.get_persona(self._default_persona()),
|
"prompt": prompt_store.get_persona(self._default_persona()),
|
||||||
@@ -253,7 +253,11 @@ def _make_handler(dash: "Dashboard"):
|
|||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
self._send_json({"ok": False, "error": "invalid JSON"}, 400)
|
self._send_json({"ok": False, "error": "invalid JSON"}, 400)
|
||||||
return
|
return
|
||||||
|
was_connected = dash.bot.state().get("connected")
|
||||||
dash.bot.report(data)
|
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 +
|
# Hand the bot any queued commands + the current listen filters +
|
||||||
# behaviour settings in the same round trip so it does not have to
|
# behaviour settings in the same round trip so it does not have to
|
||||||
# poll extra endpoints.
|
# poll extra endpoints.
|
||||||
@@ -273,10 +277,10 @@ def _make_handler(dash: "Dashboard"):
|
|||||||
channel_id = (data.get("channelId") or "").strip()
|
channel_id = (data.get("channelId") or "").strip()
|
||||||
if channel_id and guild_id:
|
if channel_id and guild_id:
|
||||||
cid = dash.bot.enqueue({"type": "join", "guildId": guild_id, "channelId": channel_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:
|
else:
|
||||||
cid = dash.bot.enqueue({"type": "leave"})
|
cid = dash.bot.enqueue({"type": "leave"})
|
||||||
monitor.log("info", "음성채널 나가기 요청")
|
monitor.log("info", "음성채널 나가기 요청", cat="VOICE")
|
||||||
self._send_json({"ok": True, "commandId": cid})
|
self._send_json({"ok": True, "commandId": cid})
|
||||||
|
|
||||||
def _handle_bot_lists(self) -> None:
|
def _handle_bot_lists(self) -> None:
|
||||||
@@ -291,7 +295,7 @@ def _make_handler(dash: "Dashboard"):
|
|||||||
self._send_json({"ok": False, "error": str(exc)}, 400)
|
self._send_json({"ok": False, "error": str(exc)}, 400)
|
||||||
return
|
return
|
||||||
saved = dash.bot.set_lists(guild_id, data.get("lists") or {})
|
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})
|
self._send_json({"ok": True, "guildId": guild_id, "lists": saved})
|
||||||
|
|
||||||
def _handle_model_switch(self, which: str) -> None:
|
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)
|
self._send_json({"ok": False, "error": "invalid JSON"}, 400)
|
||||||
return
|
return
|
||||||
settings = dash.bot.set_settings(data)
|
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})
|
self._send_json({"ok": True, "settings": settings})
|
||||||
|
|
||||||
def _handle_tts_settings_get(self) -> None:
|
def _handle_tts_settings_get(self) -> None:
|
||||||
@@ -349,7 +353,8 @@ def _make_handler(dash: "Dashboard"):
|
|||||||
tgt = data.get("emotion") or "base"
|
tgt = data.get("emotion") or "base"
|
||||||
monitor.log("info", f"봇 목소리 파라미터 적용 ({tgt}): base="
|
monitor.log("info", f"봇 목소리 파라미터 적용 ({tgt}): base="
|
||||||
+ json.dumps(res["base"], ensure_ascii=False)
|
+ 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)
|
self._send_json(res)
|
||||||
|
|
||||||
def _handle_tts_preview(self) -> None:
|
def _handle_tts_preview(self) -> None:
|
||||||
@@ -609,20 +614,22 @@ class Dashboard:
|
|||||||
raise ValueError("model required")
|
raise ValueError("model required")
|
||||||
if model not in self.STT_OPTIONS:
|
if model not in self.STT_OPTIONS:
|
||||||
raise ValueError(f"unknown STT model: {model}")
|
raise ValueError(f"unknown STT model: {model}")
|
||||||
if model != self.stt.model:
|
changed = model != self.stt.model
|
||||||
|
if changed:
|
||||||
self.stt.model = model
|
self.stt.model = model
|
||||||
self._submit(self.stt.aclose()) # drop old worker (fast)
|
self._submit(self.stt.aclose()) # drop old worker (fast)
|
||||||
# Reload+warm in the background; don't block the HTTP response.
|
# Reload+warm in the background; don't block the HTTP response.
|
||||||
asyncio.run_coroutine_threadsafe(self._warm_stt_bg(), self._loop)
|
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:
|
async def _warm_stt_bg(self) -> None:
|
||||||
try:
|
try:
|
||||||
await self.stt.warmup()
|
await self.stt.warmup()
|
||||||
self.monitor.log("info", f"STT 모델 로드 완료: {self.stt.model} "
|
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
|
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:
|
def set_llm_model(self, model: str) -> dict:
|
||||||
"""Switch the Claude model live — applied on the next reply (no reload)."""
|
"""Switch the Claude model live — applied on the next reply (no reload)."""
|
||||||
@@ -631,9 +638,11 @@ class Dashboard:
|
|||||||
model = str(model).strip()
|
model = str(model).strip()
|
||||||
if model not in self.LLM_OPTIONS:
|
if model not in self.LLM_OPTIONS:
|
||||||
raise ValueError(f"unknown LLM model: {model}")
|
raise ValueError(f"unknown LLM model: {model}")
|
||||||
|
changed = model != self.brain.model
|
||||||
self.brain.model = model
|
self.brain.model = model
|
||||||
self.monitor.log("info", f"LLM 모델 변경: {model} (다음 답변부터 적용)")
|
if changed:
|
||||||
return self.models_settings()["llm"]
|
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 = "",
|
def voice_turn(self, audio_bytes: bytes, speaker: str = "",
|
||||||
guild: str = "", channel: str = "") -> dict:
|
guild: str = "", channel: str = "") -> dict:
|
||||||
@@ -718,7 +727,7 @@ class Dashboard:
|
|||||||
self.monitor.add_claude_usage(u.get("input", 0), u.get("output", 0))
|
self.monitor.add_claude_usage(u.get("input", 0), u.get("output", 0))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
log.exception("brain failed")
|
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()
|
blob = f"{getattr(exc, 'status_code', '')} {exc}".lower()
|
||||||
if "529" in blob or "overload" in blob:
|
if "529" in blob or "overload" in blob:
|
||||||
# Transient server overload survived the SDK retries.
|
# 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{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}
|
.cfgrow input[type=checkbox]{width:16px;height:16px;accent-color:#3aa0ff;cursor:pointer}
|
||||||
.cfghint{color:var(--muted);font-weight:400;font-size:12px}
|
.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}
|
.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{background:#173042;border:1px solid #234a63;color:var(--fg);border-radius:10px;padding:8px 14px;font-size:13px;cursor:pointer}
|
||||||
.btn:hover{background:#1d3d54}
|
.btn:hover{background:#1d3d54}
|
||||||
@@ -962,6 +977,19 @@ PAGE = r"""<!DOCTYPE html>
|
|||||||
.logline.info .lv{color:var(--accent)}
|
.logline.info .lv{color:var(--accent)}
|
||||||
.logline.error .lv{color:var(--err)}
|
.logline.error .lv{color:var(--err)}
|
||||||
.logline.warn .lv{color:var(--warn)}
|
.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 .lm{flex:1;color:var(--fg);white-space:pre-wrap;word-break:break-word}
|
||||||
.logline.error .lm{color:#ffb3bb}
|
.logline.error .lm{color:#ffb3bb}
|
||||||
.logline .lacts{opacity:0;display:flex;gap:4px}
|
.logline .lacts{opacity:0;display:flex;gap:4px}
|
||||||
@@ -1052,7 +1080,10 @@ PAGE = r"""<!DOCTYPE html>
|
|||||||
<button id="mLLMapply" class="btn">적용</button>
|
<button id="mLLMapply" class="btn">적용</button>
|
||||||
<span id="mLLMstat" class="sttstat"></span>
|
<span id="mLLMstat" class="sttstat"></span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="demobar" id="demobar" style="display:none"></div>
|
<div class="demobar" id="demobar" style="display:none"></div>
|
||||||
@@ -1081,11 +1112,19 @@ PAGE = r"""<!DOCTYPE html>
|
|||||||
<div class="logbar">
|
<div class="logbar">
|
||||||
<button id="logToggle" class="logtoggle">▾ 이벤트 / 오류 로그</button>
|
<button id="logToggle" class="logtoggle">▾ 이벤트 / 오류 로그</button>
|
||||||
<input id="logSearch" class="logsearch" placeholder="로그 검색 (텍스트)">
|
<input id="logSearch" class="logsearch" placeholder="로그 검색 (텍스트)">
|
||||||
<select id="logLevel" class="logsel">
|
<select id="logLevel" class="logsel" title="레벨(타입)">
|
||||||
<option value="">전체</option>
|
<option value="">레벨 전체</option>
|
||||||
<option value="error">오류만</option>
|
<option value="error">오류(ERROR)</option>
|
||||||
<option value="warn">경고만</option>
|
<option value="warn">경고(WARN)</option>
|
||||||
<option value="info">정보만</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>
|
</select>
|
||||||
<span id="logCount" class="logcount"></span>
|
<span id="logCount" class="logcount"></span>
|
||||||
<button id="logClear" class="btn logbtn">로그 삭제</button>
|
<button id="logClear" class="btn logbtn">로그 삭제</button>
|
||||||
@@ -1243,20 +1282,34 @@ function applyTurnFilter(){
|
|||||||
|
|
||||||
// --- Bottom terminal log panel: store all events, render filtered ---------- #
|
// --- Bottom terminal log panel: store all events, render filtered ---------- #
|
||||||
let logEvents = []; // {id, level, message, wall}
|
let logEvents = []; // {id, level, message, wall}
|
||||||
|
function logCat(e){ return (e.cat || (e.level||'info').toUpperCase()); }
|
||||||
function logMatches(e){
|
function logMatches(e){
|
||||||
const lv = $('logLevel').value;
|
const lv = $('logLevel').value;
|
||||||
if(lv && e.level!==lv) return false;
|
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();
|
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;
|
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(){
|
function renderLogs(){
|
||||||
const body = $('logbody');
|
const body = $('logbody');
|
||||||
|
refreshCatOptions();
|
||||||
const shown = logEvents.filter(logMatches);
|
const shown = logEvents.filter(logMatches);
|
||||||
body.innerHTML = shown.map(e =>
|
body.innerHTML = shown.map(e =>
|
||||||
'<div class="logline '+(e.level||'info')+'" data-id="'+e.id+'">'
|
'<div class="logline '+(e.level||'info')+'" data-id="'+e.id+'">'
|
||||||
+ '<span class="lt">'+fmtTime(e.wall)+'</span>'
|
+ '<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="lm">'+esc(e.message)+'</span>'
|
||||||
+ '<span class="lacts"><button class="lact" data-act="edit" title="수정">✎</button>'
|
+ '<span class="lacts"><button class="lact" data-act="edit" title="수정">✎</button>'
|
||||||
+ '<button class="lact" data-act="del" title="삭제">✕</button></span>'
|
+ '<button class="lact" data-act="del" title="삭제">✕</button></span>'
|
||||||
@@ -1329,8 +1382,8 @@ function wireCollapse(toggleId, bodyId, caretId){
|
|||||||
wireCollapse('modelToggle','modelBody','modelCaret');
|
wireCollapse('modelToggle','modelBody','modelCaret');
|
||||||
initModels();
|
initModels();
|
||||||
})();
|
})();
|
||||||
const STT_LABEL = {tiny:'tiny (가장 빠름)', base:'base', small:'small (기본)',
|
const STT_LABEL = {tiny:'tiny (가장 빠름)', base:'base', small:'small (빠름)',
|
||||||
medium:'medium (정확도↑)', 'large-v3':'large-v3 (최고 정확도)'};
|
medium:'medium (기본·정확)', 'large-v3':'large-v3 (최고 정확도)'};
|
||||||
const LLM_LABEL = {'claude-haiku-4-5':'Haiku 4.5 (가장 빠름)',
|
const LLM_LABEL = {'claude-haiku-4-5':'Haiku 4.5 (가장 빠름)',
|
||||||
'claude-sonnet-4-5':'Sonnet 4.5 (고품질·조금 느림)'};
|
'claude-sonnet-4-5':'Sonnet 4.5 (고품질·조금 느림)'};
|
||||||
function fillSel(sel, options, current, labels){
|
function fillSel(sel, options, current, labels){
|
||||||
@@ -1349,22 +1402,54 @@ async function initModels(){
|
|||||||
$('mLLMstat').textContent='현재: '+(LLM_LABEL[j.llm.current]||j.llm.current||'?'); }
|
$('mLLMstat').textContent='현재: '+(LLM_LABEL[j.llm.current]||j.llm.current||'?'); }
|
||||||
else { $('mLLM').disabled=$('mLLMapply').disabled=true; $('mLLMstat').textContent='LLM 비활성(echo 모드)'; }
|
else { $('mLLM').disabled=$('mLLMapply').disabled=true; $('mLLMstat').textContent='LLM 비활성(echo 모드)'; }
|
||||||
$('mSTTapply').onclick = async ()=>{
|
$('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'},
|
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();
|
body:JSON.stringify({model:m})}); const jj=await r.json();
|
||||||
if(jj.ok){ toast('STT 모델 전환: '+m); $('mSTTstat').textContent='전환됨: '+m+' · 로딩은 백그라운드로 진행됩니다'; }
|
if(!jj.ok){ $('mSTTstat').textContent='실패: '+(jj.error||''); return; }
|
||||||
else { $('mSTTstat').textContent='실패: '+(jj.error||''); }
|
if(!jj.stt.changed){ flash3($('mSTTstat'), '변경사항 없음', '현재: '+(jj.stt.current||m)+(jj.stt.device?(' · '+jj.stt.device):'')); return; }
|
||||||
|
pollSTTLoad(m); // 실제 변경 → 아래 로딩창 + 완료 시 이벤트 로그
|
||||||
}catch(e){ $('mSTTstat').textContent='오류: '+e; }
|
}catch(e){ $('mSTTstat').textContent='오류: '+e; }
|
||||||
};
|
};
|
||||||
$('mLLMapply').onclick = async ()=>{
|
$('mLLMapply').onclick = async ()=>{
|
||||||
const m=$('mLLM').value;
|
const m=$('mLLM').value;
|
||||||
try{ const r=await fetch('/api/models/llm',{method:'POST',headers:{'Content-Type':'application/json'},
|
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();
|
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)+' · 다음 답변부터'; }
|
if(!jj.ok){ $('mLLMstat').textContent='실패: '+(jj.error||''); return; }
|
||||||
else { $('mLLMstat').textContent='실패: '+(jj.error||''); }
|
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; }
|
}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) 조절: 감정별 슬라이더 → 미리듣기 → 봇 적용 -------------- #
|
// --- 봇 목소리(TTS) 조절: 감정별 슬라이더 → 미리듣기 → 봇 적용 -------------- #
|
||||||
let ttsInited = false;
|
let ttsInited = false;
|
||||||
@@ -1538,6 +1623,8 @@ window.addEventListener('resize', syncDockPad);
|
|||||||
})();
|
})();
|
||||||
$('logSearch').oninput = renderLogs;
|
$('logSearch').oninput = renderLogs;
|
||||||
$('logLevel').onchange = renderLogs;
|
$('logLevel').onchange = renderLogs;
|
||||||
|
$('logCat').onchange = renderLogs;
|
||||||
|
$('logTime').onchange = renderLogs;
|
||||||
$('logClear').onclick = async ()=>{
|
$('logClear').onclick = async ()=>{
|
||||||
if(!confirm('로그를 모두 삭제할까요?')) return;
|
if(!confirm('로그를 모두 삭제할까요?')) return;
|
||||||
try{ await fetch('/api/logs/clear',{method:'POST'}); toast('로그를 삭제했습니다'); }
|
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.
|
# Guarded so the repeated _touch()/finish() calls can't double-count.
|
||||||
if self.status == "error" and not self._error_logged:
|
if self.status == "error" and not self._error_logged:
|
||||||
self._error_logged = True
|
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()
|
self._touch()
|
||||||
|
|
||||||
# -- internal --------------------------------------------------------- #
|
# -- internal --------------------------------------------------------- #
|
||||||
@@ -209,12 +209,17 @@ class Monitor:
|
|||||||
self._status["claude_output_tokens"] += int(output_tokens or 0)
|
self._status["claude_output_tokens"] += int(output_tokens or 0)
|
||||||
self._broadcast({"type": "status", "status": self.status_snapshot()})
|
self._broadcast({"type": "status", "status": self.status_snapshot()})
|
||||||
|
|
||||||
def log(self, level: str, message: str) -> None:
|
def log(self, level: str, message: str, cat: str | None = None) -> None:
|
||||||
"""A free-form lifecycle/error line (startup, disconnect, crash…)."""
|
"""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:
|
with self._lock:
|
||||||
self._event_id += 1
|
self._event_id += 1
|
||||||
evt = {"type": "log", "id": self._event_id, "level": level,
|
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)
|
self._events.append(evt)
|
||||||
if level == "error":
|
if level == "error":
|
||||||
self._status["errors_total"] += 1
|
self._status["errors_total"] += 1
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class Pipeline:
|
|||||||
except Exception as exc: # a single bad frame must not kill the loop
|
except Exception as exc: # a single bad frame must not kill the loop
|
||||||
log.exception("vision.describe failed")
|
log.exception("vision.describe failed")
|
||||||
if self.monitor is not None:
|
if self.monitor is not None:
|
||||||
self.monitor.log("error", f"화면 이해 실패: {exc}")
|
self.monitor.log("error", f"화면 이해 실패: {exc}", cat="VISION")
|
||||||
continue
|
continue
|
||||||
await self.context.update(obs)
|
await self.context.update(obs)
|
||||||
log.debug("screen: %s", obs.text[:120])
|
log.debug("screen: %s", obs.text[:120])
|
||||||
@@ -122,7 +122,7 @@ class Pipeline:
|
|||||||
return
|
return
|
||||||
if self.monitor is not None:
|
if self.monitor is not None:
|
||||||
self.monitor.set_status(listening=True)
|
self.monitor.set_status(listening=True)
|
||||||
self.monitor.log("info", "음성 수신 시작 — 발화 대기 중")
|
self.monitor.log("info", "음성 수신 시작 — 발화 대기 중", cat="PIPELINE")
|
||||||
try:
|
try:
|
||||||
async for utt in self.stt.utterances():
|
async for utt in self.stt.utterances():
|
||||||
await self._handle(utt)
|
await self._handle(utt)
|
||||||
@@ -157,7 +157,7 @@ class Pipeline:
|
|||||||
except Exception as exc: # a warm failure must not abort startup
|
except Exception as exc: # a warm failure must not abort startup
|
||||||
log.warning("prewarm %s failed: %s", name, exc)
|
log.warning("prewarm %s failed: %s", name, exc)
|
||||||
if self.monitor is not None:
|
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:
|
async def run(self) -> None:
|
||||||
# A TaskGroup (not bare gather) so that if ONE loop raises, the others
|
# 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).
|
# still-live loop (close-during-use).
|
||||||
if self.monitor is not None:
|
if self.monitor is not None:
|
||||||
self.monitor.set_status(running=True)
|
self.monitor.set_status(running=True)
|
||||||
self.monitor.log("info", "파이프라인 시작")
|
self.monitor.log("info", "파이프라인 시작", cat="PIPELINE")
|
||||||
await self._prewarm()
|
await self._prewarm()
|
||||||
try:
|
try:
|
||||||
async with asyncio.TaskGroup() as tg:
|
async with asyncio.TaskGroup() as tg:
|
||||||
@@ -177,12 +177,12 @@ class Pipeline:
|
|||||||
except* Exception as eg:
|
except* Exception as eg:
|
||||||
if self.monitor is not None:
|
if self.monitor is not None:
|
||||||
for exc in eg.exceptions:
|
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
|
raise
|
||||||
finally:
|
finally:
|
||||||
if self.monitor is not None:
|
if self.monitor is not None:
|
||||||
self.monitor.set_status(running=False, listening=False)
|
self.monitor.set_status(running=False, listening=False)
|
||||||
self.monitor.log("info", "파이프라인 종료")
|
self.monitor.log("info", "파이프라인 종료", cat="PIPELINE")
|
||||||
await self.aclose()
|
await self.aclose()
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user