Files
watch_sceen_ai/wsai/dashboard.py
EJClaw 441ab4831f feat(bot+dashboard): whitelist/blacklist listen filter (users + roles)
- Per-guild listen filter stored in the control plane (BotControl) with
  GET/POST /api/bot/lists; the filter also rides along in the bot report
  response so the bot always has the latest config.
- 화이트리스트/블랙리스트 popup: search the guild's members OR roles (type
  selector), add/remove to white/black, save. Whitelist = listen to only those
  (empty = everyone); blacklist = exclude. Reuses the shared 뒤로가기 modal.
- Bot reports guild roles + known members for the search UI, and filters
  incoming audio via a pure, unit-tested isAllowed() (dave/filter.mjs):
  blacklist always excludes; a non-empty whitelist restricts; else everyone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 12:06:03 +09:00

1136 lines
57 KiB
Python

"""Live status website for the voice loop.
Serves a single self-contained page plus a Server-Sent-Events stream so you can
open a browser and watch, step by step: is it listening, what it heard, what it
thought/answered, how long each stage took, and whether anything errored.
Pure stdlib (``http.server``). Runs in a background thread so it never blocks
the asyncio pipeline.
Endpoints:
GET / -> the dashboard HTML
GET /api/state -> JSON snapshot (initial load / fallback polling)
GET /events -> text/event-stream live push
"""
from __future__ import annotations
import json
import logging
import queue
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from .monitor import Monitor
log = logging.getLogger("wsai.dashboard")
def _speech_text(reply: str) -> str:
"""Return reply text exactly as authored for the TTS backend.
The TTS backend itself understands bracketed emotion tags: known emotion
tags steer delivery and are not spoken; non-emotion brackets are spoken.
Do not rewrite a leading tag here, or the first emotion would be read aloud
and lost before ``MeloTTS.synth`` can parse it."""
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"):
monitor = dash.monitor
class Handler(BaseHTTPRequestHandler):
# Quiet: don't spam the console with one line per request.
def log_message(self, *args) -> None: # noqa: D401
return
def _send(self, code: int, body: bytes, ctype: str) -> None:
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None: # noqa: N802
path = self.path.split("?", 1)[0]
if path == "/" or path == "/index.html":
self._send(200, PAGE.encode("utf-8"), "text/html; charset=utf-8")
elif path == "/api/state":
body = json.dumps(monitor.snapshot(), ensure_ascii=False).encode("utf-8")
self._send(200, body, "application/json; charset=utf-8")
elif path == "/api/prompt":
self._handle_prompt_get()
elif path == "/api/bot/state":
self._send_json(dash.bot.state())
elif path == "/api/bot/commands":
self._send_json({"commands": dash.bot.drain()})
elif path == "/api/bot/lists":
import urllib.parse as _up
q = _up.parse_qs(self.path.split("?", 1)[1] if "?" in self.path else "")
gid = (q.get("guildId", [""])[0])
self._send_json({"ok": True, "guildId": gid, "lists": dash.bot.get_lists(gid)})
elif path == "/events":
self._stream_events()
else:
self._send(404, b"not found", "text/plain; charset=utf-8")
def do_POST(self) -> None: # noqa: N802
path = self.path.split("?", 1)[0]
if path == "/api/stt":
self._handle_stt()
elif path == "/api/voice-turn":
self._handle_voice_turn()
elif path == "/api/prompt":
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")
elif path == "/api/bot/report":
self._handle_bot_report()
elif path == "/api/bot/select":
self._handle_bot_select()
elif path == "/api/bot/lists":
self._handle_bot_lists()
else:
self._send(404, b"not found", "text/plain; charset=utf-8")
def _read_body(self) -> bytes:
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = 0
return self.rfile.read(length) if length > 0 else b""
def _handle_voice_turn(self) -> None:
"""Discord voice bridge: utterance wav in -> reply wav out. The
recognised/reply text ride along as URL-encoded response headers so
the bot can log them; the body is the reply audio to play back."""
import urllib.parse
if dash.stt is None or dash.tts is None:
self._send(503, json.dumps({"ok": False, "error": "voice loop not enabled"}).encode(),
"application/json; charset=utf-8")
return
raw = self._read_body()
if not raw:
self._send(400, json.dumps({"ok": False, "error": "empty upload"}).encode(),
"application/json; charset=utf-8")
return
speaker = urllib.parse.unquote(self.headers.get("X-User-Name", "") or "")
try:
res = dash.voice_turn(raw, speaker=speaker)
except Exception as exc: # noqa: BLE001
log.exception("voice-turn failed")
self._send(500, json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"},
ensure_ascii=False).encode(), "application/json; charset=utf-8")
return
body = res["wav"]
self.send_response(200 if body else 204)
self.send_header("Content-Type", "audio/wav")
self.send_header("Content-Length", str(len(body)))
self.send_header("X-Heard", urllib.parse.quote(res.get("heard", "")))
self.send_header("X-Reply", urllib.parse.quote(res.get("reply", "")))
self.send_header("Cache-Control", "no-store")
self.end_headers()
if body:
self.wfile.write(body)
def _handle_stt(self) -> None:
"""Accept an uploaded audio blob (mic recording or file), run it
through the real GPU STT, and return the recognised text."""
if dash.stt is None:
self._send(503, json.dumps({"ok": False, "error": "STT not enabled"}).encode(),
"application/json; charset=utf-8")
return
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = 0
if length <= 0:
self._send(400, json.dumps({"ok": False, "error": "empty upload"}).encode(),
"application/json; charset=utf-8")
return
raw = self.rfile.read(length)
try:
result = dash.transcribe_upload(raw)
body = json.dumps({"ok": True, **result}, ensure_ascii=False).encode("utf-8")
self._send(200, body, "application/json; charset=utf-8")
except Exception as exc: # noqa: BLE001 — surface the reason to the page
log.exception("STT upload failed")
body = json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"},
ensure_ascii=False).encode("utf-8")
self._send(500, body, "application/json; charset=utf-8")
def _default_persona(self) -> str:
# The built-in seed prompt, used when no override is saved. Imported
# lazily so the dashboard has no hard dependency on the Claude backend.
try:
from .backends.claude import ClaudeBrain
return ClaudeBrain.PERSONA
except Exception:
return ""
def _handle_prompt_get(self) -> None:
"""Return the live bot system prompt so the page can show/edit it."""
from . import prompt_store
default = self._default_persona()
body = json.dumps({
"ok": True,
"prompt": prompt_store.get_persona(default),
"default": default,
"overridden": prompt_store.is_overridden(),
}, ensure_ascii=False).encode("utf-8")
self._send(200, body, "application/json; charset=utf-8")
def _handle_prompt_post(self) -> None:
"""Save an edited system prompt; takes effect on the next reply. An
empty prompt clears the override and reverts to the built-in default."""
from . import prompt_store
raw = self._read_body()
try:
data = json.loads(raw.decode("utf-8")) if raw else {}
prompt = data.get("prompt", "")
except (ValueError, AttributeError):
self._send(400, json.dumps({"ok": False, "error": "invalid JSON"}).encode(),
"application/json; charset=utf-8")
return
prompt_store.set_persona(prompt)
monitor.log("info", "봇 프롬프트가 수정되었습니다" if prompt.strip()
else "봇 프롬프트가 기본값으로 초기화되었습니다")
body = json.dumps({
"ok": True,
"prompt": prompt_store.get_persona(self._default_persona()),
"overridden": prompt_store.is_overridden(),
}, ensure_ascii=False).encode("utf-8")
self._send(200, body, "application/json; charset=utf-8")
def _send_json(self, obj, code: int = 200) -> None:
self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"),
"application/json; charset=utf-8")
def _handle_bot_report(self) -> None:
"""The Discord bot pushes its live state (identity, guilds, voice
channels, current channel + members, list settings)."""
raw = self._read_body()
try:
data = json.loads(raw.decode("utf-8")) if raw else {}
except (ValueError, AttributeError):
self._send_json({"ok": False, "error": "invalid JSON"}, 400)
return
dash.bot.report(data)
# Hand the bot any queued commands + the current listen filters in the
# same round trip so it does not have to poll extra endpoints.
self._send_json({"ok": True, "commands": dash.bot.drain(),
"lists": dash.bot.all_lists()})
def _handle_bot_select(self) -> None:
"""UI picked a server/voice channel → queue a join (or leave) command."""
raw = self._read_body()
try:
data = json.loads(raw.decode("utf-8")) if raw else {}
except (ValueError, AttributeError):
self._send_json({"ok": False, "error": "invalid JSON"}, 400)
return
guild_id = (data.get("guildId") or "").strip()
channel_id = (data.get("channelId") or "").strip()
if channel_id and guild_id:
cid = dash.bot.enqueue({"type": "join", "guildId": guild_id, "channelId": channel_id})
monitor.log("info", f"음성채널 참여 요청 (guild={guild_id} channel={channel_id})")
else:
cid = dash.bot.enqueue({"type": "leave"})
monitor.log("info", "음성채널 나가기 요청")
self._send_json({"ok": True, "commandId": cid})
def _handle_bot_lists(self) -> None:
"""Save the whitelist/blacklist (users + roles) for a guild."""
raw = self._read_body()
try:
data = json.loads(raw.decode("utf-8")) if raw else {}
guild_id = (data.get("guildId") or "").strip()
if not guild_id:
raise ValueError("guildId required")
except (ValueError, AttributeError) as exc:
self._send_json({"ok": False, "error": str(exc)}, 400)
return
saved = dash.bot.set_lists(guild_id, data.get("lists") or {})
monitor.log("info", f"청취 화이트/블랙리스트 업데이트 (guild={guild_id})")
self._send_json({"ok": True, "guildId": guild_id, "lists": saved})
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:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "keep-alive")
self.end_headers()
q = monitor.subscribe()
try:
# Prime the client with a full snapshot so it renders instantly.
first = json.dumps(
{"type": "snapshot", "snapshot": monitor.snapshot()},
ensure_ascii=False,
)
self.wfile.write(f"data: {first}\n\n".encode("utf-8"))
self.wfile.flush()
while True:
try:
data = q.get(timeout=15)
except queue.Empty:
# Heartbeat keeps proxies / the browser from timing out.
self.wfile.write(b": ping\n\n")
self.wfile.flush()
continue
self.wfile.write(f"data: {data}\n\n".encode("utf-8"))
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
pass
finally:
monitor.unsubscribe(q)
return Handler
class Dashboard:
"""Owns the HTTP server thread.
Optionally holds a real STT backend so the page can offer a live
recognition test (upload/record audio -> GPU whisper -> text). The STT
backend is async, so the dashboard runs its own asyncio loop in a
background thread and bridges the synchronous HTTP handlers onto it.
"""
def __init__(self, monitor: Monitor, host: str = "0.0.0.0", port: int = 8787,
stt=None, tts=None, brain=None, history_turns: int = 12) -> None:
from .bot_control import BotControl
self.monitor = monitor
self.host = host
self.port = port
self.stt = stt
self.tts = tts
self.brain = brain
self.bot = BotControl() # dashboard <-> Discord bot control plane
self._history: list[tuple[str, str]] = []
self._history_turns = history_turns
self._server: ThreadingHTTPServer | None = None
self._thread: threading.Thread | None = None
self._loop = None
self._loop_thread: threading.Thread | None = None
def start(self) -> None:
if self.stt is not None or self.tts is not None:
self._start_loop()
handler = _make_handler(self)
self._server = ThreadingHTTPServer((self.host, self.port), handler)
self._server.daemon_threads = True
self._thread = threading.Thread(
target=self._server.serve_forever, name="wsai-dashboard", daemon=True
)
self._thread.start()
log.info("dashboard on http://%s:%d", self.host, self.port)
def stop(self) -> None:
if self._server is not None:
self._server.shutdown()
self._server.server_close()
self._server = None
if self._loop is not None:
self._loop.call_soon_threadsafe(self._loop.stop)
self._loop = None
# -- async bridge (STT test) ----------------------------------------- #
def _start_loop(self) -> None:
import asyncio
self._loop = asyncio.new_event_loop()
self._loop_thread = threading.Thread(
target=self._loop.run_forever, name="wsai-dashboard-loop", daemon=True
)
self._loop_thread.start()
def _submit(self, coro, timeout: float = 120.0):
import asyncio
fut = asyncio.run_coroutine_threadsafe(coro, self._loop)
return fut.result(timeout=timeout)
def warm(self) -> None:
"""Pre-start the STT/TTS workers (loads + warms the GPU) so the first
recognition/synth is instant instead of paying model-load + CUDA autotune."""
if self.stt is not None:
self._submit(self.stt._ensure())
if self.tts is not None:
self._submit(self.tts._ensure())
def voice_turn(self, audio_bytes: bytes, speaker: str = "") -> dict:
"""One Discord voice turn: decode the uploaded utterance, recognise it
on the GPU, think of a reply (Claude brain if wired, else echo),
synthesise it on the GPU, and return {heard, reply, wav} where wav is the
reply audio bytes for the bot to play back into the channel."""
import os
import subprocess
import time
import uuid
if self.stt is None or self.tts is None:
raise RuntimeError("voice_turn needs both STT and TTS")
updir = os.path.expanduser("~/.cache/wsai/uploads")
os.makedirs(updir, exist_ok=True)
stem = os.path.join(updir, uuid.uuid4().hex)
src, wav = stem + ".bin", stem + ".wav"
with open(src, "wb") as f:
f.write(audio_bytes)
turn = self.monitor.turn(source="discord")
if speaker:
turn.speaker = speaker # who spoke (for the "누가 말했는지" log)
t0 = time.monotonic()
try:
subprocess.run(
["ffmpeg", "-y", "-i", src, "-ar", "16000", "-ac", "1", wav],
check=True, capture_output=True,
)
heard = (self._submit(self.stt.transcribe(wav)) or "").strip()
turn.heard(heard or "(빈 결과)")
if not heard:
# Nothing recognised (silence/noise): mark it as [잡음] and skip
# the brain/TTS so the bot plays nothing back.
turn.thought("목소리가 아닌 잡음으로 판단 → 응답하지 않음")
turn.replied("[잡음]")
turn.finish()
return {"heard": heard, "reply": "[잡음]", "wav": b""}
reply_text = self._think(heard)
turn.thought(_thought_summary(reply_text))
turn.replied(reply_text)
out_path = self._submit(self.tts.synth(_speech_text(reply_text)))
with open(out_path, "rb") as f:
reply_wav = f.read()
ms = int((time.monotonic() - t0) * 1000)
step = turn.step("STT+두뇌+TTS" if self.brain else "STT+TTS(GPU)")
step.ok, step.ms = True, float(ms)
turn._steps.append(step)
turn.finish()
try:
os.remove(out_path)
except OSError:
pass
return {"heard": heard, "reply": reply_text, "wav": reply_wav}
except subprocess.CalledProcessError as exc:
turn.finish(error="ffmpeg decode failed")
err = exc.stderr.decode("utf-8", "replace")[-300:] if exc.stderr else str(exc)
raise RuntimeError(f"ffmpeg: {err}") from exc
except Exception as exc:
turn.finish(error=str(exc))
raise
finally:
for p in (src, wav):
try:
os.remove(p)
except OSError:
pass
def _think(self, heard: str) -> str:
"""Turn what was heard into a reply. Uses the Claude brain when wired
(with rolling conversation history); falls back to echo if there is no
brain, and to a spoken apology if the brain call fails — so one API hiccup
never kills the voice loop."""
if self.brain is None:
return heard # echo mode
try:
reply = self._submit(self.brain.respond(heard, None, list(self._history)))
text = (reply.text or "").strip()
except Exception as exc: # noqa: BLE001
log.exception("brain failed")
self.monitor.log("error", f"두뇌 응답 실패: {exc}")
blob = f"{getattr(exc, 'status_code', '')} {exc}".lower()
if "529" in blob or "overload" in blob:
# Transient server overload survived the SDK retries.
return "지금 서버가 잠깐 붐벼서 생각이 늦네. 잠시 뒤에 다시 말해줄래?"
return "미안, 지금 잠깐 생각이 안 났어. 다시 말해줄래?"
if not text:
return "음, 뭐라고 해야 할지 모르겠어. 다시 말해줄래?"
self._history.append((heard, text))
if len(self._history) > self._history_turns:
self._history = self._history[-self._history_turns:]
return text
def transcribe_upload(self, audio_bytes: bytes) -> dict:
"""ffmpeg-normalise an uploaded blob to 16 kHz mono wav, transcribe it
on the GPU, and record the result as a monitor turn so it also shows in
the live feed. Returns {text, ms, device}."""
import os
import subprocess
import tempfile
import time
import uuid
updir = os.path.expanduser("~/.cache/wsai/uploads")
os.makedirs(updir, exist_ok=True)
stem = os.path.join(updir, uuid.uuid4().hex)
src, wav = stem + ".bin", stem + ".wav"
with open(src, "wb") as f:
f.write(audio_bytes)
turn = self.monitor.turn(source="web")
t0 = time.monotonic()
try:
# Decode whatever the browser sent (webm/opus, ogg, mp4, wav) to the
# 16 kHz mono wav faster-whisper expects.
subprocess.run(
["ffmpeg", "-y", "-i", src, "-ar", "16000", "-ac", "1", wav],
check=True, capture_output=True,
)
text = self._submit(self.stt.transcribe(wav))
ms = int((time.monotonic() - t0) * 1000)
turn.heard(text or "(빈 결과)")
step = turn.step("STT(GPU)")
step.ok, step.ms = True, float(ms)
turn._steps.append(step)
turn.finish()
return {"text": text, "ms": ms,
"device": getattr(self.stt, "resolved_device", None) or "?"}
except subprocess.CalledProcessError as exc:
turn.finish(error="ffmpeg decode failed")
err = exc.stderr.decode("utf-8", "replace")[-300:] if exc.stderr else str(exc)
raise RuntimeError(f"ffmpeg: {err}") from exc
except Exception as exc:
turn.finish(error=str(exc))
raise
finally:
for p in (src, wav):
try:
os.remove(p)
except OSError:
pass
# --------------------------------------------------------------------------- #
# The page. One file, no external assets, so it works offline / behind a LAN.
# --------------------------------------------------------------------------- #
PAGE = r"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>watch_sceen_ai · 실시간 상태</title>
<style>
:root{
--bg:#0b0f14; --panel:#131a22; --panel2:#0f151c; --line:#223040;
--fg:#e6edf3; --muted:#8aa0b2; --accent:#3fb6ff; --ok:#37d67a;
--err:#ff5c6c; --warn:#ffc857; --heard:#7aa2ff; --reply:#b28bff;
}
*{box-sizing:border-box}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Apple SD Gothic Neo","Malgun Gothic",sans-serif;
background:var(--bg);color:var(--fg);line-height:1.5}
header{position:sticky;top:0;z-index:5;background:linear-gradient(180deg,#0d141c,#0b0f14);
border-bottom:1px solid var(--line);padding:14px 20px;display:flex;flex-wrap:wrap;gap:16px;align-items:center}
h1{font-size:16px;margin:0;font-weight:650;letter-spacing:.2px}
.sub{color:var(--muted);font-size:12px}
.pill{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:999px;
background:var(--panel);border:1px solid var(--line);font-size:12.5px;color:var(--muted)}
.dot{width:9px;height:9px;border-radius:50%;background:#556}
.dot.live{background:var(--ok);box-shadow:0 0 0 0 rgba(55,214,122,.6);animation:pulse 1.6s infinite}
.dot.off{background:#556}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(55,214,122,.55)}70%{box-shadow:0 0 0 9px rgba(55,214,122,0)}100%{box-shadow:0 0 0 0 rgba(55,214,122,0)}}
.stats{display:flex;gap:10px;flex-wrap:wrap;margin-left:auto}
.stat{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:6px 12px;min-width:78px}
.stat b{display:block;font-size:16px}
.stat span{color:var(--muted);font-size:11px}
main{max-width:1000px;margin:0 auto;padding:18px 20px 60px}
.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}
@keyframes rise{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.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}
.badge{font-size:11px;padding:2px 9px;border-radius:999px;border:1px solid var(--line);color:var(--muted);white-space:nowrap}
.badge.ok{color:var(--ok);border-color:#1f5236}
.badge.error{color:var(--err);border-color:#5c2530}
.badge.active{color:var(--accent);border-color:#234a63}
.time{color:var(--muted);font-size:11.5px;margin-left:auto}
.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}
.heard{color:var(--heard);font-weight:550}
.thought{color:var(--muted);font-size:13px}
.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}
.step{display:grid;grid-template-columns:120px 1fr 66px;gap:10px;align-items:center;font-size:12.5px}
.step .sname{color:var(--muted)}
.step .sbar{height:8px;background:var(--panel2);border-radius:6px;overflow:hidden;border:1px solid var(--line)}
.step .sfill{height:100%;background:linear-gradient(90deg,#2b7bb0,#3fb6ff)}
.step.err .sfill{background:linear-gradient(90deg,#7a2531,#ff5c6c)}
.step .sms{text-align:right;color:var(--fg);font-variant-numeric:tabular-nums}
.step .serr{grid-column:1 / -1;color:var(--err);font-size:11.5px}
.total{margin-top:8px;font-size:12px;color:var(--muted)}
.total b{color:var(--fg)}
.empty{color:var(--muted);text-align:center;padding:50px 0;font-size:14px}
.events{margin-top:26px}
.events h2{font-size:13px;color:var(--muted);font-weight:600;margin:0 0 8px}
.ev{font-size:12px;color:var(--muted);padding:3px 0;border-bottom:1px solid #16202b;display:flex;gap:10px}
.ev.error{color:var(--err)}
.ev .et{flex:0 0 68px;color:#5f7488}
code{background:#0c1219;padding:1px 5px;border-radius:5px;border:1px solid var(--line)}
.demobar{background:#2a2210;border:1px solid #6b5417;color:var(--warn);border-radius:12px;
padding:11px 15px;margin:0 0 16px;font-size:13px;line-height:1.55}
.demobar b{color:#ffe08a}
.sttbox{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 16px;margin:0 0 16px}
.sttbox h2{font-size:13px;margin:0 0 10px;font-weight:600;color:var(--fg)}
.sttrow{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.btn{background:#173042;border:1px solid #234a63;color:var(--fg);border-radius:10px;padding:8px 14px;font-size:13px;cursor:pointer}
.btn:hover{background:#1d3d54}
.btn.rec{background:#4a1f27;border-color:#7a2531;color:#ffb3bb}
.sttstat{color:var(--muted);font-size:12.5px}
.sttres{margin-top:12px;font-size:15px;min-height:1px}
.sttres .txt{color:var(--heard);font-weight:600;line-height:1.5}
.sttres .meta{color:var(--muted);font-size:12px;margin-top:5px}
.hbtn{padding:6px 12px;font-size:12.5px}
/* Bot control bar (봇 정보 · 서버/채널 선택 · 참여자) */
.botbar{display:flex;gap:14px;align-items:center;flex-wrap:wrap;background:var(--panel);
border:1px solid var(--line);border-radius:12px;padding:10px 14px;margin:0 0 16px;font-size:13px}
.botbar label{display:flex;gap:6px;align-items:center;color:var(--muted)}
.botbar select{background:var(--panel2);border:1px solid var(--line);color:var(--fg);
border-radius:8px;padding:6px 9px;font-size:13px;max-width:230px}
.botinfo{display:flex;gap:7px;align-items:center;font-weight:600}
.parts{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-left:auto;color:var(--muted)}
.part{display:inline-flex;gap:5px;align-items:center;background:var(--panel2);border:1px solid var(--line);
border-radius:999px;padding:3px 10px;font-size:12px}
.part.spk{border-color:#1f5236;color:#9ff0bd}
.speaker{color:var(--muted);font-size:11.5px}
/* Modal / popup (reused by 프롬프트, 화이트/블랙리스트 …) */
.modal{position:fixed;inset:0;z-index:20;background:rgba(4,7,11,.66);
display:flex;align-items:center;justify-content:center;padding:20px}
.modal-card{background:var(--panel);border:1px solid var(--line);border-radius:16px;
width:min(760px,100%);max-height:86vh;display:flex;flex-direction:column;overflow:hidden;
box-shadow:0 24px 60px rgba(0,0,0,.5)}
.modal-head{display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid var(--line)}
.modal-title{font-size:14px;font-weight:650}
.modal-actions{margin-left:auto;display:flex;gap:8px}
.modal-body{padding:16px;overflow:auto}
.modal-body textarea{width:100%;min-height:340px;background:var(--panel2);color:var(--fg);
border:1px solid var(--line);border-radius:10px;padding:12px;font-size:13px;line-height:1.6;
font-family:inherit;resize:vertical}
.modal-body textarea[readonly]{color:var(--muted)}
.modal-note{color:var(--muted);font-size:12px;margin:0 0 10px}
.btn.primary{background:#16452c;border-color:#1f5236;color:#9ff0bd}
.btn.primary:hover{background:#1b5636}
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:30;
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}
.toast.show{opacity:1}
/* 화이트/블랙리스트 팝업 */
.lst-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:0 0 10px}
.lst-row select,.lst-row input{background:var(--panel2);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:7px 10px;font-size:13px}
.lst-search{flex:1;min-width:140px}
.lst-results{max-height:210px;overflow:auto;border:1px solid var(--line);border-radius:10px;margin:0 0 12px}
.lst-item{display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid #16202b;font-size:13px}
.lst-item:last-child{border-bottom:none}
.lst-item .nm{flex:1}
.lst-item .rl{color:var(--muted);font-size:11px}
.mini{padding:3px 8px;font-size:11.5px;border-radius:7px;cursor:pointer;border:1px solid var(--line);background:#173042;color:var(--fg)}
.mini.w{border-color:#1f5236;color:#9ff0bd}
.mini.b{border-color:#5c2530;color:#ffb3bb}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin:4px 0 12px}
.chip{display:inline-flex;gap:6px;align-items:center;background:var(--panel2);border:1px solid var(--line);border-radius:999px;padding:3px 10px;font-size:12px}
.chip.w{border-color:#1f5236}
.chip.b{border-color:#5c2530}
.chip button{background:none;border:none;color:var(--muted);cursor:pointer;padding:0}
.lst-h{font-size:12px;color:var(--muted);margin:8px 0 4px;font-weight:600}
/* 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>
</head>
<body>
<header>
<div>
<h1>watch_sceen_ai · 실시간 상태</h1>
<div class="sub">STT → 두뇌 → TTS 음성 루프를 단계별로 관찰</div>
</div>
<div class="pill"><span id="dot" class="dot off"></span><span id="listen">연결 대기</span></div>
<button id="promptBtn" class="btn hbtn">📝 프롬프트</button>
<div class="stats">
<div class="stat"><b id="s-turns">0</b><span>대화 수</span></div>
<div class="stat"><b id="s-errors">0</b><span>오류</span></div>
<div class="stat"><b id="s-up">0초</b><span>가동시간</span></div>
<div class="stat"><b id="s-conn">·</b><span>연결</span></div>
</div>
</header>
<main>
<section class="botbar" id="botbar">
<span class="botinfo" id="botinfo"><span class="dot off"></span>봇: 연결 안 됨</span>
<label>서버 <select id="guildSel"><option value="">없음</option></select></label>
<label>음성채널 <select id="vcSel"><option value="">없음</option></select></label>
<button id="wlBtn" class="btn hbtn">화이트리스트</button>
<button id="blBtn" class="btn hbtn">블랙리스트</button>
<span class="parts" id="parts"></span>
</section>
<section class="sttbox" id="sttbox" style="display:none">
<h2>🎤 음성 인식(STT) 테스트 · GPU</h2>
<div class="sttrow">
<button id="recbtn" class="btn">🎤 녹음 시작</button>
<label class="btn" for="fileinp">📁 오디오 파일 올리기</label>
<input id="fileinp" type="file" accept="audio/*" hidden>
<span id="ststat" class="sttstat">녹음하거나 오디오 파일을 올리면 GPU로 인식합니다.</span>
</div>
<div id="sttres" class="sttres"></div>
</section>
<div class="demobar" id="demobar" style="display:none"></div>
<div class="comp" id="comp"></div>
<div id="turns"></div>
<div id="empty" class="empty">아직 대화가 없습니다. 사용자가 말하면 여기에 단계별로 나타납니다.</div>
</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 class="modal-card">
<div class="modal-head">
<button class="btn back" id="modalBack">← 뒤로</button>
<span class="modal-title" id="modalTitle"></span>
<span class="modal-actions" id="modalActions"></span>
</div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<div id="toast" class="toast"></div>
<script>
const $ = (id)=>document.getElementById(id);
const turns = new Map(); // id -> turn object
let statusData = null;
function fmtTime(wall){
const d = new Date(wall*1000);
return d.toLocaleTimeString('ko-KR',{hour12:false}) +
'.' + String(d.getMilliseconds()).padStart(3,'0');
}
function fmtUptime(s){
s = Math.floor(s||0);
const h=Math.floor(s/3600), m=Math.floor(s%3600/60), sec=s%60;
if(h) return h+'시간 '+m+'';
if(m) return m+''+sec+'';
return sec+'';
}
function esc(t){const d=document.createElement('div');d.textContent=t==null?'':t;return d.innerHTML;}
function renderStatus(s){
statusData = s;
$('s-turns').textContent = s.turns_total ?? 0;
$('s-errors').textContent = s.errors_total ?? 0;
$('s-up').textContent = fmtUptime(s.uptime_s);
const listening = s.listening;
$('dot').className = 'dot ' + (listening ? 'live' : 'off');
$('listen').textContent = listening ? '듣는 중' : (s.running ? '실행 중 (대기)' : '중지됨');
const comps = s.components || {};
// Demo banner: if the ears/brain/mouth are still mock, everything below is
// replayed sample data, not a real conversation. Say so loudly.
const sttReal = comps.stt && comps.stt!=='mock' && comps.stt!=='none';
$('sttbox').style.display = sttReal ? 'block' : 'none';
const mockParts = ['stt','brain','tts'].filter(k => comps[k]==='mock');
const bar = $('demobar');
if(mockParts.length){
bar.style.display='block';
bar.innerHTML = '⚠ <b>데모 모드</b> — 실제 음성/STT/두뇌/TTS가 아직 연결되지 않아, 아래 대화는 '
+ '실제로 들은 내용이 아니라 <b>목(mock) 예시 스크립트</b>입니다. '
+ '실제 엔진(faster-whisper·Claude·MeloTTS)을 붙이면 이 자리에 진짜 발화·지연·오류가 표시됩니다.';
} else {
bar.style.display='none';
}
const el = $('comp'); el.innerHTML = '';
const names = {source:'눈(소스)', vision:'시각', stt:'귀(STT)', brain:'두뇌', tts:'입(TTS)', text:'텍스트'};
for(const k of Object.keys(names)){
if(!(k in comps)) continue;
const v = comps[k];
const p = document.createElement('span');
p.className = 'pill';
p.innerHTML = '<span class="dot '+(v && v!=='none'?'live':'off')+'"></span>'+names[k]+': <b>&nbsp;'+esc(v||'off')+'</b>';
el.appendChild(p);
}
}
function setConn(ok){ $('s-conn').textContent = ok ? '' : ''; $('s-conn').style.color = ok ? 'var(--ok)':'var(--err)'; }
function maxMs(steps){ let m=1; for(const s of steps) m=Math.max(m, s.ms||0); return m; }
function turnEl(t){
const wrap = document.createElement('div');
wrap.className = 'turn ' + (t.status||'active');
wrap.id = 'turn-'+t.id;
const mx = maxMs(t.steps);
let steps = '';
for(const s of (t.steps||[])){
const pct = Math.max(3, Math.round((s.ms||0)/mx*100));
const err = s.ok===false;
steps += '<div class="step'+(err?' err':'')+'">'
+ '<span class="sname">'+esc(s.name)+'</span>'
+ '<span class="sbar"><span class="sfill" style="width:'+pct+'%"></span></span>'
+ '<span class="sms">'+ (s.ms!=null? s.ms.toFixed(0)+' ms':'') +'</span>'
+ (err && s.error ? '<span class="serr">⚠ '+esc(s.error)+'</span>':'')
+ '</div>';
}
const badge = t.status==='ok' ? '<span class="badge ok">정상</span>'
: t.status==='error' ? '<span class="badge error">오류</span>'
: '<span class="badge active">진행 중…</span>';
wrap.innerHTML =
'<div class="trow">'+badge
+'<span class="badge">#'+t.id+' · '+esc(t.source||'voice')+'</span>'
+(t.speaker?'<span class="badge">🗣 '+esc(t.speaker)+'</span>':'')
+'<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="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>'
+(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="total">총 소요 <b>'+(t.total_ms?t.total_ms.toFixed(0)+' ms':'')+'</b></div>';
return wrap;
}
function upsertTurn(t){
turns.set(t.id, t);
$('empty').style.display = 'none';
const cont = $('turns');
const existing = $('turn-'+t.id);
const fresh = turnEl(t);
if(existing){ existing.replaceWith(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){
if(e.id==null){ e.id = 'c'+Date.now()+Math.random(); }
logEvents.push(e);
if(logEvents.length>2000) logEvents = logEvents.slice(-2000);
renderLogs();
}
function applySnapshot(snap){
renderStatus(snap.status);
turns.clear(); $('turns').innerHTML='';
const list = (snap.turns||[]);
for(const t of list) upsertTurn(t);
if(list.length===0){ $('empty').style.display='block'; }
logEvents = (snap.events||[]).slice();
renderLogs();
}
function connect(){
const es = new EventSource('/events');
es.onopen = ()=> setConn(true);
es.onerror = ()=> setConn(false);
es.onmessage = (m)=>{
let ev; try{ ev = JSON.parse(m.data); }catch(_){ return; }
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==='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) ----------- #
let mediaRec=null, chunks=[];
async function sendBlob(blob){
$('ststat').textContent='인식 중… (GPU)';
$('sttres').innerHTML='';
try{
const r=await fetch('/api/stt',{method:'POST',
headers:{'Content-Type':blob.type||'application/octet-stream'},body:blob});
const j=await r.json();
if(j.ok){
$('sttres').innerHTML='<div class="txt">'+esc(j.text||'(빈 결과)')+'</div>'
+'<div class="meta">인식 '+j.ms+' ms · '+esc(j.device)+'</div>';
$('ststat').textContent='완료. 다시 녹음하거나 파일을 올릴 수 있습니다.';
}else{
$('sttres').innerHTML='<div class="meta" style="color:var(--err)">오류: '+esc(j.error)+'</div>';
$('ststat').textContent='실패.';
}
}catch(e){ $('ststat').textContent='요청 실패: '+e; }
}
(function(){
const fi=$('fileinp'); if(fi) fi.onchange=()=>{ if(fi.files[0]) sendBlob(fi.files[0]); };
const rb=$('recbtn'); if(!rb) return;
rb.onclick=async()=>{
if(mediaRec && mediaRec.state==='recording'){ mediaRec.stop(); return; }
if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
$('ststat').textContent='이 주소(원격 http)에서는 브라우저 마이크가 막혀 있습니다. 파일 업로드를 사용하세요.';
return;
}
try{
const stream=await navigator.mediaDevices.getUserMedia({audio:true});
chunks=[]; mediaRec=new MediaRecorder(stream);
mediaRec.ondataavailable=(e)=>{ if(e.data.size) chunks.push(e.data); };
mediaRec.onstop=()=>{
stream.getTracks().forEach(t=>t.stop());
rb.textContent='🎤 녹음 시작'; rb.classList.remove('rec');
sendBlob(new Blob(chunks,{type:mediaRec.mimeType||'audio/webm'}));
};
mediaRec.start();
rb.textContent='⏹ 녹음 중지'; rb.classList.add('rec');
$('ststat').textContent='녹음 중… 말한 뒤 중지를 누르세요.';
}catch(e){ $('ststat').textContent='마이크 접근 실패: '+e; }
};
})();
// --- Reusable popup/modal (프롬프트, 화이트/블랙리스트 등이 공유) ---------- #
function openModal(title, actionsHtml){
$('modalTitle').textContent = title;
$('modalActions').innerHTML = actionsHtml || '';
$('modal').style.display = 'flex';
}
function closeModal(){ $('modal').style.display='none'; $('modalBody').innerHTML=''; $('modalActions').innerHTML=''; }
$('modalBack').onclick = closeModal;
$('modal').addEventListener('click', (e)=>{ if(e.target===$('modal')) closeModal(); });
document.addEventListener('keydown', (e)=>{ if(e.key==='Escape' && $('modal').style.display==='flex') closeModal(); });
let toastTimer=null;
function toast(msg){
const t=$('toast'); t.textContent=msg; t.classList.add('show');
clearTimeout(toastTimer); toastTimer=setTimeout(()=>t.classList.remove('show'), 2200);
}
// --- 프롬프트: 현재 시스템 프롬프트 보기/수정/저장 ------------------------- #
async function openPrompt(){
openModal('봇 프롬프트', '<button class="btn" id="pEdit">수정</button>'
+ '<button class="btn primary" id="pSave" style="display:none">저장</button>');
$('modalBody').innerHTML = '<p class="modal-note" id="pNote">현재 봇의 시스템 프롬프트입니다. 저장하면 다음 답변부터 즉시 적용됩니다. (빈칸 저장 시 기본값 복원)</p>'
+ '<textarea id="pText" readonly>불러오는 중…</textarea>';
let data={};
try{ data=await (await fetch('/api/prompt')).json(); }catch(e){ $('pText').value='불러오기 실패: '+e; return; }
$('pText').value = data.prompt || '';
$('pNote').textContent = (data.overridden ? '현재 사용자 지정 프롬프트가 적용 중입니다. ' : '현재 기본 프롬프트가 적용 중입니다. ')
+ '저장하면 다음 답변부터 즉시 적용됩니다. (빈칸 저장 시 기본값 복원)';
$('pEdit').onclick = ()=>{ $('pText').removeAttribute('readonly'); $('pText').focus(); $('pEdit').style.display='none'; $('pSave').style.display='inline-block'; };
$('pSave').onclick = async ()=>{
$('pSave').disabled=true;
try{
const r=await fetch('/api/prompt',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({prompt:$('pText').value})});
const j=await r.json();
if(j.ok){ toast('프롬프트 저장됨 · 다음 답변부터 적용'); closeModal(); }
else { toast('저장 실패: '+(j.error||'')); }
}catch(e){ toast('저장 실패: '+e); }
finally{ $('pSave').disabled=false; }
};
}
$('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){} }
}
});
// --- 봇 제어 바: 정보 표시 / 서버·채널 선택 / 참여자 --------------------- #
let botState = null;
let userPickedGuild = null; // remember the user's server pick across polls
function renderBot(s){
botState = s;
const info=$('botinfo');
if(s && s.connected){
const id = s.identity||{};
info.innerHTML = '<span class="dot live"></span>봇: <b>'+esc(id.tag||id.username||id.id||'연결됨')+'</b>';
} else {
info.innerHTML = '<span class="dot off"></span>봇: 연결 안 됨';
}
const guilds = (s&&s.guilds)||[];
const cur = (s&&s.current)||{};
const gsel=$('guildSel');
const gpick = userPickedGuild!=null ? userPickedGuild : (cur.guildId||'');
gsel.innerHTML = '<option value="">없음</option>' + guilds.map(g=>
'<option value="'+esc(g.id)+'"'+(g.id===gpick?' selected':'')+'>'+esc(g.name)+'</option>').join('');
// Voice channels of the picked guild.
const g = guilds.find(g=>g.id===gpick);
const vcs = (g&&g.voiceChannels)||[];
const vsel=$('vcSel');
vsel.innerHTML = '<option value="">없음</option>' + vcs.map(v=>
'<option value="'+esc(v.id)+'"'+(v.id===cur.channelId?' selected':'')+'>'+esc(v.name)+'</option>').join('');
// Participants in the current voice channel.
const members=(s&&s.members)||[];
$('parts').innerHTML = members.length
? '참여자: '+members.map(m=>'<span class="part'+(m.speaking?' spk':'')+'">'+(m.speaking?'🔊':'👤')+' '+esc(m.name||m.id)+'</span>').join('')
: (s&&s.connected&&cur.channelId ? '참여자: (없음)' : '');
}
$('guildSel').onchange = ()=>{ userPickedGuild=$('guildSel').value; renderBot(botState); };
$('vcSel').onchange = async ()=>{
const guildId=$('guildSel').value, channelId=$('vcSel').value;
try{
await fetch('/api/bot/select',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({guildId, channelId})});
toast(channelId?'음성채널 참여 요청을 보냈습니다':'나가기 요청을 보냈습니다');
}catch(e){ toast('요청 실패: '+e); }
};
// --- 화이트/블랙리스트 팝업 (유저·역할 검색 → 화이트/블랙 추가·제거) ------ #
const LKEY = {wu:'whitelistUsers', bu:'blacklistUsers', wr:'whitelistRoles', br:'blacklistRoles'};
async function openLists(){
const guildId = $('guildSel').value || (botState&&botState.current&&botState.current.guildId) || '';
if(!guildId){ toast('먼저 서버를 선택하세요'); return; }
const g = ((botState&&botState.guilds)||[]).find(x=>x.id===guildId) || {members:[],roles:[]};
let lists;
try{ lists = (await (await fetch('/api/bot/lists?guildId='+encodeURIComponent(guildId))).json()).lists; }
catch(e){ lists = {whitelistUsers:[],blacklistUsers:[],whitelistRoles:[],blacklistRoles:[]}; }
openModal('청취 화이트/블랙리스트', '<button class="btn primary" id="lstSave">저장</button>');
$('modalBody').innerHTML =
'<p class="modal-note">화이트리스트에 넣으면 그 대상만 청취(비어있으면 전체 청취), 블랙리스트는 제외됩니다. 유저/역할별로 추가할 수 있어요.</p>'
+'<div class="lst-row"><select id="lstType"><option value="user">유저</option><option value="role">역할</option></select>'
+'<input id="lstSearch" class="lst-search" placeholder="이름으로 검색"></div>'
+'<div class="lst-results" id="lstResults"></div>'
+'<div class="lst-h">화이트리스트 (그 대상만 청취)</div><div class="chips" id="chipsW"></div>'
+'<div class="lst-h">블랙리스트 (제외)</div><div class="chips" id="chipsB"></div>';
const has=(arr,id)=>(arr||[]).some(x=>x.id===id);
function add(kind,item){ const k=LKEY[kind]; if(!has(lists[k],item.id)) lists[k].push(item); renderChips(); }
function rm(k,id){ lists[k]=(lists[k]||[]).filter(x=>x.id!==id); renderChips(); }
function renderResults(){
const type=$('lstType').value, q=$('lstSearch').value.trim().toLowerCase();
const src = type==='user' ? (g.members||[]) : (g.roles||[]);
const rows = src.filter(x=>!q || (x.name||'').toLowerCase().includes(q)).slice(0,100);
$('lstResults').innerHTML = rows.length ? rows.map(x=>
'<div class="lst-item"><span class="nm">'+esc(x.name)+(x.bot?' <span class="rl">(봇)</span>':'')+'</span>'
+'<button class="mini w" data-k="'+(type==='user'?'wu':'wr')+'" data-id="'+esc(x.id)+'" data-nm="'+esc(x.name)+'">+화이트</button>'
+'<button class="mini b" data-k="'+(type==='user'?'bu':'br')+'" data-id="'+esc(x.id)+'" data-nm="'+esc(x.name)+'">+블랙</button></div>'
).join('') : '<div class="lst-item"><span class="rl">결과 없음 · 봇이 아는 멤버/역할만 검색됩니다</span></div>';
}
const chip=(k,cls,x)=>'<span class="chip '+cls+'">'+esc(x.name)+' <button data-k="'+k+'" data-id="'+esc(x.id)+'">✕</button></span>';
const empty='<span class="rl" style="color:var(--muted);font-size:12px">비어있음</span>';
function renderChips(){
$('chipsW').innerHTML = [...lists.whitelistUsers.map(x=>chip('whitelistUsers','w',x)),...lists.whitelistRoles.map(x=>chip('whitelistRoles','w',x))].join('') || (empty+' (전체 청취)');
$('chipsB').innerHTML = [...lists.blacklistUsers.map(x=>chip('blacklistUsers','b',x)),...lists.blacklistRoles.map(x=>chip('blacklistRoles','b',x))].join('') || empty;
}
$('lstType').onchange=renderResults; $('lstSearch').oninput=renderResults;
$('lstResults').onclick=(e)=>{ const b=e.target.closest('.mini'); if(!b)return; add(b.dataset.k,{id:b.dataset.id,name:b.dataset.nm}); };
$('chipsW').onclick=$('chipsB').onclick=(e)=>{ const b=e.target.closest('button'); if(!b)return; rm(b.dataset.k,b.dataset.id); };
$('lstSave').onclick=async()=>{
try{ await fetch('/api/bot/lists',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({guildId,lists})}); toast('청취 필터 저장됨 · 봇에 곧 반영'); closeModal(); }
catch(e){ toast('저장 실패: '+e); }
};
renderResults(); renderChips();
}
$('wlBtn').onclick = openLists; $('blBtn').onclick = openLists;
async function pollBot(){
try{ renderBot(await (await fetch('/api/bot/state')).json()); }catch(e){}
}
pollBot(); setInterval(pollBot, 2500);
connect();
// 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);
</script>
</body>
</html>
"""