Files
watch_sceen_ai/wsai/dashboard.py
EJClaw ed5f328889 fix(dashboard): CONNECT log TypeError when bot identity is a dict
The bot reports identity as {id, username, tag}; the CONNECT log concatenated
that dict onto a string, raising TypeError and 500ing /api/bot/report (breaking
the bot's command/settings round-trip). Extract a string field (tag/username/id)
before formatting.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-26 23:54:54 +09:00

1764 lines
90 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 == "/api/tts/settings":
self._handle_tts_settings_get()
elif path == "/api/bot/settings":
self._send_json({"ok": True, "settings": dash.bot.get_settings()})
elif path == "/api/models":
self._send_json(dash.models_settings())
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()
elif path == "/api/tts/settings":
self._handle_tts_settings_post()
elif path == "/api/tts/preview":
self._handle_tts_preview()
elif path == "/api/bot/settings":
self._handle_bot_settings_post()
elif path == "/api/models/stt":
self._handle_model_switch("stt")
elif path == "/api/models/llm":
self._handle_model_switch("llm")
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 "")
guild = urllib.parse.unquote(self.headers.get("X-Guild-Name", "") or "")
channel = urllib.parse.unquote(self.headers.get("X-Channel-Name", "") or "")
try:
res = dash.voice_turn(raw, speaker=speaker, guild=guild, channel=channel)
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 "봇 프롬프트가 기본값으로 초기화되었습니다", cat="PROMPT")
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
was_connected = dash.bot.state().get("connected")
dash.bot.report(data)
if not was_connected: # transition disconnected -> connected
# identity is reported as {id, username, tag}; pull a string only
# (concatenating a dict here would TypeError and 500 the report).
ident = data.get("identity")
if isinstance(ident, dict):
who = ident.get("tag") or ident.get("username") or ident.get("id") or ""
else:
who = ident if isinstance(ident, str) else ""
monitor.log("info", f"디스코드 봇 연결됨{(' · ' + who) if who else ''}", cat="CONNECT")
# Hand the bot any queued commands + the current listen filters +
# behaviour settings 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(),
"settings": dash.bot.get_settings()})
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})", cat="VOICE")
else:
cid = dash.bot.enqueue({"type": "leave"})
monitor.log("info", "음성채널 나가기 요청", cat="VOICE")
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})", cat="FILTER")
self._send_json({"ok": True, "guildId": guild_id, "lists": saved})
def _handle_model_switch(self, which: str) -> None:
"""Switch the STT size or the LLM model live."""
raw = self._read_body()
try:
data = json.loads(raw.decode("utf-8")) if raw else {}
model = data.get("model")
except (ValueError, AttributeError):
self._send_json({"ok": False, "error": "invalid JSON"}, 400)
return
try:
if which == "stt":
res = dash.set_stt_model(model)
else:
res = dash.set_llm_model(model)
except Exception as exc: # noqa: BLE001 — surface the reason to the page
self._send_json({"ok": False, "error": f"{type(exc).__name__}: {exc}"}, 400)
return
self._send_json({"ok": True, which: res})
def _handle_bot_settings_post(self) -> None:
"""Save a bot behaviour toggle (e.g. bargeIn). The bot reads the new
value on its next report round-trip."""
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
settings = dash.bot.set_settings(data)
monitor.log("info", "봇 설정 변경: " + json.dumps(settings, ensure_ascii=False), cat="SETTING")
self._send_json({"ok": True, "settings": settings})
def _handle_tts_settings_get(self) -> None:
"""Return the live TTS controls (speed/word_gap/sentence_gap/pitch)
and their slider ranges so the page can render the panel."""
if dash.tts is None:
self._send_json({"ok": False, "error": "TTS not enabled"}, 503)
return
self._send_json(dash.tts_settings())
def _handle_tts_settings_post(self) -> None:
"""Apply new TTS controls to the live bot voice (next reply uses them)."""
if dash.tts is None:
self._send_json({"ok": False, "error": "TTS not enabled"}, 503)
return
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
res = dash.set_tts_settings(data)
tgt = data.get("emotion") or "base"
monitor.log("info", f"봇 목소리 파라미터 적용 ({tgt}): base="
+ json.dumps(res["base"], ensure_ascii=False)
+ " overrides=" + json.dumps(res["overrides"], ensure_ascii=False),
cat="TTS")
self._send_json(res)
def _handle_tts_preview(self) -> None:
"""Synthesize a short sample with the supplied controls WITHOUT
changing the live bot voice, and return the wav for playback."""
if dash.tts is None:
self._send_json({"ok": False, "error": "TTS not enabled"}, 503)
return
raw = self._read_body()
try:
data = json.loads(raw.decode("utf-8")) if raw else {}
text = (data.get("text") or "").strip()
except (ValueError, AttributeError):
self._send_json({"ok": False, "error": "invalid JSON"}, 400)
return
if not text:
self._send_json({"ok": False, "error": "text required"}, 400)
return
try:
wav = dash.tts_preview(text, data)
except Exception as exc: # noqa: BLE001 — surface the reason to the page
log.exception("TTS preview failed")
self._send_json({"ok": False, "error": f"{type(exc).__name__}: {exc}"}, 500)
return
self._send(200, wav, "audio/wav")
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())
# -- TTS voice controls (dashboard sliders) -------------------------- #
# (min, max, step) for each slider — mirrors the tts_site manual ranges.
_TTS_RANGES = {
"speed": (0.5, 2.0, 0.05),
"word_gap": (-0.2, 0.5, 0.01),
"sentence_gap": (-0.5, 1.5, 0.05),
"pitch": (-12.0, 12.0, 1.0),
}
def _tts_base(self) -> dict:
t = self.tts
return {
"speed": float(getattr(t, "speed", 1.35)),
"word_gap": float(getattr(t, "word_gap", -0.07)),
"sentence_gap": float(getattr(t, "sentence_gap", -0.30)),
"pitch": float(getattr(t, "pitch", 0.0)),
}
def tts_settings(self) -> dict:
"""Base (공통) controls + the per-emotion overrides + the emotion list
(canonical key + Korean label) so the dashboard can offer per-emotion
tuning. Emotions with no override inherit base (모든 감정 = 기본값)."""
from .backends.emotion import EMOTION_LABELS
overrides = dict(getattr(self.tts, "emotion_overrides", {}) or {})
return {
"ok": True,
"base": self._tts_base(),
"overrides": overrides,
"emotions": [{"key": k, "label": v} for k, v in EMOTION_LABELS.items()],
"ranges": {k: list(v) for k, v in self._TTS_RANGES.items()},
}
def set_tts_settings(self, data: dict) -> dict:
"""Apply controls. Without ``emotion`` (or emotion == "base"), set the
base/공통 values on the live TTS instance. With a specific emotion, store
(or, if ``reset``, clear) that emotion's override."""
emotion = data.get("emotion")
if not emotion or emotion == "base":
for key, (lo, hi, _step) in self._TTS_RANGES.items():
v = data.get(key)
if v is not None:
setattr(self.tts, key, float(max(lo, min(hi, float(v)))))
else:
store = getattr(self.tts, "emotion_overrides", None)
if store is None:
store = self.tts.emotion_overrides = {}
if data.get("reset"):
store.pop(emotion, None)
else:
store[emotion] = self._tts_overrides(data)
return self.tts_settings()
def _tts_overrides(self, data: dict) -> dict:
out = {}
for key, (lo, hi, _step) in self._TTS_RANGES.items():
v = data.get(key)
if v is not None:
out[key] = float(max(lo, min(hi, float(v))))
return out
def tts_preview(self, text: str, data: dict | None = None) -> bytes:
"""Synthesize `text` with per-call control overrides (no change to the
live bot voice) and return the wav bytes."""
import os
if self.tts is None:
raise RuntimeError("TTS not enabled")
overrides = self._tts_overrides(data or {})
out_path = self._submit(self.tts.synth(text, **overrides))
with open(out_path, "rb") as f:
wav = f.read()
try:
os.remove(out_path)
except OSError:
pass
return wav
# -- live model switching (STT size / LLM model) --------------------- #
STT_OPTIONS = ["tiny", "base", "small", "medium", "large-v3"]
LLM_OPTIONS = ["claude-haiku-4-5", "claude-sonnet-4-5"]
def models_settings(self) -> dict:
stt = self.stt
brain = self.brain
return {
"ok": True,
"stt": {
"enabled": stt is not None,
"current": getattr(stt, "model", None),
"ready": bool(getattr(stt, "_ready", False)),
"device": getattr(stt, "resolved_device", None),
"options": self.STT_OPTIONS,
},
"llm": {
"enabled": brain is not None,
"current": getattr(brain, "model", None),
"options": self.LLM_OPTIONS,
},
}
def set_stt_model(self, model: str) -> dict:
"""Switch the whisper model size live. Tears down the current worker and
warms the new one in the BACKGROUND so the HTTP call returns fast (the
first switch to a not-yet-downloaded size fetches it, which can take a
while); the next utterance waits for the reload if it isn't warm yet."""
import asyncio
if self.stt is None:
raise RuntimeError("STT not enabled")
model = str(model).strip()
if not model:
raise ValueError("model required")
if model not in self.STT_OPTIONS:
raise ValueError(f"unknown STT model: {model}")
changed = model != self.stt.model
if changed:
self.stt.model = model
self._submit(self.stt.aclose()) # drop old worker (fast)
# Reload+warm in the background; don't block the HTTP response.
asyncio.run_coroutine_threadsafe(self._warm_stt_bg(), self._loop)
self.monitor.log("info", f"STT 모델 전환 시작: {model} (로딩 중…)", cat="MODEL")
return {**self.models_settings()["stt"], "changed": changed}
async def _warm_stt_bg(self) -> None:
try:
await self.stt.warmup()
self.monitor.log("info", f"STT 모델 로드 완료: {self.stt.model} "
f"(device={getattr(self.stt, 'resolved_device', '?')})", cat="READY")
except Exception as exc: # noqa: BLE001
self.monitor.log("error", f"STT 모델 로드 실패({self.stt.model}): {exc}", cat="MODEL")
def set_llm_model(self, model: str) -> dict:
"""Switch the Claude model live — applied on the next reply (no reload)."""
if self.brain is None:
raise RuntimeError("LLM(brain) not enabled — echo 모드입니다")
model = str(model).strip()
if model not in self.LLM_OPTIONS:
raise ValueError(f"unknown LLM model: {model}")
changed = model != self.brain.model
self.brain.model = model
if changed:
self.monitor.log("info", f"LLM 모델 변경: {model} (다음 답변부터 적용)", cat="MODEL")
return {**self.models_settings()["llm"], "changed": changed}
def voice_turn(self, audio_bytes: bytes, speaker: str = "",
guild: str = "", channel: 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)
turn.guild, turn.channel = guild, channel # for 서버별/채널별 필터
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()
u = getattr(reply, "usage", None)
if u:
self.monitor.add_claude_usage(u.get("input", 0), u.get("output", 0))
except Exception as exc: # noqa: BLE001
log.exception("brain failed")
self.monitor.log("error", f"두뇌 응답 실패: {exc}", cat="BRAIN")
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)}
.sttbox h2.collap{cursor:pointer;user-select:none;margin:0}
.sttbox h2.collap span{display:inline-block;width:12px;color:var(--muted)}
.cfgrow{display:flex;gap:9px;align-items:center;font-size:13px;color:var(--fg);cursor:pointer;padding:4px 0}
.cfgrow input[type=checkbox]{width:16px;height:16px;accent-color:#3aa0ff;cursor:pointer}
.cfghint{color:var(--muted);font-weight:400;font-size:12px}
.mload{display:flex;align-items:center;gap:9px;margin-top:10px;padding:8px 12px;
background:#0f2333;border:1px solid #234a63;border-radius:10px;font-size:13px;color:var(--fg)}
.mload .bar{flex:1;height:6px;border-radius:4px;background:#12304a;overflow:hidden;position:relative}
.spin{width:15px;height:15px;border:2px solid #2a4a63;border-top-color:#3aa0ff;border-radius:50%;
display:inline-block;animation:spin 0.8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.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}
.ttsgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:12px 18px;margin:2px 0 12px}
.ttsgrid label{display:flex;flex-direction:column;gap:6px;font-size:12.5px;color:var(--muted)}
.ttsgrid label b{color:var(--fg);font-weight:600}
.ttsgrid input[type=range]{width:100%;accent-color:#3aa0ff}
.ttstext{flex:1;min-width:180px;background:#0f2333;border:1px solid #234a63;color:var(--fg);border-radius:10px;padding:8px 12px;font-size:13px}
.ttssel{background:#0f2333;border:1px solid #234a63;color:var(--fg);border-radius:10px;padding:7px 10px;font-size:13px;margin-left:8px}
.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}
/* 대화 로그 검색 필터 (시간·유저·서버·채널·내용) */
.tfilter{display:flex;gap:8px;align-items:center;flex-wrap:wrap;background:var(--panel);
border:1px solid var(--line);border-radius:12px;padding:8px 12px;margin:0 0 12px}
.tfilter input,.tfilter select{background:var(--panel2);border:1px solid var(--line);color:var(--fg);
border-radius:8px;padding:6px 9px;font-size:12.5px}
.tfilter input{width:118px}
.tf-label{color:var(--muted);font-size:12px;font-weight:600}
.tf-count{color:var(--muted);font-size:11.5px;margin-left:auto}
/* 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:90vh;box-shadow:0 -8px 24px rgba(0,0,0,.35)}
.logresize{height:7px;cursor:ns-resize;background:#0d141c;border-bottom:1px solid var(--line);
flex:0 0 auto;touch-action:none}
.logresize:hover{background:#1d3d54}
.logresize::before{content:"";display:block;width:44px;height:3px;margin:2px auto 0;
border-radius:2px;background:#2a3a49}
.logdock.collapsed .logresize{display:none}
.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)}
.lc-tag{flex:0 0 auto;min-width:62px;text-align:center;font-size:10px;font-weight:700;
letter-spacing:.3px;padding:1px 7px;border-radius:9px;background:#1b2b3a;color:#8fb3d6;
border:1px solid #24405a}
.lc-READY{background:#12331f;color:#7fe0a0;border-color:#1f5a34}
.lc-CONNECT{background:#12283f;color:#7fb8ff;border-color:#22507f}
.lc-MODEL{background:#2a2340;color:#c3a6ff;border-color:#463a7a}
.lc-TTS{background:#0f2f33;color:#6fe0d8;border-color:#1f5a5a}
.lc-VOICE{background:#33280f;color:#e0c06f;border-color:#5a481f}
.lc-FILTER{background:#301a2a;color:#e79fd0;border-color:#5a2545}
.lc-SETTING{background:#22303a;color:#9fc7e0;border-color:#2f5568}
.lc-TURN,.lc-ERROR{background:#3a1520;color:#ffb3bb;border-color:#7a2531}
.lc-BRAIN,.lc-VISION{background:#2a2340;color:#c3a6ff;border-color:#463a7a}
.lc-WARN{background:#332a10;color:#ffd98a;border-color:#5a4a1f}
.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" title="오늘 사용한 클로드 토큰(입력+출력)"><b id="s-claude-today">0</b><span>오늘 토큰</span></div>
<div class="stat" title="최근 7일 사용한 클로드 토큰(입력+출력)"><b id="s-claude-week">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="listsBtn" class="btn hbtn">👤 유저 등록/관리</button>
<span class="parts" id="parts"></span>
</section>
<section class="sttbox" id="ttsbox" style="display:none">
<h2 id="ttsToggle" class="collap"><span id="ttsCaret">▸</span> 🔊 봇 목소리(TTS) 조절 · MeloTTS</h2>
<div id="ttsBody" style="display:none">
<div class="sttrow" style="margin-bottom:10px">
<label style="font-size:12.5px;color:var(--muted)">감정 선택
<select id="tEmotion" class="ttssel"></select></label>
<span id="tEmoNote" class="sttstat">감정을 고르면 그 감정만 따로 조절합니다. 기본(공통)은 태그 없는 말투에 적용됩니다.</span>
</div>
<div class="ttsgrid">
<label>글자 속도 <b id="tvSpeed">1.35x</b>
<input type="range" id="tSpeed" min="0.5" max="2.0" step="0.05" value="1.35"></label>
<label>단어 간격 <b id="tvWord">-70 ms</b>
<input type="range" id="tWord" min="-0.2" max="0.5" step="0.01" value="-0.07"></label>
<label>문장 간격 <b id="tvSent">-0.30 s</b>
<input type="range" id="tSent" min="-0.5" max="1.5" step="0.05" value="-0.3"></label>
<label>피치 <b id="tvPitch">0 반음</b>
<input type="range" id="tPitch" min="-12" max="12" step="1" value="0"></label>
</div>
<div class="sttrow">
<input id="tText" class="ttstext" placeholder="미리들을 문장" value="안녕하세요, 목소리 미리듣기입니다.">
<button id="tPreview" class="btn">▶ 미리듣기</button>
<button id="tApply" class="btn">💾 봇에 적용</button>
<button id="tReset" class="btn hbtn">기본값</button>
<span id="tStat" class="sttstat">슬라이더로 조절 → 미리듣기로 확인 → 봇에 적용하면 다음 답변부터 반영됩니다.</span>
</div>
<audio id="tAudio" controls style="display:none;margin-top:10px;width:100%"></audio>
</div>
</section>
<section class="sttbox" id="botcfgbox">
<h2 id="botcfgToggle" class="collap"><span id="botcfgCaret">▸</span> ⚙️ 봇 관련 설정</h2>
<div id="botcfgBody" style="display:none">
<label class="cfgrow"><input type="checkbox" id="cfgBargeIn" checked>
<span>유저 목소리 들을 때 봇이 말하던 것 중지 <b class="cfghint">(기본: 켜짐)</b></span></label>
</div>
</section>
<section class="sttbox" id="logcfgbox">
<h2 id="logcfgToggle" class="collap"><span id="logcfgCaret">▸</span> 🧹 로그 관련 설정</h2>
<div id="logcfgBody" style="display:none">
<label class="cfgrow"><input type="checkbox" id="cfgHideNoise" checked>
<span>잡음 로그 표시 안 함 <b class="cfghint">(들음: (빈 결과) 또는 답변: [잡음]) · 기본: 켜짐</b></span></label>
</div>
</section>
<section class="sttbox" id="modelbox">
<h2 id="modelToggle" class="collap"><span id="modelCaret">▸</span> 🧠 모델 (STT · LLM)</h2>
<div id="modelBody" style="display:none">
<div class="sttrow">
<label style="font-size:12.5px;color:var(--muted)">STT(귀)
<select id="mSTT" class="ttssel"></select></label>
<button id="mSTTapply" class="btn">적용</button>
<span id="mSTTstat" class="sttstat"></span>
</div>
<div class="sttrow" style="margin-top:8px">
<label style="font-size:12.5px;color:var(--muted)">LLM(두뇌)
<select id="mLLM" class="ttssel"></select></label>
<button id="mLLMapply" class="btn">적용</button>
<span id="mLLMstat" class="sttstat"></span>
</div>
<div id="mLoad" class="mload" style="display:none">
<span class="spin"></span><span id="mLoadText">로딩 중…</span>
</div>
<p class="cfghint" style="margin:8px 0 0">STT는 전환 시 모델을 다시 로드합니다(처음 medium/large-v3는 다운로드로 수 분 걸릴 수 있어요). LLM은 다음 답변부터 즉시 적용됩니다. 서비스 재시작 시 기본값(medium · Haiku)으로 돌아갑니다.</p>
</div>
</section>
<div class="demobar" id="demobar" style="display:none"></div>
<div class="comp" id="comp"></div>
<div class="tfilter" id="tfilter">
<span class="tf-label">대화 로그 검색</span>
<select id="tfTime">
<option value="0">전체 시간</option>
<option value="5">최근 5분</option>
<option value="30">최근 30분</option>
<option value="60">최근 1시간</option>
<option value="180">최근 3시간</option>
</select>
<input id="tfUser" placeholder="유저(발화자)">
<input id="tfGuild" placeholder="서버">
<input id="tfChannel" placeholder="채널">
<input id="tfText" placeholder="내용(들음/답변)">
<button id="tfClear" class="btn hbtn">초기화</button>
<span id="tfCount" class="tf-count"></span>
</div>
<div id="turns"></div>
<div id="empty" class="empty">아직 대화가 없습니다. 사용자가 말하면 여기에 단계별로 나타납니다.</div>
</main>
<div id="logdock" class="logdock">
<div id="logResize" class="logresize" title="드래그해서 로그 높이 조절"></div>
<div class="logbar">
<button id="logToggle" class="logtoggle">▾ 이벤트 / 오류 로그</button>
<input id="logSearch" class="logsearch" placeholder="로그 검색 (텍스트)">
<select id="logLevel" class="logsel" title="레벨(타입)">
<option value="">레벨 전체</option>
<option value="error">오류(ERROR)</option>
<option value="warn">경고(WARN)</option>
<option value="info">정보(INFO)</option>
</select>
<select id="logCat" class="logsel" title="이벤트 종류"><option value="">종류 전체</option></select>
<select id="logTime" class="logsel" title="시간">
<option value="0">전체 시간</option>
<option value="5">최근 5분</option>
<option value="30">최근 30분</option>
<option value="60">최근 1시간</option>
<option value="180">최근 3시간</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 fmtNum(n){ n=n||0; if(n>=1e6) return (n/1e6).toFixed(2)+'M'; if(n>=1e3) return (n/1e3).toFixed(1)+'k'; return ''+n; }
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 cu = s.claude_usage || {today:{input:0,output:0,requests:0}, week:{input:0,output:0,requests:0}};
const td=cu.today||{}, wk=cu.week||{};
const te=$('s-claude-today');
if(te){ te.textContent = fmtNum((td.input||0)+(td.output||0));
te.parentElement.title = '오늘 클로드 — 입력 '+(td.input||0).toLocaleString()+' · 출력 '+(td.output||0).toLocaleString()+' 토큰 · 요청 '+(td.requests||0)+'회'; }
const we=$('s-claude-week');
if(we){ we.textContent = fmtNum((wk.input||0)+(wk.output||0));
we.parentElement.title = '최근 7일 클로드 — 입력 '+(wk.input||0).toLocaleString()+' · 출력 '+(wk.output||0).toLocaleString()+' 토큰 · 요청 '+(wk.requests||0)+'회'; }
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 ttsReal = comps.tts && comps.tts!=='mock' && comps.tts!=='none';
$('ttsbox').style.display = ttsReal ? 'block' : 'none';
if(ttsReal) initTts();
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>':'')
+(t.channel?'<span class="badge">🔊 '+esc((t.guild?t.guild+' / ':'')+t.channel)+'</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); }
applyTurnFilter();
}
// --- 대화 로그 검색: 시간·유저·서버·채널·내용 필터 ------------------------ #
function turnFilter(){
return { mins:+$('tfTime').value, user:$('tfUser').value.trim().toLowerCase(),
guild:$('tfGuild').value.trim().toLowerCase(), channel:$('tfChannel').value.trim().toLowerCase(),
text:$('tfText').value.trim().toLowerCase() };
}
function isNoiseTurn(t){
return (t.heard==='(빈 결과)') || (t.reply==='[잡음]');
}
function turnMatches(t, f){
if(hideNoise && isNoiseTurn(t)) return false; // 잡음 로그 표시 안 함 토글
if(f.mins && (Date.now()/1000 - (t.wall||0)) > f.mins*60) return false;
if(f.user && !((t.speaker||'').toLowerCase().includes(f.user))) return false;
if(f.guild && !((t.guild||'').toLowerCase().includes(f.guild))) return false;
if(f.channel && !((t.channel||'').toLowerCase().includes(f.channel))) return false;
if(f.text && !(((t.heard||'')+' '+(t.reply||'')+' '+(t.thought||'')).toLowerCase().includes(f.text))) return false;
return true;
}
function applyTurnFilter(){
const f=turnFilter(); let shown=0;
for(const [id,t] of turns){ const el=$('turn-'+id); if(!el) continue;
const ok=turnMatches(t,f); el.style.display=ok?'':'none'; if(ok) shown++; }
const active = f.mins||f.user||f.guild||f.channel||f.text||hideNoise;
$('tfCount').textContent = turns.size ? (active ? shown+' / '+turns.size+' 대화' : turns.size+' 대화') : '';
}
// --- Bottom terminal log panel: store all events, render filtered ---------- #
let logEvents = []; // {id, level, message, wall}
function logCat(e){ return (e.cat || (e.level||'info').toUpperCase()); }
function logMatches(e){
const lv = $('logLevel').value;
if(lv && e.level!==lv) return false;
const c = $('logCat').value;
if(c && logCat(e)!==c) return false;
const mins = +$('logTime').value;
if(mins && (Date.now()/1000 - (e.wall||0)) > mins*60) return false;
const q = $('logSearch').value.trim().toLowerCase();
if(q && !((e.message||'').toLowerCase().includes(q) || logCat(e).toLowerCase().includes(q) || fmtTime(e.wall).includes(q))) return false;
return true;
}
// Keep the 종류(카테고리) dropdown in sync with whatever categories appear.
function refreshCatOptions(){
const sel=$('logCat'); const cur=sel.value;
const cats=[...new Set(logEvents.map(logCat))].sort();
sel.innerHTML='<option value="">종류 전체</option>'
+ cats.map(c=>'<option value="'+c+'">'+esc(c)+'</option>').join('');
sel.value = cats.includes(cur) ? cur : '';
}
function renderLogs(){
const body = $('logbody');
refreshCatOptions();
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="lc-tag lc-'+esc(logCat(e))+'">'+esc(logCat(e))+'</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 : '') + '줄';
if(typeof syncDockPad==='function') syncDockPad();
}
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();} }
};
}
// --- 봇 관련 설정 / 로그 관련 설정 (접이식) ------------------------------- #
let hideNoise = localStorage.getItem('wsai_hideNoise') !== '0'; // 기본: 켜짐
function wireCollapse(toggleId, bodyId, caretId){
$(toggleId).onclick = ()=>{
const b=$(bodyId), open=b.style.display==='none';
b.style.display = open ? 'block' : 'none';
$(caretId).textContent = open ? '▾' : '▸';
};
}
(function initSettingsPanels(){
wireCollapse('botcfgToggle','botcfgBody','botcfgCaret');
wireCollapse('logcfgToggle','logcfgBody','logcfgCaret');
// 봇 설정: 바지-인(유저가 말하면 봇 TTS 중지) — 서버에 저장, 봇이 읽어감.
$('cfgBargeIn').onchange = async ()=>{
try{ await fetch('/api/bot/settings',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({bargeIn:$('cfgBargeIn').checked})});
toast('봇 설정 저장됨'); }catch(e){}
};
fetch('/api/bot/settings').then(r=>r.json()).then(j=>{
if(j&&j.ok&&j.settings) $('cfgBargeIn').checked = j.settings.bargeIn !== false;
}).catch(()=>{});
// 로그 설정: 잡음 로그 표시 안 함 — 클라이언트 필터(로컬 저장).
$('cfgHideNoise').checked = hideNoise;
$('cfgHideNoise').onchange = ()=>{
hideNoise = $('cfgHideNoise').checked;
localStorage.setItem('wsai_hideNoise', hideNoise ? '1':'0');
applyTurnFilter();
};
// 모델 설정: STT 크기 / LLM 모델 실시간 전환.
wireCollapse('modelToggle','modelBody','modelCaret');
initModels();
})();
const STT_LABEL = {tiny:'tiny (가장 빠름)', base:'base', small:'small (빠름)',
medium:'medium (기본·정확)', 'large-v3':'large-v3 (최고 정확도)'};
const LLM_LABEL = {'claude-haiku-4-5':'Haiku 4.5 (가장 빠름)',
'claude-sonnet-4-5':'Sonnet 4.5 (고품질·조금 느림)'};
function fillSel(sel, options, current, labels){
sel.innerHTML='';
options.forEach(o=>{ const el=document.createElement('option');
el.value=o; el.textContent=(labels[o]||o); sel.appendChild(el); });
if(current) sel.value=current;
}
async function initModels(){
let j; try{ j=await (await fetch('/api/models')).json(); }catch(e){ return; }
if(!j||!j.ok) return;
if(j.stt.enabled){ fillSel($('mSTT'), j.stt.options, j.stt.current, STT_LABEL);
$('mSTTstat').textContent='현재: '+(j.stt.current||'?')+(j.stt.device?(' · '+j.stt.device):''); }
else { $('mSTT').disabled=$('mSTTapply').disabled=true; $('mSTTstat').textContent='STT 비활성'; }
if(j.llm.enabled){ fillSel($('mLLM'), j.llm.options, j.llm.current, LLM_LABEL);
$('mLLMstat').textContent='현재: '+(LLM_LABEL[j.llm.current]||j.llm.current||'?'); }
else { $('mLLM').disabled=$('mLLMapply').disabled=true; $('mLLMstat').textContent='LLM 비활성(echo 모드)'; }
$('mSTTapply').onclick = async ()=>{
const m=$('mSTT').value;
try{ const r=await fetch('/api/models/stt',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({model:m})}); const jj=await r.json();
if(!jj.ok){ $('mSTTstat').textContent='실패: '+(jj.error||''); return; }
if(!jj.stt.changed){ flash3($('mSTTstat'), '변경사항 없음', '현재: '+(jj.stt.current||m)+(jj.stt.device?(' · '+jj.stt.device):'')); return; }
pollSTTLoad(m); // 실제 변경 → 아래 로딩창 + 완료 시 이벤트 로그
}catch(e){ $('mSTTstat').textContent='오류: '+e; }
};
$('mLLMapply').onclick = async ()=>{
const m=$('mLLM').value;
try{ const r=await fetch('/api/models/llm',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({model:m})}); const jj=await r.json();
if(!jj.ok){ $('mLLMstat').textContent='실패: '+(jj.error||''); return; }
if(!jj.llm.changed){ flash3($('mLLMstat'), '변경사항 없음', '현재: '+(LLM_LABEL[jj.llm.current]||jj.llm.current)); return; }
toast('LLM 모델 변경: '+(LLM_LABEL[m]||m));
$('mLLMstat').textContent='현재: '+(LLM_LABEL[m]||m)+' · 다음 답변부터';
}catch(e){ $('mLLMstat').textContent='오류: '+e; }
};
}
// 3초 동안 임시 메시지를 보여준 뒤 원래(현재 모델) 텍스트로 복귀.
function flash3(el, temp, revert){
el.textContent = temp;
clearTimeout(el._t);
el._t = setTimeout(()=>{ el.textContent = revert; }, 3000);
}
// STT 실제 전환: 아래 로딩창을 띄우고 /api/models를 폴링해 ready가 될 때까지 표시.
let sttPollTimer=null;
async function pollSTTLoad(target){
const load=$('mLoad'), txt=$('mLoadText');
load.style.display='flex';
$('mSTTstat').textContent='전환 중…';
const t0=Date.now();
if(sttPollTimer) clearInterval(sttPollTimer);
const tick=async ()=>{
const sec=Math.round((Date.now()-t0)/1000);
txt.textContent = 'STT '+target+' 로딩 중… (경과 '+sec+'초, 처음이면 다운로드로 수 분 걸릴 수 있어요)';
try{
const j=await (await fetch('/api/models')).json();
if(j&&j.ok&&j.stt.current===target&&j.stt.ready){
clearInterval(sttPollTimer); sttPollTimer=null;
load.style.display='none';
$('mSTTstat').textContent='현재: '+target+(j.stt.device?(' · '+j.stt.device):'')+' · 로드 완료';
toast('STT 모델 로드 완료: '+target);
}
}catch(e){}
};
tick(); sttPollTimer=setInterval(tick, 1500);
}
// --- 봇 목소리(TTS) 조절: 감정별 슬라이더 → 미리듣기 → 봇 적용 -------------- #
let ttsInited = false;
let ttsState = {base:{}, overrides:{}, emotions:[]}; // last-known server state
const TTS_DEFAULT = {speed:1.35, word_gap:-0.07, sentence_gap:-0.3, pitch:0};
function ttsLabels(){
const sp=parseFloat($('tSpeed').value), wg=parseFloat($('tWord').value),
sg=parseFloat($('tSent').value), pt=parseInt($('tPitch').value,10);
$('tvSpeed').textContent = sp.toFixed(2)+'x';
$('tvWord').textContent = Math.round(wg*1000)+' ms';
$('tvSent').textContent = sg.toFixed(2)+' s';
$('tvPitch').textContent = (pt>0?'+'+pt:pt)+' 반음';
}
function ttsVals(){
return {speed:parseFloat($('tSpeed').value), word_gap:parseFloat($('tWord').value),
sentence_gap:parseFloat($('tSent').value), pitch:parseInt($('tPitch').value,10)};
}
function ttsSet(s){
if(!s) return;
if(s.speed!=null) $('tSpeed').value=s.speed;
if(s.word_gap!=null) $('tWord').value=s.word_gap;
if(s.sentence_gap!=null) $('tSent').value=s.sentence_gap;
if(s.pitch!=null) $('tPitch').value=s.pitch;
ttsLabels();
}
// Effective values for the picked emotion: its override merged over base
// (base itself when 기본 or when the emotion has no override).
function ttsValuesFor(key){
const base = Object.assign({}, TTS_DEFAULT, ttsState.base);
if(key && key!=='base' && ttsState.overrides && ttsState.overrides[key])
return Object.assign({}, base, ttsState.overrides[key]);
return base;
}
function ttsApplyState(j){
if(!j || !j.ok) return;
ttsState = {base:j.base||{}, overrides:j.overrides||{}, emotions:j.emotions||ttsState.emotions};
// Refresh dropdown labels to mark which emotions are customised.
const sel=$('tEmotion'); const cur=sel.value||'base';
sel.innerHTML='';
ttsState.emotions.forEach(e=>{
const o=document.createElement('option'); o.value=e.key;
const custom = e.key!=='base' && ttsState.overrides[e.key];
o.textContent = e.label + (custom ? ' ●' : '');
sel.appendChild(o);
});
sel.value = cur;
ttsSet(ttsValuesFor(sel.value));
}
async function initTts(){
if(ttsInited) return; ttsInited = true;
$('ttsToggle').onclick = ()=>{
const b=$('ttsBody'), open=b.style.display==='none';
b.style.display = open ? 'block' : 'none';
$('ttsCaret').textContent = open ? '▾' : '▸';
};
['tSpeed','tWord','tSent','tPitch'].forEach(id => $(id).addEventListener('input', ttsLabels));
$('tEmotion').onchange = ()=>{ ttsSet(ttsValuesFor($('tEmotion').value));
$('tStat').textContent = $('tEmotion').value==='base'
? '기본(공통) 값을 조절 중입니다.' : '"'+$('tEmotion').selectedOptions[0].textContent.replace(' ●','')+'" 감정만 조절 중입니다.'; };
$('tPreview').onclick = async ()=>{
const text=($('tText').value||'').trim();
if(!text){ $('tStat').textContent='미리들을 문장을 입력하세요.'; return; }
$('tStat').textContent='합성 중…';
try{
const r=await fetch('/api/tts/preview',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify(Object.assign({text},ttsVals()))});
if(!r.ok){ $('tStat').textContent='미리듣기 실패: '+(await r.text()); return; }
const blob=await r.blob(); const a=$('tAudio');
a.src=URL.createObjectURL(blob); a.style.display='block'; a.play();
$('tStat').textContent='미리듣기 재생 중 (봇에는 아직 미적용).';
}catch(e){ $('tStat').textContent='미리듣기 오류: '+e; }
};
$('tApply').onclick = async ()=>{
const key=$('tEmotion').value||'base';
try{
const r=await fetch('/api/tts/settings',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify(Object.assign({emotion:key}, ttsVals()))});
const j=await r.json();
if(j.ok){ ttsApplyState(j); toast((key==='base'?'기본(공통)':'해당 감정')+' 적용됨 · 다음 답변부터 반영'); }
else { $('tStat').textContent='적용 실패: '+(j.error||''); }
}catch(e){ $('tStat').textContent='적용 오류: '+e; }
};
$('tReset').onclick = async ()=>{
const key=$('tEmotion').value||'base';
if(key==='base'){ ttsSet(TTS_DEFAULT);
$('tStat').textContent='기본값으로 세팅됨 (적용을 눌러 반영).'; return; }
try{ // clear this emotion's override -> it inherits 기본(공통) again
const r=await fetch('/api/tts/settings',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({emotion:key, reset:true})});
const j=await r.json();
if(j.ok){ ttsApplyState(j); toast('이 감정을 기본(공통)값으로 되돌림'); }
}catch(e){ $('tStat').textContent='되돌리기 오류: '+e; }
};
try{ ttsApplyState(await (await fetch('/api/tts/settings')).json()); }catch(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;
// --- 로그 패널: 열고닫기 / 검색 / 삭제(전체·개별) / 수정 -------------------- #
// Keep enough space at the bottom of the page so the fixed log dock never
// covers the last (bottom-most) conversation turn.
function syncDockPad(){
const dock=$('logdock'), main=document.querySelector('main');
if(dock && main) main.style.paddingBottom = (dock.offsetHeight + 24) + 'px';
}
$('logToggle').onclick = ()=>{
const d=$('logdock'); d.classList.toggle('collapsed');
$('logToggle').textContent = (d.classList.contains('collapsed')?'▸':'▾') + ' 이벤트 / 오류 로그';
syncDockPad();
};
window.addEventListener('resize', syncDockPad);
// Drag the top edge of the log dock to resize its height (persisted).
(function(){
const dock=$('logdock'), body=$('logbody'), handle=$('logResize');
const KEY='wsai_logH';
const maxH = ()=> Math.round(window.innerHeight*0.82);
function setH(px){ const h=Math.max(80, Math.min(maxH(), Math.round(px)));
body.style.height=h+'px'; localStorage.setItem(KEY, h); syncDockPad(); }
setH(parseInt(localStorage.getItem(KEY)||'180', 10));
let startY=0, startH=0, dragging=false;
handle.addEventListener('pointerdown', (e)=>{
if(dock.classList.contains('collapsed')) return;
dragging=true; startY=e.clientY; startH=body.offsetHeight;
try{ handle.setPointerCapture(e.pointerId); }catch(_){}
e.preventDefault();
});
handle.addEventListener('pointermove', (e)=>{ if(dragging) setH(startH + (startY - e.clientY)); });
const end=(e)=>{ if(!dragging) return; dragging=false; try{ handle.releasePointerCapture(e.pointerId); }catch(_){} };
handle.addEventListener('pointerup', end);
handle.addEventListener('pointercancel', end);
})();
$('logSearch').oninput = renderLogs;
$('logLevel').onchange = renderLogs;
$('logCat').onchange = renderLogs;
$('logTime').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 ? '참여자: (없음)' : '');
}
async function sendSelect(guildId, channelId){
try{
await fetch('/api/bot/select',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({guildId, channelId})});
toast(channelId?'음성채널 참여 요청을 보냈습니다':'음성채널에서 나가기 요청을 보냈습니다');
}catch(e){ toast('요청 실패: '+e); }
}
$('guildSel').onchange = ()=>{
userPickedGuild = $('guildSel').value;
renderBot(botState); // 선택 서버의 음성채널 목록을 다시 채운다
// 서버를 '없음'으로 두거나 다른 서버로 바꿔 음성채널이 선택되지 않은 상태면 나간다.
if(!$('vcSel').value) sendSelect($('guildSel').value, '');
};
// 음성채널 변경 → 그 채널로 이동, '없음' → 나가기.
$('vcSel').onchange = ()=> sendSelect($('guildSel').value, $('vcSel').value);
// --- 화이트/블랙리스트 팝업 (유저·역할 검색 → 화이트/블랙 추가·제거) ------ #
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();
}
$('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(); };
async function pollBot(){
try{ renderBot(await (await fetch('/api/bot/state')).json()); }catch(e){}
}
pollBot(); setInterval(pollBot, 2500);
connect();
syncDockPad();
// 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>
"""