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:
@@ -37,7 +37,7 @@ def test_monitor_records_turn_with_timed_steps():
|
||||
assert turn["total_ms"] >= 0
|
||||
# step-by-step: every stage is named and timed
|
||||
names = [s["name"] for s in turn["steps"]]
|
||||
assert names == ["화면 맥락", "두뇌(생각)", "응답(TTS/전송)"]
|
||||
assert names == ["화면 맥락", "LLM(생각)", "응답(TTS/전송)"]
|
||||
assert all(s["ok"] is True for s in turn["steps"])
|
||||
assert all(s["ms"] >= 0 for s in turn["steps"])
|
||||
|
||||
@@ -66,7 +66,7 @@ def test_monitor_marks_errors():
|
||||
|
||||
turn = snap["turns"][0]
|
||||
assert turn["status"] == "error"
|
||||
brain_step = next(s for s in turn["steps"] if s["name"] == "두뇌(생각)")
|
||||
brain_step = next(s for s in turn["steps"] if s["name"] == "LLM(생각)")
|
||||
assert brain_step["ok"] is False
|
||||
assert "boom" in brain_step["error"]
|
||||
assert snap["status"]["errors_total"] >= 1
|
||||
|
||||
@@ -100,6 +100,28 @@ def _run_stt_test(host: str, port: int) -> None:
|
||||
dash.stop()
|
||||
|
||||
|
||||
def _apply_persisted_state(stt, tts, brain) -> None:
|
||||
"""Apply dashboard settings saved to the state store (STT/LLM model + TTS
|
||||
controls) onto freshly-built backends, so they survive a restart."""
|
||||
from . import state_store
|
||||
st = state_store.load()
|
||||
models = st.get("models") or {}
|
||||
if models.get("stt") and stt is not None:
|
||||
stt.model = str(models["stt"])
|
||||
if models.get("llm") and brain is not None:
|
||||
brain.model = str(models["llm"])
|
||||
tj = st.get("tts") or {}
|
||||
base = tj.get("base") or {}
|
||||
for k in ("speed", "word_gap", "sentence_gap", "pitch"):
|
||||
if base.get(k) is not None and tts is not None:
|
||||
try:
|
||||
setattr(tts, k, float(base[k]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if tts is not None and isinstance(tj.get("overrides"), dict):
|
||||
tts.emotion_overrides = {k: dict(v) for k, v in tj["overrides"].items() if isinstance(v, dict)}
|
||||
|
||||
|
||||
def _run_voice_server(host: str, port: int) -> None:
|
||||
"""Serve the STT+TTS voice-turn endpoint that the Discord bot (dave/bot.mjs)
|
||||
calls: it POSTs a captured utterance wav and gets back the reply wav to play
|
||||
@@ -129,6 +151,10 @@ def _run_voice_server(host: str, port: int) -> None:
|
||||
monitor = Monitor()
|
||||
stt = WhisperSTT()
|
||||
tts = MeloTTS()
|
||||
# Restore persisted dashboard settings (models + TTS controls) BEFORE warmup
|
||||
# so the worker loads the last-chosen model. Bot lists/toggles are restored
|
||||
# inside BotControl. Applied before dash.warm() so nothing reloads twice.
|
||||
_apply_persisted_state(stt, tts, brain)
|
||||
dash = Dashboard(monitor, host=host, port=port, stt=stt, tts=tts, brain=brain)
|
||||
dash.start()
|
||||
monitor.set_components({"source": "none", "vision": "none", "stt": "whisper",
|
||||
|
||||
@@ -31,10 +31,13 @@ class BotControl:
|
||||
self._cmd_id = 0
|
||||
# Per-guild listen filter. Empty whitelist => listen to everyone;
|
||||
# blacklist always excludes. Users and roles both supported.
|
||||
self._lists: dict[str, dict[str, Any]] = {}
|
||||
# Restored from the persistent state store so it survives a restart.
|
||||
from . import state_store
|
||||
st = state_store.load()
|
||||
self._lists: dict[str, dict[str, Any]] = st.get("lists") or {}
|
||||
# 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}
|
||||
self._settings: dict[str, Any] = {"bargeIn": True, **(st.get("botSettings") or {})}
|
||||
|
||||
# -- bot behaviour settings ------------------------------------------ #
|
||||
def get_settings(self) -> dict[str, Any]:
|
||||
@@ -45,7 +48,10 @@ class BotControl:
|
||||
with self._lock:
|
||||
if "bargeIn" in data:
|
||||
self._settings["bargeIn"] = bool(data["bargeIn"])
|
||||
return dict(self._settings)
|
||||
out = dict(self._settings)
|
||||
from . import state_store
|
||||
state_store.patch("botSettings", out)
|
||||
return out
|
||||
|
||||
# -- whitelist / blacklist (per guild) ------------------------------- #
|
||||
@staticmethod
|
||||
@@ -72,6 +78,9 @@ class BotControl:
|
||||
]
|
||||
with self._lock:
|
||||
self._lists[guild_id] = clean
|
||||
snapshot = {g: dict(v) for g, v in self._lists.items()}
|
||||
from . import state_store
|
||||
state_store.patch("lists", snapshot) # persist per-guild lists across restarts
|
||||
return clean
|
||||
|
||||
# -- bot -> dashboard (state push) ----------------------------------- #
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
61
wsai/state_store.py
Normal file
61
wsai/state_store.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Tiny JSON state store so dashboard/bot settings survive a restart.
|
||||
|
||||
Persists things the user configures on the dashboard — per-guild listen lists,
|
||||
bot behaviour toggles, TTS controls, and the chosen STT/LLM models — to a single
|
||||
JSON file so a service or container restart keeps them.
|
||||
|
||||
Path: ``WSAI_STATE_FILE`` env, else ``~/.config/wsai/state.json``. For a
|
||||
container deployment, mount that path (or point the env at a mounted volume) to
|
||||
keep the file across ``docker restart``.
|
||||
|
||||
Pure stdlib, thread-safe, best-effort: a read/write failure never raises into
|
||||
the caller (the dashboard must keep working even if the disk is unwritable).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger("wsai.state")
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def path() -> str:
|
||||
return os.environ.get("WSAI_STATE_FILE") or os.path.expanduser("~/.config/wsai/state.json")
|
||||
|
||||
|
||||
def load() -> dict[str, Any]:
|
||||
try:
|
||||
with open(path(), encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except Exception as exc: # noqa: BLE001 — corrupt/unreadable state must not crash startup
|
||||
log.warning("state load failed (%s): %s", path(), exc)
|
||||
return {}
|
||||
|
||||
|
||||
def save(state: dict[str, Any]) -> None:
|
||||
p = path()
|
||||
try:
|
||||
with _LOCK:
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
tmp = p + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, p) # atomic
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("state save failed (%s): %s", p, exc)
|
||||
|
||||
|
||||
def patch(key: str, value: Any) -> None:
|
||||
"""Read-modify-write one top-level key."""
|
||||
s = load()
|
||||
s[key] = value
|
||||
save(s)
|
||||
Reference in New Issue
Block a user