From be4bc87edfce800a6143393cdf15903b0022dbf7 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Sat, 22 Aug 2026 12:15:11 +0900 Subject: [PATCH] feat(dashboard): conversation log filter by time/user/server/channel/content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the requested multi-dimension log search. Turns now carry speaker, guild and channel (the bot sends X-User/Guild/Channel-Name on the voice-turn POST), and a filter bar above the conversation feed narrows by 시간(최근 N분)·유저·서버· 채널·내용. The event/error panel keeps its text+level search. Co-Authored-By: Claude Opus 4.7 --- README.md | 6 +++-- dave/bot.mjs | 8 ++++++- wsai/dashboard.py | 59 +++++++++++++++++++++++++++++++++++++++++++++-- wsai/monitor.py | 4 ++++ 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index aa82944..49aaee9 100644 --- a/README.md +++ b/README.md @@ -245,8 +245,10 @@ Claude 두뇌 → GPU TTS를 돌려 응답 wav를 돌려주고 봇이 채널에 - **화이트/블랙리스트** — 팝업에서 서버의 유저/역할을 검색해 추가/제거. 화이트리스트가 있으면 그 대상만 청취(비어있으면 전체), 블랙리스트는 제외. 봇이 발화자 SSRC→유저/역할로 필터 (`dave/filter.mjs`의 `isAllowed`). -- **대화 카드** — 들음 / 생각(감정 톤 계획) / 답변 3분할, 발화자(🗣) 표시, 단계별·총 소요시간. -- **로그 패널** — 하단 고정 VSCode 터미널식, 열고닫기, 시작부터 기록. 텍스트/레벨 검색, +- **대화 카드 + 로그 검색** — 들음 / 생각(감정 톤 계획) / 답변 3분할, 발화자(🗣)·서버/채널(🔊) + 표시, 단계별·총 소요시간. 대화 로그는 **시간·유저(발화자)·서버·채널·내용**으로 필터한다 + (턴에 speaker/guild/channel 메타데이터를 실어 봇이 X-User/Guild/Channel-Name 헤더로 보고). +- **이벤트 로그 패널** — 하단 고정 VSCode 터미널식, 열고닫기, 시작부터 기록. 텍스트/레벨 검색, 전체·라인별 삭제/수정(`/api/logs/{clear,delete,edit}`). ### 대시보드 ↔ 봇 제어 채널 diff --git a/dave/bot.mjs b/dave/bot.mjs index 875d1ee..a0fad90 100644 --- a/dave/bot.mjs +++ b/dave/bot.mjs @@ -123,9 +123,15 @@ async function handleUtterance(userId, pcm) { } catch {} let resp; try { + const guildName = (currentGuildId && client.guilds.cache.get(currentGuildId)?.name) || ''; resp = await fetch(VOICE_ENDPOINT, { method: 'POST', - headers: { 'Content-Type': 'audio/wav', 'X-User-Name': encodeURIComponent(speaker) }, + headers: { + 'Content-Type': 'audio/wav', + 'X-User-Name': encodeURIComponent(speaker), + 'X-Guild-Name': encodeURIComponent(guildName), + 'X-Channel-Name': encodeURIComponent(currentChannelName || ''), + }, body: wav, }); } catch (e) { diff --git a/wsai/dashboard.py b/wsai/dashboard.py index c70c9ee..7fef884 100644 --- a/wsai/dashboard.py +++ b/wsai/dashboard.py @@ -135,8 +135,10 @@ def _make_handler(dash: "Dashboard"): "application/json; charset=utf-8") return speaker = urllib.parse.unquote(self.headers.get("X-User-Name", "") or "") + guild = urllib.parse.unquote(self.headers.get("X-Guild-Name", "") or "") + channel = urllib.parse.unquote(self.headers.get("X-Channel-Name", "") or "") try: - res = dash.voice_turn(raw, speaker=speaker) + res = dash.voice_turn(raw, speaker=speaker, guild=guild, channel=channel) except Exception as exc: # noqa: BLE001 log.exception("voice-turn failed") self._send(500, json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"}, @@ -395,7 +397,8 @@ class Dashboard: if self.tts is not None: self._submit(self.tts._ensure()) - def voice_turn(self, audio_bytes: bytes, speaker: str = "") -> dict: + def voice_turn(self, audio_bytes: bytes, speaker: str = "", + guild: str = "", channel: str = "") -> dict: """One Discord voice turn: decode the uploaded utterance, recognise it on the GPU, think of a reply (Claude brain if wired, else echo), synthesise it on the GPU, and return {heard, reply, wav} where wav is the @@ -416,6 +419,7 @@ class Dashboard: turn = self.monitor.turn(source="discord") if speaker: turn.speaker = speaker # who spoke (for the "누가 말했는지" log) + turn.guild, turn.channel = guild, channel # for 서버별/채널별 필터 t0 = time.monotonic() try: subprocess.run( @@ -629,6 +633,14 @@ PAGE = r""" border-radius:999px;padding:3px 10px;font-size:12px} .part.spk{border-color:#1f5236;color:#9ff0bd} .speaker{color:var(--muted);font-size:11.5px} + /* 대화 로그 검색 필터 (시간·유저·서버·채널·내용) */ + .tfilter{display:flex;gap:8px;align-items:center;flex-wrap:wrap;background:var(--panel); + border:1px solid var(--line);border-radius:12px;padding:8px 12px;margin:0 0 12px} + .tfilter input,.tfilter select{background:var(--panel2);border:1px solid var(--line);color:var(--fg); + border-radius:8px;padding:6px 9px;font-size:12.5px} + .tfilter input{width:118px} + .tf-label{color:var(--muted);font-size:12px;font-weight:600} + .tf-count{color:var(--muted);font-size:11.5px;margin-left:auto} /* Modal / popup (reused by 프롬프트, 화이트/블랙리스트 …) */ .modal{position:fixed;inset:0;z-index:20;background:rgba(4,7,11,.66); display:flex;align-items:center;justify-content:center;padding:20px} @@ -736,6 +748,22 @@ PAGE = r"""
+
+ 대화 로그 검색 + + + + + + + +
아직 대화가 없습니다. 사용자가 말하면 여기에 단계별로 나타납니다.
@@ -845,6 +873,7 @@ function turnEl(t){ '
'+badge +'#'+t.id+' · '+esc(t.source||'voice')+'' +(t.speaker?'🗣 '+esc(t.speaker)+'':'') + +(t.channel?'🔊 '+esc((t.guild?t.guild+' / ':'')+t.channel)+'':'') +''+fmtTime(t.wall)+'
' +'
들음'+(t.heard?esc(t.heard):'(수신 대기)')+'
' +'
생각'+(t.thought?esc(t.thought):'')+'
' @@ -863,6 +892,29 @@ function upsertTurn(t){ const fresh = turnEl(t); if(existing){ existing.replaceWith(fresh); } else { cont.prepend(fresh); } + applyTurnFilter(); +} + +// --- 대화 로그 검색: 시간·유저·서버·채널·내용 필터 ------------------------ # +function turnFilter(){ + return { mins:+$('tfTime').value, user:$('tfUser').value.trim().toLowerCase(), + guild:$('tfGuild').value.trim().toLowerCase(), channel:$('tfChannel').value.trim().toLowerCase(), + text:$('tfText').value.trim().toLowerCase() }; +} +function turnMatches(t, f){ + if(f.mins && (Date.now()/1000 - (t.wall||0)) > f.mins*60) return false; + if(f.user && !((t.speaker||'').toLowerCase().includes(f.user))) return false; + if(f.guild && !((t.guild||'').toLowerCase().includes(f.guild))) return false; + if(f.channel && !((t.channel||'').toLowerCase().includes(f.channel))) return false; + if(f.text && !(((t.heard||'')+' '+(t.reply||'')+' '+(t.thought||'')).toLowerCase().includes(f.text))) return false; + return true; +} +function applyTurnFilter(){ + const f=turnFilter(); let shown=0; + for(const [id,t] of turns){ const el=$('turn-'+id); if(!el) continue; + const ok=turnMatches(t,f); el.style.display=ok?'':'none'; if(ok) shown++; } + const active = f.mins||f.user||f.guild||f.channel||f.text; + $('tfCount').textContent = turns.size ? (active ? shown+' / '+turns.size+' 대화' : turns.size+' 대화') : ''; } // --- Bottom terminal log panel: store all events, render filtered ---------- # @@ -1121,6 +1173,9 @@ async function openLists(){ } $('wlBtn').onclick = openLists; $('blBtn').onclick = openLists; +['tfTime','tfUser','tfGuild','tfChannel','tfText'].forEach(id=>{ const e=$(id); if(e){ e.oninput=applyTurnFilter; e.onchange=applyTurnFilter; } }); +$('tfClear').onclick=()=>{ $('tfTime').value='0'; $('tfUser').value=''; $('tfGuild').value=''; $('tfChannel').value=''; $('tfText').value=''; applyTurnFilter(); }; + async function pollBot(){ try{ renderBot(await (await fetch('/api/bot/state')).json()); }catch(e){} } diff --git a/wsai/monitor.py b/wsai/monitor.py index dc6dd5a..1d7a013 100644 --- a/wsai/monitor.py +++ b/wsai/monitor.py @@ -81,6 +81,8 @@ class Turn: self.id = turn_id self.source = source self.speaker = "" # who spoke (Discord display name), when known + self.guild = "" # server name, when known (for 서버별 필터) + self.channel = "" # voice channel name, when known (for 채널별 필터) self.wall = _now_wall() self._t0 = _now_mono() self.heard_text = "" @@ -139,6 +141,8 @@ class Turn: "id": self.id, "source": self.source, "speaker": self.speaker, + "guild": self.guild, + "channel": self.channel, "wall": self.wall, "heard": self.heard_text, "thought": self.thought_text,