From b0272d2171ec2acfccbee2f6fb146540c717f4ea Mon Sep 17 00:00:00 2001 From: EJClaw Date: Wed, 26 Aug 2026 23:19:21 +0900 Subject: [PATCH] feat(dashboard): merge lists button, drop STT test, add bot/log settings, barge-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard changes requested by the user: - Merge 화이트리스트/블랙리스트 into one "👤 유저 등록/관리" button (the modal already holds both lists). - Remove the 음성 인식(STT) 테스트 section and its client JS. - Add collapsible "⚙️ 봇 관련 설정" with a barge-in toggle: "유저 목소리 들을 때 봇이 말하던 것 중지" (default on). Stored via /api/bot/settings; the bot reads it on its report round-trip and calls voicePlayer.stop() when an allowed user starts speaking (dave/bot.mjs). - Add collapsible "🧹 로그 관련 설정" with a "잡음 로그 표시 안 함" toggle (default on, localStorage-backed): hides turns whose 들음 is (빈 결과) or 답변 is [잡음]. - Fix the fixed event/error log dock covering the bottom-most turn: syncDockPad() pads main by the dock height (on load, log render, toggle, resize). BotControl gains a settings store; /api/bot/report now also returns settings. Co-Authored-By: Claude Opus 4.7 --- dave/bot.mjs | 7 +++ wsai/bot_control.py | 14 +++++ wsai/dashboard.py | 143 +++++++++++++++++++++++++------------------- 3 files changed, 104 insertions(+), 60 deletions(-) diff --git a/dave/bot.mjs b/dave/bot.mjs index e8e710c..6d7f994 100644 --- a/dave/bot.mjs +++ b/dave/bot.mjs @@ -177,6 +177,7 @@ let currentGuildId = null, currentChannelId = null, currentChannelName = null; const speakingSet = new Set(); // userIds currently speaking (for participant list) const activeSubs = new Set(); // userIds with an in-flight receive subscription let listsByGuild = {}; // guildId -> {whitelistUsers, blacklistUsers, whitelistRoles, blacklistRoles} +let botSettings = { bargeIn: true }; // behaviour toggles from the dashboard // Attach the bot's audio player (so it can speak) to a fresh connection. function setupPlayer(connection) { @@ -201,6 +202,11 @@ function setupReceiver(connection) { return; } } + // Barge-in: the moment an allowed user speaks, stop the bot's current TTS + // so it doesn't talk over them (toggleable from the dashboard, default on). + if (botSettings.bargeIn !== false && voicePlayer) { + try { voicePlayer.stop(true); } catch {} + } activeSubs.add(userId); if (!perUser.has(userId)) perUser.set(userId, { opusPackets: 0, pcmFrames: 0 }); const opusStream = receiver.subscribe(userId, { @@ -314,6 +320,7 @@ async function reportLoop() { j = await r.json(); } catch { return; } // dashboard down: keep running, retry next tick if (j?.lists) listsByGuild = j.lists; // latest whitelist/blacklist config + if (j?.settings) botSettings = j.settings; // latest behaviour toggles for (const cmd of (j?.commands || [])) { try { await handleCommand(cmd); } catch (e) { log(`command ${cmd?.type} failed: ${e.message}`); } } diff --git a/wsai/bot_control.py b/wsai/bot_control.py index 294be6d..08fe30b 100644 --- a/wsai/bot_control.py +++ b/wsai/bot_control.py @@ -32,6 +32,20 @@ class BotControl: # Per-guild listen filter. Empty whitelist => listen to everyone; # blacklist always excludes. Users and roles both supported. self._lists: dict[str, dict[str, Any]] = {} + # Bot behaviour settings the dashboard toggles and the bot reads on each + # report. bargeIn: stop the bot's current TTS the moment a user speaks. + self._settings: dict[str, Any] = {"bargeIn": True} + + # -- bot behaviour settings ------------------------------------------ # + def get_settings(self) -> dict[str, Any]: + with self._lock: + return dict(self._settings) + + def set_settings(self, data: dict[str, Any]) -> dict[str, Any]: + with self._lock: + if "bargeIn" in data: + self._settings["bargeIn"] = bool(data["bargeIn"]) + return dict(self._settings) # -- whitelist / blacklist (per guild) ------------------------------- # @staticmethod diff --git a/wsai/dashboard.py b/wsai/dashboard.py index 641668a..e42a1b3 100644 --- a/wsai/dashboard.py +++ b/wsai/dashboard.py @@ -85,6 +85,8 @@ def _make_handler(dash: "Dashboard"): self._send_json({"ok": True, "guildId": gid, "lists": dash.bot.get_lists(gid)}) elif path == "/api/tts/settings": self._handle_tts_settings_get() + elif path == "/api/bot/settings": + self._send_json({"ok": True, "settings": dash.bot.get_settings()}) elif path == "/events": self._stream_events() else: @@ -115,6 +117,8 @@ def _make_handler(dash: "Dashboard"): self._handle_tts_settings_post() elif path == "/api/tts/preview": self._handle_tts_preview() + elif path == "/api/bot/settings": + self._handle_bot_settings_post() else: self._send(404, b"not found", "text/plain; charset=utf-8") @@ -244,10 +248,12 @@ def _make_handler(dash: "Dashboard"): self._send_json({"ok": False, "error": "invalid JSON"}, 400) return dash.bot.report(data) - # Hand the bot any queued commands + the current listen filters in the - # same round trip so it does not have to poll extra endpoints. + # 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. self._send_json({"ok": True, "commands": dash.bot.drain(), - "lists": dash.bot.all_lists()}) + "lists": dash.bot.all_lists(), + "settings": dash.bot.get_settings()}) def _handle_bot_select(self) -> None: """UI picked a server/voice channel → queue a join (or leave) command.""" @@ -282,6 +288,19 @@ def _make_handler(dash: "Dashboard"): monitor.log("info", f"청취 화이트/블랙리스트 업데이트 (guild={guild_id})") self._send_json({"ok": True, "guildId": guild_id, "lists": saved}) + def _handle_bot_settings_post(self) -> None: + """Save a bot behaviour toggle (e.g. bargeIn). The bot reads the new + value on its next report round-trip.""" + raw = self._read_body() + try: + data = json.loads(raw.decode("utf-8")) if raw else {} + except (ValueError, AttributeError): + 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)) + self._send_json({"ok": True, "settings": settings}) + def _handle_tts_settings_get(self) -> None: """Return the live TTS controls (speed/word_gap/sentence_gap/pitch) and their slider ranges so the page can render the panel.""" @@ -748,7 +767,10 @@ PAGE = r""" .sttbox{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 16px;margin:0 0 16px} .sttbox h2{font-size:13px;margin:0 0 10px;font-weight:600;color:var(--fg)} .sttbox h2.collap{cursor:pointer;user-select:none;margin:0} - .sttbox h2.collap #ttsCaret{display:inline-block;width:12px;color:var(--muted)} + .sttbox h2.collap span{display:inline-block;width:12px;color:var(--muted)} + .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} .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} @@ -877,20 +899,9 @@ PAGE = r""" 봇: 연결 안 됨 - - + - +
+

