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

@@ -83,6 +83,7 @@ class Turn:
self.wall = _now_wall()
self._t0 = _now_mono()
self.heard_text = ""
self.thought_text = ""
self.reply_text = ""
self.status = "active" # active | ok | error
self.error = ""
@@ -95,6 +96,10 @@ class Turn:
self.heard_text = text
self._touch()
def thought(self, text: str) -> None:
self.thought_text = text
self._touch()
def replied(self, text: str) -> None:
self.reply_text = text
self._touch()
@@ -134,6 +139,7 @@ class Turn:
"source": self.source,
"wall": self.wall,
"heard": self.heard_text,
"thought": self.thought_text,
"reply": self.reply_text,
"status": self.status,
"error": self.error,
@@ -147,7 +153,9 @@ class Monitor:
def __init__(self, keep: int = 60) -> None:
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] = {
"running": False,
"listening": False,
@@ -159,6 +167,7 @@ class Monitor:
self._lock = threading.Lock()
self._subs: list["queue.Queue[str]"] = []
self._id = 0
self._event_id = 0
# -- status ----------------------------------------------------------- #
def set_status(self, **kw: Any) -> None:
@@ -179,13 +188,48 @@ class Monitor:
def log(self, level: str, message: str) -> None:
"""A free-form lifecycle/error line (startup, disconnect, crash…)."""
evt = {"type": "log", "level": level, "message": message, "wall": _now_wall()}
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)
if level == "error":
self._status["errors_total"] += 1
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 ------------------------------------------------------------ #
def turn(self, source: str = "voice") -> Turn:
with self._lock: