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:
@@ -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"""<!DOCTYPE html>
|
||||
.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"""<!DOCTYPE html>
|
||||
<span class="botinfo" id="botinfo"><span class="dot off"></span>봇: 연결 안 됨</span>
|
||||
<label>서버 <select id="guildSel"><option value="">없음</option></select></label>
|
||||
<label>음성채널 <select id="vcSel"><option value="">없음</option></select></label>
|
||||
<button id="wlBtn" class="btn hbtn">화이트리스트</button>
|
||||
<button id="blBtn" class="btn hbtn">블랙리스트</button>
|
||||
<button id="listsBtn" class="btn hbtn">👤 유저 등록/관리</button>
|
||||
<span class="parts" id="parts"></span>
|
||||
</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">
|
||||
<h2 id="ttsToggle" class="collap"><span id="ttsCaret">▸</span> 🔊 봇 목소리(TTS) 조절 · MeloTTS</h2>
|
||||
<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>
|
||||
</div>
|
||||
</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="comp" id="comp"></div>
|
||||
<div class="tfilter" id="tfilter">
|
||||
@@ -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(){
|
||||
+ '</div>'
|
||||
).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='<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; }
|
||||
// --- 봇 관련 설정 / 로그 관련 설정 (접이식) ------------------------------- #
|
||||
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);
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user