▸ ⚙️ 봇 관련 설정

+ +
+
+

▸ 🧹 로그 관련 설정

+ +
@@ -1005,8 +1030,6 @@ function renderStatus(s){ const comps = s.components || {}; // Demo banner: if the ears/brain/mouth are still mock, everything below is // replayed sample data, not a real conversation. Say so loudly. - const sttReal = comps.stt && comps.stt!=='mock' && comps.stt!=='none'; - $('sttbox').style.display = sttReal ? 'block' : 'none'; const ttsReal = comps.tts && comps.tts!=='mock' && comps.tts!=='none'; $('ttsbox').style.display = ttsReal ? 'block' : 'none'; if(ttsReal) initTts(); @@ -1086,7 +1109,11 @@ function turnFilter(){ guild:$('tfGuild').value.trim().toLowerCase(), channel:$('tfChannel').value.trim().toLowerCase(), text:$('tfText').value.trim().toLowerCase() }; } +function isNoiseTurn(t){ + return (t.heard==='(빈 결과)') || (t.reply==='[잡음]'); +} function turnMatches(t, f){ + if(hideNoise && isNoiseTurn(t)) return false; // 잡음 로그 표시 안 함 토글 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; @@ -1098,7 +1125,7 @@ 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; + const active = f.mins||f.user||f.guild||f.channel||f.text||hideNoise; $('tfCount').textContent = turns.size ? (active ? shown+' / '+turns.size+' 대화' : turns.size+' 대화') : ''; } @@ -1124,6 +1151,7 @@ function renderLogs(){ + '
' ).join(''); $('logCount').textContent = shown.length + (shown.length!==logEvents.length ? ' / '+logEvents.length : '') + '줄'; + if(typeof syncDockPad==='function') syncDockPad(); } function addEvent(e){ if(e.id==null){ e.id = 'c'+Date.now()+Math.random(); } @@ -1157,47 +1185,33 @@ function connect(){ else if(ev.type==='log_edited') { const e=logEvents.find(e=>e.id===ev.id); if(e){e.message=ev.message; renderLogs();} } }; } -// --- STT recognition test (upload / mic record -> GPU whisper) ----------- # -let mediaRec=null, chunks=[]; -async function sendBlob(blob){ - $('ststat').textContent='인식 중… (GPU)'; - $('sttres').innerHTML=''; - try{ - const r=await fetch('/api/stt',{method:'POST', - headers:{'Content-Type':blob.type||'application/octet-stream'},body:blob}); - const j=await r.json(); - if(j.ok){ - $('sttres').innerHTML='
'+esc(j.text||'(빈 결과)')+'
' - +'
인식 '+j.ms+' ms · '+esc(j.device)+'
'; - $('ststat').textContent='완료. 다시 녹음하거나 파일을 올릴 수 있습니다.'; - }else{ - $('sttres').innerHTML='
오류: '+esc(j.error)+'
'; - $('ststat').textContent='실패.'; - } - }catch(e){ $('ststat').textContent='요청 실패: '+e; } +// --- 봇 관련 설정 / 로그 관련 설정 (접이식) ------------------------------- # +let hideNoise = localStorage.getItem('wsai_hideNoise') !== '0'; // 기본: 켜짐 +function wireCollapse(toggleId, bodyId, caretId){ + $(toggleId).onclick = ()=>{ + const b=$(bodyId), open=b.style.display==='none'; + b.style.display = open ? 'block' : 'none'; + $(caretId).textContent = open ? '▾' : '▸'; + }; } -(function(){ - const fi=$('fileinp'); if(fi) fi.onchange=()=>{ if(fi.files[0]) sendBlob(fi.files[0]); }; - const rb=$('recbtn'); if(!rb) return; - rb.onclick=async()=>{ - if(mediaRec && mediaRec.state==='recording'){ mediaRec.stop(); return; } - if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){ - $('ststat').textContent='이 주소(원격 http)에서는 브라우저 마이크가 막혀 있습니다. 파일 업로드를 사용하세요.'; - return; - } - try{ - const stream=await navigator.mediaDevices.getUserMedia({audio:true}); - chunks=[]; mediaRec=new MediaRecorder(stream); - mediaRec.ondataavailable=(e)=>{ if(e.data.size) chunks.push(e.data); }; - mediaRec.onstop=()=>{ - stream.getTracks().forEach(t=>t.stop()); - rb.textContent='🎤 녹음 시작'; rb.classList.remove('rec'); - sendBlob(new Blob(chunks,{type:mediaRec.mimeType||'audio/webm'})); - }; - mediaRec.start(); - rb.textContent='⏹ 녹음 중지'; rb.classList.add('rec'); - $('ststat').textContent='녹음 중… 말한 뒤 중지를 누르세요.'; - }catch(e){ $('ststat').textContent='마이크 접근 실패: '+e; } +(function initSettingsPanels(){ + wireCollapse('botcfgToggle','botcfgBody','botcfgCaret'); + wireCollapse('logcfgToggle','logcfgBody','logcfgCaret'); + // 봇 설정: 바지-인(유저가 말하면 봇 TTS 중지) — 서버에 저장, 봇이 읽어감. + $('cfgBargeIn').onchange = async ()=>{ + try{ await fetch('/api/bot/settings',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({bargeIn:$('cfgBargeIn').checked})}); + toast('봇 설정 저장됨'); }catch(e){} + }; + fetch('/api/bot/settings').then(r=>r.json()).then(j=>{ + if(j&&j.ok&&j.settings) $('cfgBargeIn').checked = j.settings.bargeIn !== false; + }).catch(()=>{}); + // 로그 설정: 잡음 로그 표시 안 함 — 클라이언트 필터(로컬 저장). + $('cfgHideNoise').checked = hideNoise; + $('cfgHideNoise').onchange = ()=>{ + hideNoise = $('cfgHideNoise').checked; + localStorage.setItem('wsai_hideNoise', hideNoise ? '1':'0'); + applyTurnFilter(); }; })(); @@ -1339,10 +1353,18 @@ async function openPrompt(){ $('promptBtn').onclick = openPrompt; // --- 로그 패널: 열고닫기 / 검색 / 삭제(전체·개별) / 수정 -------------------- # +// Keep enough space at the bottom of the page so the fixed log dock never +// covers the last (bottom-most) conversation turn. +function syncDockPad(){ + const dock=$('logdock'), main=document.querySelector('main'); + if(dock && main) main.style.paddingBottom = (dock.offsetHeight + 24) + 'px'; +} $('logToggle').onclick = ()=>{ const d=$('logdock'); d.classList.toggle('collapsed'); $('logToggle').textContent = (d.classList.contains('collapsed')?'▸':'▾') + ' 이벤트 / 오류 로그'; + syncDockPad(); }; +window.addEventListener('resize', syncDockPad); $('logSearch').oninput = renderLogs; $('logLevel').onchange = renderLogs; $('logClear').onclick = async ()=>{ @@ -1457,7 +1479,7 @@ async function openLists(){ }; renderResults(); renderChips(); } -$('wlBtn').onclick = openLists; $('blBtn').onclick = openLists; +$('listsBtn').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(); }; @@ -1468,6 +1490,7 @@ async function pollBot(){ pollBot(); setInterval(pollBot, 2500); connect(); +syncDockPad(); // Refresh uptime label every second from the last known status. setInterval(()=>{ if(statusData){ statusData.uptime_s=(statusData.uptime_s||0)+1; $('s-up').textContent=fmtUptime(statusData.uptime_s);} }, 1000);