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: