feat(dashboard): 4-way roster (user/bot/voice/role), persistence, TTS all-emotions, layout
- 유저 등록/관리 search now has 4 types: 유저(non-bot members), 봇(bot members), 통화방(current voice-channel members, bot flag enriched), 역할(roles — expandable to the members under each role; register the role OR a member under it). - Persist settings to a JSON state store (WSAI_STATE_FILE, default ~/.config/wsai/state.json) so they survive a service/container restart: per-guild listen lists + bargeIn (BotControl), TTS base/overrides and the chosen STT/LLM models (Dashboard). Applied on voice-server startup before warm. - TTS 감정 선택에 최상단 "전체변경 (모든 감정)" 추가: 적용하면 base를 그 값으로 세팅하고 모든 per-emotion override를 지워 전 감정이 한 번에 그 값으로 말합니다. - Move the component status bar (눈/시각/귀/LLM/입) above the bot bar. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -526,6 +526,20 @@ class Dashboard:
|
||||
"pitch": float(getattr(t, "pitch", 0.0)),
|
||||
}
|
||||
|
||||
def _persist_tts(self) -> None:
|
||||
from . import state_store
|
||||
state_store.patch("tts", {
|
||||
"base": self._tts_base(),
|
||||
"overrides": dict(getattr(self.tts, "emotion_overrides", {}) or {}),
|
||||
})
|
||||
|
||||
def _persist_models(self) -> None:
|
||||
from . import state_store
|
||||
state_store.patch("models", {
|
||||
"stt": getattr(self.stt, "model", None),
|
||||
"llm": getattr(self.brain, "model", None),
|
||||
})
|
||||
|
||||
def tts_settings(self) -> dict:
|
||||
"""Base (공통) controls + the per-emotion overrides + the emotion list
|
||||
(canonical key + Korean label) so the dashboard can offer per-emotion
|
||||
@@ -541,11 +555,20 @@ class Dashboard:
|
||||
}
|
||||
|
||||
def set_tts_settings(self, data: dict) -> dict:
|
||||
"""Apply controls. Without ``emotion`` (or emotion == "base"), set the
|
||||
base/공통 values on the live TTS instance. With a specific emotion, store
|
||||
(or, if ``reset``, clear) that emotion's override."""
|
||||
"""Apply controls. ``emotion`` selects the target:
|
||||
- "all" → set base to the values AND clear every per-emotion override,
|
||||
so ALL emotions speak with these values (전체변경).
|
||||
- "" / "base" → set the base/공통 values on the live TTS instance.
|
||||
- a specific emotion → store (or, if ``reset``, clear) its override."""
|
||||
emotion = data.get("emotion")
|
||||
if not emotion or emotion == "base":
|
||||
if emotion == "all":
|
||||
for key, (lo, hi, _step) in self._TTS_RANGES.items():
|
||||
v = data.get(key)
|
||||
if v is not None:
|
||||
setattr(self.tts, key, float(max(lo, min(hi, float(v)))))
|
||||
if getattr(self.tts, "emotion_overrides", None):
|
||||
self.tts.emotion_overrides.clear() # all emotions inherit the new base
|
||||
elif not emotion or emotion == "base":
|
||||
for key, (lo, hi, _step) in self._TTS_RANGES.items():
|
||||
v = data.get(key)
|
||||
if v is not None:
|
||||
@@ -558,6 +581,7 @@ class Dashboard:
|
||||
store.pop(emotion, None)
|
||||
else:
|
||||
store[emotion] = self._tts_overrides(data)
|
||||
self._persist_tts()
|
||||
return self.tts_settings()
|
||||
|
||||
def _tts_overrides(self, data: dict) -> dict:
|
||||
@@ -627,6 +651,7 @@ class Dashboard:
|
||||
# Reload+warm in the background; don't block the HTTP response.
|
||||
asyncio.run_coroutine_threadsafe(self._warm_stt_bg(), self._loop)
|
||||
self.monitor.log("info", f"STT 모델 전환 시작: {model} (로딩 중…)", cat="MODEL")
|
||||
self._persist_models()
|
||||
return {**self.models_settings()["stt"], "changed": changed}
|
||||
|
||||
async def _warm_stt_bg(self) -> None:
|
||||
@@ -648,6 +673,7 @@ class Dashboard:
|
||||
self.brain.model = model
|
||||
if changed:
|
||||
self.monitor.log("info", f"LLM 모델 변경: {model} (다음 답변부터 적용)", cat="MODEL")
|
||||
self._persist_models()
|
||||
return {**self.models_settings()["llm"], "changed": changed}
|
||||
|
||||
@staticmethod
|
||||
@@ -952,6 +978,10 @@ PAGE = r"""<!DOCTYPE html>
|
||||
.lst-item:last-child{border-bottom:none}
|
||||
.lst-item .nm{flex:1}
|
||||
.lst-item .rl{color:var(--muted);font-size:11px}
|
||||
.role-h{cursor:pointer;user-select:none}
|
||||
.role-h .rcaret{display:inline-block;width:11px;color:var(--muted);font-weight:400}
|
||||
.role-members{background:#0c141d;border-top:1px solid #16202b}
|
||||
.role-members .lst-item{padding-left:26px}
|
||||
.mini{padding:3px 8px;font-size:11.5px;border-radius:7px;cursor:pointer;border:1px solid var(--line);background:#173042;color:var(--fg)}
|
||||
.mini.w{border-color:#1f5236;color:#9ff0bd}
|
||||
.mini.b{border-color:#5c2530;color:#ffb3bb}
|
||||
@@ -1030,6 +1060,7 @@ PAGE = r"""<!DOCTYPE html>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div class="comp" id="comp"></div>
|
||||
<section class="botbar" id="botbar">
|
||||
<span class="botinfo" id="botinfo"><span class="dot off"></span>봇: 연결 안 됨</span>
|
||||
<label>서버 <select id="guildSel"><option value="">없음</option></select></label>
|
||||
@@ -1101,7 +1132,6 @@ PAGE = r"""<!DOCTYPE html>
|
||||
</div>
|
||||
</section>
|
||||
<div class="demobar" id="demobar" style="display:none"></div>
|
||||
<div class="comp" id="comp"></div>
|
||||
<div class="tfilter" id="tfilter">
|
||||
<span class="tf-label">대화 로그 검색</span>
|
||||
<select id="tfTime">
|
||||
@@ -1493,9 +1523,9 @@ function ttsSet(s){
|
||||
// (base itself when 기본 or when the emotion has no override).
|
||||
function ttsValuesFor(key){
|
||||
const base = Object.assign({}, TTS_DEFAULT, ttsState.base);
|
||||
if(key && key!=='base' && ttsState.overrides && ttsState.overrides[key])
|
||||
if(key && key!=='base' && key!=='all' && ttsState.overrides && ttsState.overrides[key])
|
||||
return Object.assign({}, base, ttsState.overrides[key]);
|
||||
return base;
|
||||
return base; // 'all' and 'base' both edit the base values
|
||||
}
|
||||
function ttsApplyState(j){
|
||||
if(!j || !j.ok) return;
|
||||
@@ -1503,6 +1533,8 @@ function ttsApplyState(j){
|
||||
// Refresh dropdown labels to mark which emotions are customised.
|
||||
const sel=$('tEmotion'); const cur=sel.value||'base';
|
||||
sel.innerHTML='';
|
||||
const allOpt=document.createElement('option'); // 최상단 전체변경
|
||||
allOpt.value='all'; allOpt.textContent='🌐 전체변경 (모든 감정)'; sel.appendChild(allOpt);
|
||||
ttsState.emotions.forEach(e=>{
|
||||
const o=document.createElement('option'); o.value=e.key;
|
||||
const custom = e.key!=='base' && ttsState.overrides[e.key];
|
||||
@@ -1520,9 +1552,11 @@ async function initTts(){
|
||||
$('ttsCaret').textContent = open ? '▾' : '▸';
|
||||
};
|
||||
['tSpeed','tWord','tSent','tPitch'].forEach(id => $(id).addEventListener('input', ttsLabels));
|
||||
$('tEmotion').onchange = ()=>{ ttsSet(ttsValuesFor($('tEmotion').value));
|
||||
$('tStat').textContent = $('tEmotion').value==='base'
|
||||
? '기본(공통) 값을 조절 중입니다.' : '"'+$('tEmotion').selectedOptions[0].textContent.replace(' ●','')+'" 감정만 조절 중입니다.'; };
|
||||
$('tEmotion').onchange = ()=>{ const k=$('tEmotion').value; ttsSet(ttsValuesFor(k));
|
||||
$('tStat').textContent = k==='all'
|
||||
? '모든 감정을 한 번에 조절 중입니다 (적용 시 전체 반영).'
|
||||
: (k==='base' ? '기본(공통) 값을 조절 중입니다.'
|
||||
: '"'+$('tEmotion').selectedOptions[0].textContent.replace(' ●','')+'" 감정만 조절 중입니다.'); };
|
||||
$('tPreview').onclick = async ()=>{
|
||||
const text=($('tText').value||'').trim();
|
||||
if(!text){ $('tStat').textContent='미리들을 문장을 입력하세요.'; return; }
|
||||
@@ -1542,13 +1576,13 @@ async function initTts(){
|
||||
const r=await fetch('/api/tts/settings',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(Object.assign({emotion:key}, ttsVals()))});
|
||||
const j=await r.json();
|
||||
if(j.ok){ ttsApplyState(j); toast((key==='base'?'기본(공통)':'해당 감정')+' 적용됨 · 다음 답변부터 반영'); }
|
||||
if(j.ok){ ttsApplyState(j); toast((key==='all'?'모든 감정':(key==='base'?'기본(공통)':'해당 감정'))+' 적용됨 · 다음 답변부터 반영'); }
|
||||
else { $('tStat').textContent='적용 실패: '+(j.error||''); }
|
||||
}catch(e){ $('tStat').textContent='적용 오류: '+e; }
|
||||
};
|
||||
$('tReset').onclick = async ()=>{
|
||||
const key=$('tEmotion').value||'base';
|
||||
if(key==='base'){ ttsSet(TTS_DEFAULT);
|
||||
if(key==='base' || key==='all'){ ttsSet(TTS_DEFAULT);
|
||||
$('tStat').textContent='기본값으로 세팅됨 (적용을 눌러 반영).'; return; }
|
||||
try{ // clear this emotion's override -> it inherits 기본(공통) again
|
||||
const r=await fetch('/api/tts/settings',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
@@ -1717,8 +1751,10 @@ async function openLists(){
|
||||
catch(e){ lists = {whitelistUsers:[],blacklistUsers:[],whitelistRoles:[],blacklistRoles:[]}; }
|
||||
openModal('청취 화이트/블랙리스트', '<button class="btn primary" id="lstSave">저장</button>');
|
||||
$('modalBody').innerHTML =
|
||||
'<p class="modal-note">화이트리스트에 넣으면 그 대상만 청취(비어있으면 전체 청취), 블랙리스트는 제외됩니다. 유저/역할별로 추가할 수 있어요.</p>'
|
||||
+'<div class="lst-row"><select id="lstType"><option value="user">유저</option><option value="role">역할</option></select>'
|
||||
'<p class="modal-note">화이트리스트에 넣으면 그 대상만 청취(비어있으면 전체 청취), 블랙리스트는 제외됩니다. 유저·봇·통화방·역할별로 추가할 수 있어요.</p>'
|
||||
+'<div class="lst-row"><select id="lstType">'
|
||||
+'<option value="user">유저</option><option value="bot">봇</option>'
|
||||
+'<option value="voice">통화방</option><option value="role">역할</option></select>'
|
||||
+'<input id="lstSearch" class="lst-search" placeholder="이름으로 검색"></div>'
|
||||
+'<div class="lst-results" id="lstResults"></div>'
|
||||
+'<div class="lst-h">화이트리스트 (그 대상만 청취)</div><div class="chips" id="chipsW"></div>'
|
||||
@@ -1726,15 +1762,39 @@ async function openLists(){
|
||||
const has=(arr,id)=>(arr||[]).some(x=>x.id===id);
|
||||
function add(kind,item){ const k=LKEY[kind]; if(!has(lists[k],item.id)) lists[k].push(item); renderChips(); }
|
||||
function rm(k,id){ lists[k]=(lists[k]||[]).filter(x=>x.id!==id); renderChips(); }
|
||||
const NO_RES='<div class="lst-item"><span class="rl">결과 없음 · 봇이 아는 멤버/역할만 검색됩니다</span></div>';
|
||||
function memberRow(m){
|
||||
return '<div class="lst-item"><span class="nm">'+esc(m.name)+(m.bot?' <span class="rl">(봇)</span>':'')+'</span>'
|
||||
+'<button class="mini w" data-k="wu" data-id="'+esc(m.id)+'" data-nm="'+esc(m.name)+'">+화이트</button>'
|
||||
+'<button class="mini b" data-k="bu" data-id="'+esc(m.id)+'" data-nm="'+esc(m.name)+'">+블랙</button></div>';
|
||||
}
|
||||
function renderResults(){
|
||||
const type=$('lstType').value, q=$('lstSearch').value.trim().toLowerCase();
|
||||
const src = type==='user' ? (g.members||[]) : (g.roles||[]);
|
||||
const rows = src.filter(x=>!q || (x.name||'').toLowerCase().includes(q)).slice(0,100);
|
||||
$('lstResults').innerHTML = rows.length ? rows.map(x=>
|
||||
'<div class="lst-item"><span class="nm">'+esc(x.name)+(x.bot?' <span class="rl">(봇)</span>':'')+'</span>'
|
||||
+'<button class="mini w" data-k="'+(type==='user'?'wu':'wr')+'" data-id="'+esc(x.id)+'" data-nm="'+esc(x.name)+'">+화이트</button>'
|
||||
+'<button class="mini b" data-k="'+(type==='user'?'bu':'br')+'" data-id="'+esc(x.id)+'" data-nm="'+esc(x.name)+'">+블랙</button></div>'
|
||||
).join('') : '<div class="lst-item"><span class="rl">결과 없음 · 봇이 아는 멤버/역할만 검색됩니다</span></div>';
|
||||
const members=(g.members||[]); const byId={}; members.forEach(m=>byId[m.id]=m);
|
||||
const match=(n)=>!q||((n||'').toLowerCase().includes(q));
|
||||
let html='';
|
||||
if(type==='user' || type==='bot'){
|
||||
const rows=members.filter(m=>(type==='bot'?m.bot:!m.bot)&&match(m.name)).slice(0,300);
|
||||
html = rows.length ? rows.map(memberRow).join('') : NO_RES;
|
||||
} else if(type==='voice'){
|
||||
const vm=((botState&&botState.members)||[]).map(m=>({id:m.id,name:m.name,bot:!!(byId[m.id]&&byId[m.id].bot)}));
|
||||
const rows=vm.filter(m=>match(m.name)).slice(0,300);
|
||||
html = rows.length ? rows.map(memberRow).join('')
|
||||
: '<div class="lst-item"><span class="rl">봇이 통화방에 없거나 참여자가 없습니다</span></div>';
|
||||
} else { // role: 역할 등록 + 펼치면 역할 아래 유저(봇 포함) 등록
|
||||
const roles=(g.roles||[]).filter(r=>match(r.name)).slice(0,300);
|
||||
html = roles.length ? roles.map(r=>{
|
||||
const rid=esc(r.id), rmem=members.filter(m=>(m.roleIds||[]).includes(r.id));
|
||||
return '<div class="lst-role">'
|
||||
+'<div class="lst-item"><span class="nm role-h" data-rid="'+rid+'"><b class="rcaret">▸</b> '+esc(r.name)+' <span class="rl">('+rmem.length+'명)</span></span>'
|
||||
+'<button class="mini w" data-k="wr" data-id="'+rid+'" data-nm="'+esc(r.name)+'">+화이트</button>'
|
||||
+'<button class="mini b" data-k="br" data-id="'+rid+'" data-nm="'+esc(r.name)+'">+블랙</button></div>'
|
||||
+'<div class="role-members" data-rid="'+rid+'" style="display:none">'
|
||||
+(rmem.length? rmem.map(memberRow).join('') : '<div class="lst-item"><span class="rl">이 역할의 멤버 없음</span></div>')
|
||||
+'</div></div>';
|
||||
}).join('') : NO_RES;
|
||||
}
|
||||
$('lstResults').innerHTML = html;
|
||||
}
|
||||
const chip=(k,cls,x)=>'<span class="chip '+cls+'">'+esc(x.name)+' <button data-k="'+k+'" data-id="'+esc(x.id)+'">✕</button></span>';
|
||||
const empty='<span class="rl" style="color:var(--muted);font-size:12px">비어있음</span>';
|
||||
@@ -1743,7 +1803,13 @@ async function openLists(){
|
||||
$('chipsB').innerHTML = [...lists.blacklistUsers.map(x=>chip('blacklistUsers','b',x)),...lists.blacklistRoles.map(x=>chip('blacklistRoles','b',x))].join('') || empty;
|
||||
}
|
||||
$('lstType').onchange=renderResults; $('lstSearch').oninput=renderResults;
|
||||
$('lstResults').onclick=(e)=>{ const b=e.target.closest('.mini'); if(!b)return; add(b.dataset.k,{id:b.dataset.id,name:b.dataset.nm}); };
|
||||
$('lstResults').onclick=(e)=>{
|
||||
const rh=e.target.closest('.role-h');
|
||||
if(rh){ const box=$('lstResults').querySelector('.role-members[data-rid="'+rh.dataset.rid+'"]');
|
||||
if(box){ const open=box.style.display==='none'; box.style.display=open?'block':'none';
|
||||
const c=rh.querySelector('.rcaret'); if(c) c.textContent=open?'▾':'▸'; } return; }
|
||||
const b=e.target.closest('.mini'); if(!b)return; add(b.dataset.k,{id:b.dataset.id,name:b.dataset.nm});
|
||||
};
|
||||
$('chipsW').onclick=$('chipsB').onclick=(e)=>{ const b=e.target.closest('button'); if(!b)return; rm(b.dataset.k,b.dataset.id); };
|
||||
$('lstSave').onclick=async()=>{
|
||||
try{ await fetch('/api/bot/lists',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({guildId,lists})}); toast('청취 필터 저장됨 · 봇에 곧 반영'); closeModal(); }
|
||||
|
||||
Reference in New Issue
Block a user