feat(dashboard): merge lists button, drop STT test, add bot/log settings, barge-in

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 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-08-26 23:19:21 +09:00
parent b782ca70bd
commit b0272d2171
3 changed files with 104 additions and 60 deletions

View File

@@ -177,6 +177,7 @@ let currentGuildId = null, currentChannelId = null, currentChannelName = null;
const speakingSet = new Set(); // userIds currently speaking (for participant list) const speakingSet = new Set(); // userIds currently speaking (for participant list)
const activeSubs = new Set(); // userIds with an in-flight receive subscription const activeSubs = new Set(); // userIds with an in-flight receive subscription
let listsByGuild = {}; // guildId -> {whitelistUsers, blacklistUsers, whitelistRoles, blacklistRoles} 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. // Attach the bot's audio player (so it can speak) to a fresh connection.
function setupPlayer(connection) { function setupPlayer(connection) {
@@ -201,6 +202,11 @@ function setupReceiver(connection) {
return; 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); activeSubs.add(userId);
if (!perUser.has(userId)) perUser.set(userId, { opusPackets: 0, pcmFrames: 0 }); if (!perUser.has(userId)) perUser.set(userId, { opusPackets: 0, pcmFrames: 0 });
const opusStream = receiver.subscribe(userId, { const opusStream = receiver.subscribe(userId, {
@@ -314,6 +320,7 @@ async function reportLoop() {
j = await r.json(); j = await r.json();
} catch { return; } // dashboard down: keep running, retry next tick } catch { return; } // dashboard down: keep running, retry next tick
if (j?.lists) listsByGuild = j.lists; // latest whitelist/blacklist config 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 || [])) { for (const cmd of (j?.commands || [])) {
try { await handleCommand(cmd); } catch (e) { log(`command ${cmd?.type} failed: ${e.message}`); } try { await handleCommand(cmd); } catch (e) { log(`command ${cmd?.type} failed: ${e.message}`); }
} }

View File

@@ -32,6 +32,20 @@ class BotControl:
# Per-guild listen filter. Empty whitelist => listen to everyone; # Per-guild listen filter. Empty whitelist => listen to everyone;
# blacklist always excludes. Users and roles both supported. # blacklist always excludes. Users and roles both supported.
self._lists: dict[str, dict[str, Any]] = {} 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) ------------------------------- # # -- whitelist / blacklist (per guild) ------------------------------- #
@staticmethod @staticmethod

View File

@@ -85,6 +85,8 @@ def _make_handler(dash: "Dashboard"):
self._send_json({"ok": True, "guildId": gid, "lists": dash.bot.get_lists(gid)}) self._send_json({"ok": True, "guildId": gid, "lists": dash.bot.get_lists(gid)})
elif path == "/api/tts/settings": elif path == "/api/tts/settings":
self._handle_tts_settings_get() self._handle_tts_settings_get()
elif path == "/api/bot/settings":
self._send_json({"ok": True, "settings": dash.bot.get_settings()})
elif path == "/events": elif path == "/events":
self._stream_events() self._stream_events()
else: else:
@@ -115,6 +117,8 @@ def _make_handler(dash: "Dashboard"):
self._handle_tts_settings_post() self._handle_tts_settings_post()
elif path == "/api/tts/preview": elif path == "/api/tts/preview":
self._handle_tts_preview() self._handle_tts_preview()
elif path == "/api/bot/settings":
self._handle_bot_settings_post()
else: else:
self._send(404, b"not found", "text/plain; charset=utf-8") 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) self._send_json({"ok": False, "error": "invalid JSON"}, 400)
return return
dash.bot.report(data) dash.bot.report(data)
# Hand the bot any queued commands + the current listen filters in the # Hand the bot any queued commands + the current listen filters +
# same round trip so it does not have to poll extra endpoints. # 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(), 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: def _handle_bot_select(self) -> None:
"""UI picked a server/voice channel → queue a join (or leave) command.""" """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})") monitor.log("info", f"청취 화이트/블랙리스트 업데이트 (guild={guild_id})")
self._send_json({"ok": True, "guildId": guild_id, "lists": saved}) 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: def _handle_tts_settings_get(self) -> None:
"""Return the live TTS controls (speed/word_gap/sentence_gap/pitch) """Return the live TTS controls (speed/word_gap/sentence_gap/pitch)
and their slider ranges so the page can render the panel.""" and their slider ranges so the page can render the panel."""
@@ -748,7 +767,10 @@ PAGE = r"""<!DOCTYPE html>
.sttbox{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 16px;margin:0 0 16px} .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{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{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} .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}
@@ -877,20 +899,9 @@ PAGE = r"""<!DOCTYPE html>
<span class="botinfo" id="botinfo"><span class="dot off"></span>봇: 연결 안 됨</span> <span class="botinfo" id="botinfo"><span class="dot off"></span>봇: 연결 안 됨</span>
<label>서버 <select id="guildSel"><option value="">없음</option></select></label> <label>서버 <select id="guildSel"><option value="">없음</option></select></label>
<label>음성채널 <select id="vcSel"><option value="">없음</option></select></label> <label>음성채널 <select id="vcSel"><option value="">없음</option></select></label>
<button id="wlBtn" class="btn hbtn">화이트리스트</button> <button id="listsBtn" class="btn hbtn">👤 유저 등록/관리</button>
<button id="blBtn" class="btn hbtn">블랙리스트</button>
<span class="parts" id="parts"></span> <span class="parts" id="parts"></span>
</section> </section>
<section class="sttbox" id="sttbox" style="display:none">
<h2>🎤 음성 인식(STT) 테스트 · GPU</h2>
<div class="sttrow">
<button id="recbtn" class="btn">🎤 녹음 시작</button>
<label class="btn" for="fileinp">📁 오디오 파일 올리기</label>
<input id="fileinp" type="file" accept="audio/*" hidden>
<span id="ststat" class="sttstat">녹음하거나 오디오 파일을 올리면 GPU로 인식합니다.</span>
</div>
<div id="sttres" class="sttres"></div>
</section>
<section class="sttbox" id="ttsbox" style="display:none"> <section class="sttbox" id="ttsbox" style="display:none">
<h2 id="ttsToggle" class="collap"><span id="ttsCaret">▸</span> 🔊 봇 목소리(TTS) 조절 · MeloTTS</h2> <h2 id="ttsToggle" class="collap"><span id="ttsCaret">▸</span> 🔊 봇 목소리(TTS) 조절 · MeloTTS</h2>
<div id="ttsBody" style="display:none"> <div id="ttsBody" style="display:none">
@@ -919,6 +930,20 @@ PAGE = r"""<!DOCTYPE html>
<audio id="tAudio" controls style="display:none;margin-top:10px;width:100%"></audio> <audio id="tAudio" controls style="display:none;margin-top:10px;width:100%"></audio>
</div> </div>
</section> </section>
<section class="sttbox" id="botcfgbox">
<h2 id="botcfgToggle" class="collap"><span id="botcfgCaret">▸</span> ⚙️ 봇 관련 설정</h2>
<div id="botcfgBody" style="display:none">
<label class="cfgrow"><input type="checkbox" id="cfgBargeIn" checked>
<span>유저 목소리 들을 때 봇이 말하던 것 중지 <b class="cfghint">(기본: 켜짐)</b></span></label>
</div>
</section>
<section class="sttbox" id="logcfgbox">
<h2 id="logcfgToggle" class="collap"><span id="logcfgCaret">▸</span> 🧹 로그 관련 설정</h2>
<div id="logcfgBody" style="display:none">
<label class="cfgrow"><input type="checkbox" id="cfgHideNoise" checked>
<span>잡음 로그 표시 안 함 <b class="cfghint">(들음: (빈 결과) 또는 답변: [잡음]) · 기본: 켜짐</b></span></label>
</div>
</section>
<div class="demobar" id="demobar" style="display:none"></div> <div class="demobar" id="demobar" style="display:none"></div>
<div class="comp" id="comp"></div> <div class="comp" id="comp"></div>
<div class="tfilter" id="tfilter"> <div class="tfilter" id="tfilter">
@@ -1005,8 +1030,6 @@ function renderStatus(s){
const comps = s.components || {}; const comps = s.components || {};
// Demo banner: if the ears/brain/mouth are still mock, everything below is // Demo banner: if the ears/brain/mouth are still mock, everything below is
// replayed sample data, not a real conversation. Say so loudly. // 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'; const ttsReal = comps.tts && comps.tts!=='mock' && comps.tts!=='none';
$('ttsbox').style.display = ttsReal ? 'block' : 'none'; $('ttsbox').style.display = ttsReal ? 'block' : 'none';
if(ttsReal) initTts(); if(ttsReal) initTts();
@@ -1086,7 +1109,11 @@ function turnFilter(){
guild:$('tfGuild').value.trim().toLowerCase(), channel:$('tfChannel').value.trim().toLowerCase(), guild:$('tfGuild').value.trim().toLowerCase(), channel:$('tfChannel').value.trim().toLowerCase(),
text:$('tfText').value.trim().toLowerCase() }; text:$('tfText').value.trim().toLowerCase() };
} }
function isNoiseTurn(t){
return (t.heard==='(빈 결과)') || (t.reply==='[잡음]');
}
function turnMatches(t, f){ 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.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.user && !((t.speaker||'').toLowerCase().includes(f.user))) return false;
if(f.guild && !((t.guild||'').toLowerCase().includes(f.guild))) 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; const f=turnFilter(); let shown=0;
for(const [id,t] of turns){ const el=$('turn-'+id); if(!el) continue; 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 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+' 대화') : ''; $('tfCount').textContent = turns.size ? (active ? shown+' / '+turns.size+' 대화' : turns.size+' 대화') : '';
} }
@@ -1124,6 +1151,7 @@ function renderLogs(){
+ '</div>' + '</div>'
).join(''); ).join('');
$('logCount').textContent = shown.length + (shown.length!==logEvents.length ? ' / '+logEvents.length : '') + '줄'; $('logCount').textContent = shown.length + (shown.length!==logEvents.length ? ' / '+logEvents.length : '') + '줄';
if(typeof syncDockPad==='function') syncDockPad();
} }
function addEvent(e){ function addEvent(e){
if(e.id==null){ e.id = 'c'+Date.now()+Math.random(); } 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();} } 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=[]; let hideNoise = localStorage.getItem('wsai_hideNoise') !== '0'; // 기본: 켜짐
async function sendBlob(blob){ function wireCollapse(toggleId, bodyId, caretId){
$('ststat').textContent='인식 중… (GPU)'; $(toggleId).onclick = ()=>{
$('sttres').innerHTML=''; const b=$(bodyId), open=b.style.display==='none';
try{ b.style.display = open ? 'block' : 'none';
const r=await fetch('/api/stt',{method:'POST', $(caretId).textContent = open ? '▾' : '▸';
headers:{'Content-Type':blob.type||'application/octet-stream'},body:blob}); };
const j=await r.json();
if(j.ok){
$('sttres').innerHTML='<div class="txt">'+esc(j.text||'(빈 결과)')+'</div>'
+'<div class="meta">인식 '+j.ms+' ms · '+esc(j.device)+'</div>';
$('ststat').textContent='완료. 다시 녹음하거나 파일을 올릴 수 있습니다.';
}else{
$('sttres').innerHTML='<div class="meta" style="color:var(--err)">오류: '+esc(j.error)+'</div>';
$('ststat').textContent='실패.';
}
}catch(e){ $('ststat').textContent='요청 실패: '+e; }
} }
(function(){ (function initSettingsPanels(){
const fi=$('fileinp'); if(fi) fi.onchange=()=>{ if(fi.files[0]) sendBlob(fi.files[0]); }; wireCollapse('botcfgToggle','botcfgBody','botcfgCaret');
const rb=$('recbtn'); if(!rb) return; wireCollapse('logcfgToggle','logcfgBody','logcfgCaret');
rb.onclick=async()=>{ // 봇 설정: 바지-인(유저가 말하면 봇 TTS 중지) — 서버에 저장, 봇이 읽어감.
if(mediaRec && mediaRec.state==='recording'){ mediaRec.stop(); return; } $('cfgBargeIn').onchange = async ()=>{
if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){ try{ await fetch('/api/bot/settings',{method:'POST',headers:{'Content-Type':'application/json'},
$('ststat').textContent='이 주소(원격 http)에서는 브라우저 마이크가 막혀 있습니다. 파일 업로드를 사용하세요.'; body:JSON.stringify({bargeIn:$('cfgBargeIn').checked})});
return; toast('봇 설정 저장됨'); }catch(e){}
} };
try{ fetch('/api/bot/settings').then(r=>r.json()).then(j=>{
const stream=await navigator.mediaDevices.getUserMedia({audio:true}); if(j&&j.ok&&j.settings) $('cfgBargeIn').checked = j.settings.bargeIn !== false;
chunks=[]; mediaRec=new MediaRecorder(stream); }).catch(()=>{});
mediaRec.ondataavailable=(e)=>{ if(e.data.size) chunks.push(e.data); }; // 로그 설정: 잡음 로그 표시 안 함 — 클라이언트 필터(로컬 저장).
mediaRec.onstop=()=>{ $('cfgHideNoise').checked = hideNoise;
stream.getTracks().forEach(t=>t.stop()); $('cfgHideNoise').onchange = ()=>{
rb.textContent='🎤 녹음 시작'; rb.classList.remove('rec'); hideNoise = $('cfgHideNoise').checked;
sendBlob(new Blob(chunks,{type:mediaRec.mimeType||'audio/webm'})); localStorage.setItem('wsai_hideNoise', hideNoise ? '1':'0');
}; applyTurnFilter();
mediaRec.start();
rb.textContent='⏹ 녹음 중지'; rb.classList.add('rec');
$('ststat').textContent='녹음 중… 말한 뒤 중지를 누르세요.';
}catch(e){ $('ststat').textContent='마이크 접근 실패: '+e; }
}; };
})(); })();
@@ -1339,10 +1353,18 @@ async function openPrompt(){
$('promptBtn').onclick = 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 = ()=>{ $('logToggle').onclick = ()=>{
const d=$('logdock'); d.classList.toggle('collapsed'); const d=$('logdock'); d.classList.toggle('collapsed');
$('logToggle').textContent = (d.classList.contains('collapsed')?'▸':'▾') + ' 이벤트 / 오류 로그'; $('logToggle').textContent = (d.classList.contains('collapsed')?'▸':'▾') + ' 이벤트 / 오류 로그';
syncDockPad();
}; };
window.addEventListener('resize', syncDockPad);
$('logSearch').oninput = renderLogs; $('logSearch').oninput = renderLogs;
$('logLevel').onchange = renderLogs; $('logLevel').onchange = renderLogs;
$('logClear').onclick = async ()=>{ $('logClear').onclick = async ()=>{
@@ -1457,7 +1479,7 @@ async function openLists(){
}; };
renderResults(); renderChips(); 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; } }); ['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(); }; $('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); pollBot(); setInterval(pollBot, 2500);
connect(); connect();
syncDockPad();
// Refresh uptime label every second from the last known status. // 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); setInterval(()=>{ if(statusData){ statusData.uptime_s=(statusData.uptime_s||0)+1; $('s-up').textContent=fmtUptime(statusData.uptime_s);} }, 1000);
</script> </script>