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:
@@ -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"""<!DOCTYPE html>
|
||||
.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"""<!DOCTYPE html>
|
||||
<input id="tfChannel" placeholder="채널">
|
||||
<input id="tfText" placeholder="내용(들음/답변)">
|
||||
<button id="tfClear" class="btn hbtn">초기화</button>
|
||||
<button id="turnsClear" class="btn hbtn">🗑 전체 삭제</button>
|
||||
<span id="tfCount" class="tf-count"></span>
|
||||
</div>
|
||||
<div id="turns"></div>
|
||||
@@ -1298,7 +1321,8 @@ function turnEl(t){
|
||||
+'<span class="badge">#'+t.id+' · '+esc(t.source||'voice')+'</span>'
|
||||
+(t.speaker?'<span class="badge">🗣 '+esc(t.speaker)+'</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="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>'
|
||||
@@ -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){}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user