feat(dashboard): delete conversation turns (per-turn ✕ + 전체 삭제)

Add a per-turn ✕ button on each 대화 카드 and a "🗑 전체 삭제" button in the turn
filter bar. Monitor gains delete_turn(id)/clear_turns() which broadcast
turn_deleted/turns_cleared; the page removes them live (all connected clients).
Routes: POST /api/turns/delete, POST /api/turns/clear.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-08-27 02:17:16 +09:00
parent 8539acd0f8
commit 5966f6dedb
2 changed files with 66 additions and 2 deletions

View File

@@ -119,6 +119,11 @@ def _make_handler(dash: "Dashboard"):
elif path == "/api/logs/clear": elif path == "/api/logs/clear":
monitor.clear_events() monitor.clear_events()
self._send(200, json.dumps({"ok": True}).encode(), "application/json; charset=utf-8") 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": elif path == "/api/logs/delete":
self._handle_log_mutate("delete") self._handle_log_mutate("delete")
elif path == "/api/logs/edit": elif path == "/api/logs/edit":
@@ -401,6 +406,20 @@ def _make_handler(dash: "Dashboard"):
return return
self._send(200, wav, "audio/wav") 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: def _handle_log_mutate(self, action: str) -> None:
"""Per-line log delete/edit by event id.""" """Per-line log delete/edit by event id."""
raw = self._read_body() raw = self._read_body()
@@ -886,8 +905,11 @@ PAGE = r"""<!DOCTYPE html>
.comp{display:flex;gap:8px;flex-wrap:wrap;margin:2px 0 18px} .comp{display:flex;gap:8px;flex-wrap:wrap;margin:2px 0 18px}
.comp .pill{font-size:11.5px} .comp .pill{font-size:11.5px}
.turn{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 16px;margin:12px 0; .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}} @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.active{border-color:#2b5d86;box-shadow:0 0 0 1px #14324a inset}
.turn.error{border-color:#5c2530} .turn.error{border-color:#5c2530}
.trow{display:flex;align-items:baseline;gap:10px;margin-bottom:8px} .trow{display:flex;align-items:baseline;gap:10px;margin-bottom:8px}
@@ -1167,6 +1189,7 @@ PAGE = r"""<!DOCTYPE html>
<input id="tfChannel" placeholder="채널"> <input id="tfChannel" placeholder="채널">
<input id="tfText" placeholder="내용(들음/답변)"> <input id="tfText" placeholder="내용(들음/답변)">
<button id="tfClear" class="btn hbtn">초기화</button> <button id="tfClear" class="btn hbtn">초기화</button>
<button id="turnsClear" class="btn hbtn">🗑 전체 삭제</button>
<span id="tfCount" class="tf-count"></span> <span id="tfCount" class="tf-count"></span>
</div> </div>
<div id="turns"></div> <div id="turns"></div>
@@ -1298,7 +1321,8 @@ function turnEl(t){
+'<span class="badge">#'+t.id+' · '+esc(t.source||'voice')+'</span>' +'<span class="badge">#'+t.id+' · '+esc(t.source||'voice')+'</span>'
+(t.speaker?'<span class="badge">🗣 '+esc(t.speaker)+'</span>':'') +(t.speaker?'<span class="badge">🗣 '+esc(t.speaker)+'</span>':'')
+(t.channel?'<span class="badge">🔊 '+esc((t.guild?t.guild+' / ':'')+t.channel)+'</span>':'') +(t.channel?'<span class="badge">🔊 '+esc((t.guild?t.guild+' / ':'')+t.channel)+'</span>':'')
+'<span class="time">'+fmtTime(t.wall)+'</span></div>' +'<span class="time">'+fmtTime(t.wall)+'</span>'
+'<button class="tdel" data-id="'+t.id+'" title="이 대화 삭제">✕</button></div>'
+'<div class="line"><span class="tag">들음</span><span class="heard">'+(t.heard?esc(t.heard):'<i style="color:var(--muted)">(수신 대기)</i>')+'</span></div>' +'<div class="line"><span class="tag">들음</span><span class="heard">'+(t.heard?esc(t.heard):'<i style="color:var(--muted)">(수신 대기)</i>')+'</span></div>'
+'<div class="line"><span class="tag">생각</span><span class="thought">'+(t.thought?esc(t.thought):'<i style="color:var(--muted)">…</i>')+'</span></div>' +'<div class="line"><span class="tag">생각</span><span class="thought">'+(t.thought?esc(t.thought):'<i style="color:var(--muted)">…</i>')+'</span></div>'
+'<div class="line"><span class="tag">답변</span><span class="reply">'+(t.reply?esc(t.reply):'<i style="color:var(--muted)">…생각 중</i>')+'</span></div>' +'<div class="line"><span class="tag">답변</span><span class="reply">'+(t.reply?esc(t.reply):'<i style="color:var(--muted)">…생각 중</i>')+'</span></div>'
@@ -1318,6 +1342,12 @@ function upsertTurn(t){
else { cont.prepend(fresh); } else { cont.prepend(fresh); }
applyTurnFilter(); 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(){ function turnFilter(){
@@ -1409,6 +1439,8 @@ function connect(){
if(ev.type==='snapshot') applySnapshot(ev.snapshot); if(ev.type==='snapshot') applySnapshot(ev.snapshot);
else if(ev.type==='status') renderStatus(ev.status); else if(ev.type==='status') renderStatus(ev.status);
else if(ev.type==='turn') upsertTurn(ev.turn); 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==='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==='logs_cleared') { logEvents=[]; renderLogs(); }
else if(ev.type==='log_deleted') { logEvents=logEvents.filter(e=>e.id!==ev.id); 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; } }); ['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(); };
// 대화 로그: 개별 ✕ 삭제 + 전체 삭제.
$('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(){ async function pollBot(){
try{ renderBot(await (await fetch('/api/bot/state')).json()); }catch(e){} try{ renderBot(await (await fetch('/api/bot/state')).json()); }catch(e){}

View File

@@ -258,6 +258,26 @@ class Monitor:
self._events.clear() self._events.clear()
self._broadcast({"type": "logs_cleared"}) 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 ------------------------------------------------------------ # # -- turns ------------------------------------------------------------ #
def turn(self, source: str = "voice") -> Turn: def turn(self, source: str = "voice") -> Turn:
with self._lock: with self._lock: