feat(dashboard): VSCode-style log dock, 3-row turns, log search/edit/delete

- Bottom-docked collapsible terminal log panel (open/close), retains the event
  log from voice-server start (event tail 200→2000).
- Log search box + level filter (전체/오류/경고/정보); per-line 삭제/수정 and
  전체 삭제, backed by new monitor event ids and /api/logs/{clear,delete,edit}.
- Turns now show 들음 / 생각 / 답변 three rows; 생각 surfaces the emotion-tone
  plan the bot chose (and the [잡음] decision), via a new Turn.thought field.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-08-22 11:35:11 +09:00
parent df07224feb
commit 6a2865d899
2 changed files with 194 additions and 14 deletions

View File

@@ -35,6 +35,20 @@ def _speech_text(reply: str) -> str:
return reply return reply
def _thought_summary(reply: str) -> str:
"""A short '생각내용' line: the emotion/tone plan the bot chose for delivery,
derived from the [감정] tags in its reply. This is the AI's decision about
*how* to say the answer, shown between 들음 and 답변."""
import re
from .backends.emotion import match_emotion
tags = re.findall(r"\[([^\[\]]*)\]", reply or "")
emotions = [t.strip() for t in tags if match_emotion(t)]
if emotions:
return "감정 톤: " + "".join(emotions)
return "감정 태그 없음 · 기본 톤으로 답변"
def _make_handler(dash: "Dashboard"): def _make_handler(dash: "Dashboard"):
monitor = dash.monitor monitor = dash.monitor
@@ -73,6 +87,13 @@ def _make_handler(dash: "Dashboard"):
self._handle_voice_turn() self._handle_voice_turn()
elif path == "/api/prompt": elif path == "/api/prompt":
self._handle_prompt_post() self._handle_prompt_post()
elif path == "/api/logs/clear":
monitor.clear_events()
self._send(200, json.dumps({"ok": True}).encode(), "application/json; charset=utf-8")
elif path == "/api/logs/delete":
self._handle_log_mutate("delete")
elif path == "/api/logs/edit":
self._handle_log_mutate("edit")
else: else:
self._send(404, b"not found", "text/plain; charset=utf-8") self._send(404, b"not found", "text/plain; charset=utf-8")
@@ -185,6 +206,23 @@ def _make_handler(dash: "Dashboard"):
}, ensure_ascii=False).encode("utf-8") }, ensure_ascii=False).encode("utf-8")
self._send(200, body, "application/json; charset=utf-8") self._send(200, body, "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()
try:
data = json.loads(raw.decode("utf-8")) if raw else {}
event_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
if action == "delete":
ok = monitor.delete_event(event_id)
else:
ok = monitor.edit_event(event_id, str(data.get("message", "")))
self._send(200 if ok else 404,
json.dumps({"ok": ok}).encode(), "application/json; charset=utf-8")
def _stream_events(self) -> None: def _stream_events(self) -> None:
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8") self.send_header("Content-Type", "text/event-stream; charset=utf-8")
@@ -317,10 +355,12 @@ class Dashboard:
if not heard: if not heard:
# Nothing recognised (silence/noise): mark it as [잡음] and skip # Nothing recognised (silence/noise): mark it as [잡음] and skip
# the brain/TTS so the bot plays nothing back. # the brain/TTS so the bot plays nothing back.
turn.thought("목소리가 아닌 잡음으로 판단 → 응답하지 않음")
turn.replied("[잡음]") turn.replied("[잡음]")
turn.finish() turn.finish()
return {"heard": heard, "reply": "[잡음]", "wav": b""} return {"heard": heard, "reply": "[잡음]", "wav": b""}
reply_text = self._think(heard) reply_text = self._think(heard)
turn.thought(_thought_summary(reply_text))
turn.replied(reply_text) turn.replied(reply_text)
out_path = self._submit(self.tts.synth(_speech_text(reply_text))) out_path = self._submit(self.tts.synth(_speech_text(reply_text)))
with open(out_path, "rb") as f: with open(out_path, "rb") as f:
@@ -472,6 +512,7 @@ PAGE = r"""<!DOCTYPE html>
.line{display:flex;gap:9px;margin:5px 0;align-items:flex-start} .line{display:flex;gap:9px;margin:5px 0;align-items:flex-start}
.tag{flex:0 0 42px;font-size:11px;color:var(--muted);padding-top:2px} .tag{flex:0 0 42px;font-size:11px;color:var(--muted);padding-top:2px}
.heard{color:var(--heard);font-weight:550} .heard{color:var(--heard);font-weight:550}
.thought{color:var(--muted);font-size:13px}
.reply{color:var(--reply);font-weight:550} .reply{color:var(--reply);font-weight:550}
.steps{margin-top:10px;border-top:1px dashed var(--line);padding-top:10px;display:flex;flex-direction:column;gap:6px} .steps{margin-top:10px;border-top:1px dashed var(--line);padding-top:10px;display:flex;flex-direction:column;gap:6px}
.step{display:grid;grid-template-columns:120px 1fr 66px;gap:10px;align-items:center;font-size:12.5px} .step{display:grid;grid-template-columns:120px 1fr 66px;gap:10px;align-items:center;font-size:12.5px}
@@ -525,6 +566,36 @@ PAGE = r"""<!DOCTYPE html>
background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 16px; background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 16px;
font-size:13px;box-shadow:0 10px 30px rgba(0,0,0,.4);opacity:0;transition:opacity .2s} font-size:13px;box-shadow:0 10px 30px rgba(0,0,0,.4);opacity:0;transition:opacity .2s}
.toast.show{opacity:1} .toast.show{opacity:1}
/* Bottom-docked VSCode-style terminal log panel */
main{padding-bottom:46px}
.logdock{position:fixed;left:0;right:0;bottom:0;z-index:15;background:#0a0e13;
border-top:1px solid var(--line);display:flex;flex-direction:column;
max-height:45vh;box-shadow:0 -8px 24px rgba(0,0,0,.35)}
.logbar{display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid var(--line);
background:#0d141c;flex-wrap:wrap}
.logtoggle{background:none;border:none;color:var(--fg);font-size:12.5px;cursor:pointer;font-weight:600;padding:4px 6px}
.logsearch{flex:1;min-width:120px;background:var(--panel2);border:1px solid var(--line);color:var(--fg);
border-radius:8px;padding:5px 9px;font-size:12.5px}
.logsel{background:var(--panel2);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:5px 8px;font-size:12.5px}
.logcount{color:var(--muted);font-size:11.5px}
.logbtn{padding:5px 10px;font-size:12px}
.logbody{overflow:auto;padding:8px 12px;font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;
font-size:12px;line-height:1.65;background:#0a0e13}
.logdock.collapsed .logbody{display:none}
.logdock.collapsed{max-height:none}
.logline{display:flex;gap:8px;align-items:baseline;padding:1px 0;border-bottom:1px solid #10171f}
.logline:hover{background:#0e151d}
.logline .lt{flex:0 0 92px;color:#5f7488}
.logline .lv{flex:0 0 46px;text-transform:uppercase;font-size:10.5px}
.logline.info .lv{color:var(--accent)}
.logline.error .lv{color:var(--err)}
.logline.warn .lv{color:var(--warn)}
.logline .lm{flex:1;color:var(--fg);white-space:pre-wrap;word-break:break-word}
.logline.error .lm{color:#ffb3bb}
.logline .lacts{opacity:0;display:flex;gap:4px}
.logline:hover .lacts{opacity:1}
.lact{background:none;border:none;color:var(--muted);cursor:pointer;font-size:12px;padding:0 3px}
.lact:hover{color:var(--fg)}
</style> </style>
</head> </head>
<body> <body>
@@ -557,11 +628,22 @@ PAGE = r"""<!DOCTYPE html>
<div class="comp" id="comp"></div> <div class="comp" id="comp"></div>
<div id="turns"></div> <div id="turns"></div>
<div id="empty" class="empty">아직 대화가 없습니다. 사용자가 말하면 여기에 단계별로 나타납니다.</div> <div id="empty" class="empty">아직 대화가 없습니다. 사용자가 말하면 여기에 단계별로 나타납니다.</div>
<div class="events">
<h2>이벤트 / 오류 로그</h2>
<div id="events"></div>
</div>
</main> </main>
<div id="logdock" class="logdock">
<div class="logbar">
<button id="logToggle" class="logtoggle">▾ 이벤트 / 오류 로그</button>
<input id="logSearch" class="logsearch" placeholder="로그 검색 (텍스트)">
<select id="logLevel" class="logsel">
<option value="">전체</option>
<option value="error">오류만</option>
<option value="warn">경고만</option>
<option value="info">정보만</option>
</select>
<span id="logCount" class="logcount"></span>
<button id="logClear" class="btn logbtn">로그 삭제</button>
</div>
<div id="logbody" class="logbody"></div>
</div>
<div id="modal" class="modal" style="display:none"> <div id="modal" class="modal" style="display:none">
<div class="modal-card"> <div class="modal-card">
<div class="modal-head"> <div class="modal-head">
@@ -654,6 +736,7 @@ function turnEl(t){
+'<span class="badge">#'+t.id+' · '+esc(t.source||'voice')+'</span>' +'<span class="badge">#'+t.id+' · '+esc(t.source||'voice')+'</span>'
+'<span class="time">'+fmtTime(t.wall)+'</span></div>' +'<span class="time">'+fmtTime(t.wall)+'</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="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>' +'<div class="line"><span class="tag">답변</span><span class="reply">'+(t.reply?esc(t.reply):'<i style="color:var(--muted)">…생각 중</i>')+'</span></div>'
+(t.error?'<div class="line"><span class="tag">오류</span><span style="color:var(--err)">'+esc(t.error)+'</span></div>':'') +(t.error?'<div class="line"><span class="tag">오류</span><span style="color:var(--err)">'+esc(t.error)+'</span></div>':'')
+'<div class="steps">'+steps+'</div>' +'<div class="steps">'+steps+'</div>'
@@ -671,13 +754,34 @@ function upsertTurn(t){
else { cont.prepend(fresh); } else { cont.prepend(fresh); }
} }
// --- Bottom terminal log panel: store all events, render filtered ---------- #
let logEvents = []; // {id, level, message, wall}
function logMatches(e){
const lv = $('logLevel').value;
if(lv && e.level!==lv) return false;
const q = $('logSearch').value.trim().toLowerCase();
if(q && !((e.message||'').toLowerCase().includes(q) || fmtTime(e.wall).includes(q))) return false;
return true;
}
function renderLogs(){
const body = $('logbody');
const shown = logEvents.filter(logMatches);
body.innerHTML = shown.map(e =>
'<div class="logline '+(e.level||'info')+'" data-id="'+e.id+'">'
+ '<span class="lt">'+fmtTime(e.wall)+'</span>'
+ '<span class="lv">'+esc(e.level||'info')+'</span>'
+ '<span class="lm">'+esc(e.message)+'</span>'
+ '<span class="lacts"><button class="lact" data-act="edit" title="수정">✎</button>'
+ '<button class="lact" data-act="del" title="삭제">✕</button></span>'
+ '</div>'
).join('');
$('logCount').textContent = shown.length + (shown.length!==logEvents.length ? ' / '+logEvents.length : '') + '';
}
function addEvent(e){ function addEvent(e){
const box = $('events'); if(e.id==null){ e.id = 'c'+Date.now()+Math.random(); }
const row = document.createElement('div'); logEvents.push(e);
row.className = 'ev ' + (e.level==='error'?'error':''); if(logEvents.length>2000) logEvents = logEvents.slice(-2000);
row.innerHTML = '<span class="et">'+fmtTime(e.wall)+'</span><span>'+esc(e.message)+'</span>'; renderLogs();
box.prepend(row);
while(box.childElementCount>60) box.removeChild(box.lastChild);
} }
function applySnapshot(snap){ function applySnapshot(snap){
@@ -686,8 +790,8 @@ function applySnapshot(snap){
const list = (snap.turns||[]); const list = (snap.turns||[]);
for(const t of list) upsertTurn(t); for(const t of list) upsertTurn(t);
if(list.length===0){ $('empty').style.display='block'; } if(list.length===0){ $('empty').style.display='block'; }
$('events').innerHTML=''; logEvents = (snap.events||[]).slice();
for(const e of (snap.events||[])) addEvent(e); renderLogs();
} }
function connect(){ function connect(){
@@ -700,6 +804,9 @@ function connect(){
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==='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==='log_deleted') { logEvents=logEvents.filter(e=>e.id!==ev.id); renderLogs(); }
else if(ev.type==='log_edited') { const e=logEvents.find(e=>e.id===ev.id); if(e){e.message=ev.message; renderLogs();} }
}; };
} }
// --- STT recognition test (upload / mic record -> GPU whisper) ----------- # // --- STT recognition test (upload / mic record -> GPU whisper) ----------- #
@@ -788,6 +895,35 @@ async function openPrompt(){
} }
$('promptBtn').onclick = openPrompt; $('promptBtn').onclick = openPrompt;
// --- 로그 패널: 열고닫기 / 검색 / 삭제(전체·개별) / 수정 -------------------- #
$('logToggle').onclick = ()=>{
const d=$('logdock'); d.classList.toggle('collapsed');
$('logToggle').textContent = (d.classList.contains('collapsed')?'':'') + ' 이벤트 / 오류 로그';
};
$('logSearch').oninput = renderLogs;
$('logLevel').onchange = renderLogs;
$('logClear').onclick = async ()=>{
if(!confirm('로그를 모두 삭제할까요?')) return;
try{ await fetch('/api/logs/clear',{method:'POST'}); toast('로그를 삭제했습니다'); }
catch(e){ toast('삭제 실패: '+e); }
};
$('logbody').addEventListener('click', async (ev)=>{
const btn = ev.target.closest('.lact'); if(!btn) return;
const line = btn.closest('.logline'); const id = line && line.getAttribute('data-id');
if(id==null) return;
const act = btn.getAttribute('data-act');
const isServer = !String(id).startsWith('c'); // server events have numeric ids
if(act==='del'){
if(isServer){ try{ await fetch('/api/logs/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:Number(id)})}); }catch(e){} }
logEvents = logEvents.filter(e=>String(e.id)!==String(id)); renderLogs();
} else if(act==='edit'){
const cur = logEvents.find(e=>String(e.id)===String(id)); if(!cur) return;
const nv = prompt('로그 수정', cur.message); if(nv==null) return;
cur.message = nv; renderLogs();
if(isServer){ try{ await fetch('/api/logs/edit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:Number(id),message:nv})}); }catch(e){} }
}
});
connect(); connect();
// Refresh uptime label every second from the last known status. // Refresh uptime label every second from the last known status.
setInterval(()=>{ if(statusData){ statusData.uptime_s=(statusData.uptime_s||0)+1; $('s-up').textContent=fmtUptime(statusData.uptime_s);} }, 1000); setInterval(()=>{ if(statusData){ statusData.uptime_s=(statusData.uptime_s||0)+1; $('s-up').textContent=fmtUptime(statusData.uptime_s);} }, 1000);

View File

@@ -83,6 +83,7 @@ class Turn:
self.wall = _now_wall() self.wall = _now_wall()
self._t0 = _now_mono() self._t0 = _now_mono()
self.heard_text = "" self.heard_text = ""
self.thought_text = ""
self.reply_text = "" self.reply_text = ""
self.status = "active" # active | ok | error self.status = "active" # active | ok | error
self.error = "" self.error = ""
@@ -95,6 +96,10 @@ class Turn:
self.heard_text = text self.heard_text = text
self._touch() self._touch()
def thought(self, text: str) -> None:
self.thought_text = text
self._touch()
def replied(self, text: str) -> None: def replied(self, text: str) -> None:
self.reply_text = text self.reply_text = text
self._touch() self._touch()
@@ -134,6 +139,7 @@ class Turn:
"source": self.source, "source": self.source,
"wall": self.wall, "wall": self.wall,
"heard": self.heard_text, "heard": self.heard_text,
"thought": self.thought_text,
"reply": self.reply_text, "reply": self.reply_text,
"status": self.status, "status": self.status,
"error": self.error, "error": self.error,
@@ -147,7 +153,9 @@ class Monitor:
def __init__(self, keep: int = 60) -> None: def __init__(self, keep: int = 60) -> None:
self._turns: deque[Turn] = deque(maxlen=keep) self._turns: deque[Turn] = deque(maxlen=keep)
self._events: deque[dict[str, Any]] = deque(maxlen=200) # Keep a long event tail so the terminal panel can show the log from
# (voice-server) start, not just the last few lines.
self._events: deque[dict[str, Any]] = deque(maxlen=2000)
self._status: dict[str, Any] = { self._status: dict[str, Any] = {
"running": False, "running": False,
"listening": False, "listening": False,
@@ -159,6 +167,7 @@ class Monitor:
self._lock = threading.Lock() self._lock = threading.Lock()
self._subs: list["queue.Queue[str]"] = [] self._subs: list["queue.Queue[str]"] = []
self._id = 0 self._id = 0
self._event_id = 0
# -- status ----------------------------------------------------------- # # -- status ----------------------------------------------------------- #
def set_status(self, **kw: Any) -> None: def set_status(self, **kw: Any) -> None:
@@ -179,13 +188,48 @@ class Monitor:
def log(self, level: str, message: str) -> None: def log(self, level: str, message: str) -> None:
"""A free-form lifecycle/error line (startup, disconnect, crash…).""" """A free-form lifecycle/error line (startup, disconnect, crash…)."""
evt = {"type": "log", "level": level, "message": message, "wall": _now_wall()}
with self._lock: with self._lock:
self._event_id += 1
evt = {"type": "log", "id": self._event_id, "level": level,
"message": message, "wall": _now_wall()}
self._events.append(evt) self._events.append(evt)
if level == "error": if level == "error":
self._status["errors_total"] += 1 self._status["errors_total"] += 1
self._broadcast(evt) self._broadcast(evt)
def delete_event(self, event_id: int) -> bool:
"""Remove one log line by id (dashboard per-line '삭제')."""
found = False
with self._lock: # _broadcast re-locks, so must run outside this block
for e in list(self._events):
if e.get("id") == event_id:
self._events.remove(e)
found = True
break
if found:
self._broadcast({"type": "log_deleted", "id": event_id})
return found
def edit_event(self, event_id: int, message: str) -> bool:
"""Edit one log line's text by id (dashboard per-line '수정')."""
found = False
with self._lock: # _broadcast re-locks, so must run outside this block
for e in self._events:
if e.get("id") == event_id:
e["message"] = message
found = True
break
if found:
self._broadcast({"type": "log_edited", "id": event_id, "message": message})
return found
def clear_events(self) -> None:
"""Wipe the event/error log (dashboard '로그 삭제'). Broadcasts a reset so
every connected page clears its terminal panel too."""
with self._lock:
self._events.clear()
self._broadcast({"type": "logs_cleared"})
# -- turns ------------------------------------------------------------ # # -- turns ------------------------------------------------------------ #
def turn(self, source: str = "voice") -> Turn: def turn(self, source: str = "voice") -> Turn:
with self._lock: with self._lock: