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:
@@ -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}`); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(){
|
|
||||||
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');
|
(function initSettingsPanels(){
|
||||||
$('ststat').textContent='녹음 중… 말한 뒤 중지를 누르세요.';
|
wireCollapse('botcfgToggle','botcfgBody','botcfgCaret');
|
||||||
}catch(e){ $('ststat').textContent='마이크 접근 실패: '+e; }
|
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;
|
$('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>
|
||||||
|
|||||||
Reference in New Issue
Block a user