diff --git a/wsai/dashboard.py b/wsai/dashboard.py index 0994598..80066bb 100644 --- a/wsai/dashboard.py +++ b/wsai/dashboard.py @@ -119,6 +119,11 @@ def _make_handler(dash: "Dashboard"): elif path == "/api/logs/clear": monitor.clear_events() self._send(200, json.dumps({"ok": True}).encode(), "application/json; charset=utf-8") + elif path == "/api/turns/clear": + monitor.clear_turns() + self._send(200, json.dumps({"ok": True}).encode(), "application/json; charset=utf-8") + elif path == "/api/turns/delete": + self._handle_turn_delete() elif path == "/api/logs/delete": self._handle_log_mutate("delete") elif path == "/api/logs/edit": @@ -401,6 +406,20 @@ def _make_handler(dash: "Dashboard"): return self._send(200, wav, "audio/wav") + def _handle_turn_delete(self) -> None: + """Delete one conversation turn by id.""" + raw = self._read_body() + try: + data = json.loads(raw.decode("utf-8")) if raw else {} + turn_id = int(data.get("id")) + except (ValueError, TypeError, AttributeError): + self._send(400, json.dumps({"ok": False, "error": "id required"}).encode(), + "application/json; charset=utf-8") + return + ok = monitor.delete_turn(turn_id) + self._send(200 if ok else 404, + json.dumps({"ok": ok}).encode(), "application/json; charset=utf-8") + def _handle_log_mutate(self, action: str) -> None: """Per-line log delete/edit by event id.""" raw = self._read_body() @@ -886,8 +905,11 @@ PAGE = r""" .comp{display:flex;gap:8px;flex-wrap:wrap;margin:2px 0 18px} .comp .pill{font-size:11.5px} .turn{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 16px;margin:12px 0; - animation:rise .25s ease} + animation:rise .25s ease;position:relative} @keyframes rise{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}} + .tdel{margin-left:auto;background:none;border:none;color:var(--muted);cursor:pointer;font-size:14px; + line-height:1;padding:2px 4px;border-radius:6px} + .tdel:hover{color:var(--err);background:#2a1620} .turn.active{border-color:#2b5d86;box-shadow:0 0 0 1px #14324a inset} .turn.error{border-color:#5c2530} .trow{display:flex;align-items:baseline;gap:10px;margin-bottom:8px} @@ -1167,6 +1189,7 @@ PAGE = r""" +
@@ -1298,7 +1321,8 @@ function turnEl(t){ +'#'+t.id+' · '+esc(t.source||'voice')+'' +(t.speaker?'🗣 '+esc(t.speaker)+'':'') +(t.channel?'🔊 '+esc((t.guild?t.guild+' / ':'')+t.channel)+'':'') - +''+fmtTime(t.wall)+'' + +''+fmtTime(t.wall)+'' + +'' +'
들음'+(t.heard?esc(t.heard):'(수신 대기)')+'
' +'
생각'+(t.thought?esc(t.thought):'…')+'
' +'
답변'+(t.reply?esc(t.reply):'…생각 중')+'
' @@ -1318,6 +1342,12 @@ function upsertTurn(t){ else { cont.prepend(fresh); } applyTurnFilter(); } +function removeTurn(id){ + turns.delete(id); + const el=$('turn-'+id); if(el) el.remove(); + if(turns.size===0) $('empty').style.display='block'; + applyTurnFilter(); +} // --- 대화 로그 검색: 시간·유저·서버·채널·내용 필터 ------------------------ # function turnFilter(){ @@ -1409,6 +1439,8 @@ function connect(){ if(ev.type==='snapshot') applySnapshot(ev.snapshot); else if(ev.type==='status') renderStatus(ev.status); else if(ev.type==='turn') upsertTurn(ev.turn); + else if(ev.type==='turn_deleted') removeTurn(ev.id); + else if(ev.type==='turns_cleared') { turns.clear(); $('turns').innerHTML=''; $('empty').style.display='block'; applyTurnFilter(); } else if(ev.type==='log') { addEvent(ev); if(statusData){ statusData.errors_total=(statusData.errors_total||0)+(ev.level==='error'?1:0); $('s-errors').textContent=statusData.errors_total; } } else if(ev.type==='logs_cleared') { logEvents=[]; renderLogs(); } else if(ev.type==='log_deleted') { logEvents=logEvents.filter(e=>e.id!==ev.id); renderLogs(); } @@ -1842,6 +1874,18 @@ $('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(); }; +// 대화 로그: 개별 ✕ 삭제 + 전체 삭제. +$('turns').addEventListener('click', async (e)=>{ + const b=e.target.closest('.tdel'); if(!b) return; + const id=Number(b.dataset.id); removeTurn(id); // optimistic + try{ await fetch('/api/turns/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})}); }catch(_){} +}); +$('turnsClear').onclick=async ()=>{ + if(!turns.size){ toast('삭제할 대화가 없습니다'); return; } + if(!confirm('대화 로그를 모두 삭제할까요?')) return; + try{ await fetch('/api/turns/clear',{method:'POST'}); toast('대화 로그를 모두 삭제했습니다'); } + catch(e){ toast('삭제 실패: '+e); } +}; async function pollBot(){ try{ renderBot(await (await fetch('/api/bot/state')).json()); }catch(e){} diff --git a/wsai/monitor.py b/wsai/monitor.py index eae7ad1..27e4bca 100644 --- a/wsai/monitor.py +++ b/wsai/monitor.py @@ -258,6 +258,26 @@ class Monitor: self._events.clear() self._broadcast({"type": "logs_cleared"}) + def delete_turn(self, turn_id: int) -> bool: + """Remove one conversation turn by id (dashboard per-turn '✕').""" + found = False + with self._lock: + for t in list(self._turns): + if t.id == turn_id: + self._turns.remove(t) + found = True + break + if found: + self._broadcast({"type": "turn_deleted", "id": turn_id}) + return found + + def clear_turns(self) -> None: + """Wipe all conversation turns (dashboard '대화 전체 삭제'). Broadcasts a + reset so every connected page clears its turn list too.""" + with self._lock: + self._turns.clear() + self._broadcast({"type": "turns_cleared"}) + # -- turns ------------------------------------------------------------ # def turn(self, source: str = "voice") -> Turn: with self._lock: