Instead of immediately discarding a fragment like "그러면", hold it briefly
(WSAI_FRAGMENT_MERGE_MS, default 5s). If the next utterance arrives in time,
merge it in front and answer the whole thing ("그러면" + "뭐 먹지"); if nothing
follows within the window, it's silently dropped. Also redeployed the wsai-bot
container so the sustained (700ms) barge-in bot.mjs is now live.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1921 lines
99 KiB
Python
1921 lines
99 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 os
|
|
import queue
|
|
import threading
|
|
import time
|
|
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 "감정 태그 없음 · 기본 톤으로 답변"
|
|
|
|
|
|
# Lone connective/fillers that clearly expect more to come ("그러면…"). On their
|
|
# own they carry no answerable intent, so we discard them instead of blurting a
|
|
# confused reply. (A fuller version would buffer and wait for the continuation.)
|
|
_FRAGMENT_FILLERS = {
|
|
"그러면", "그래서", "그런데", "근데", "그리고", "그러니까", "그럼", "그게",
|
|
"저기", "있잖아", "그", "음", "어", "저", "그러", "그니까",
|
|
}
|
|
|
|
|
|
def _is_fragment(text: str) -> bool:
|
|
t = (text or "").strip().rstrip(" .,!?…~").strip()
|
|
return t in _FRAGMENT_FILLERS
|
|
|
|
|
|
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/turns/clear":
|
|
monitor.clear_turns()
|
|
self._send(200, json.dumps({"ok": True}).encode(), "application/json; charset=utf-8")
|
|
elif path == "/api/turns/delete":
|
|
self._handle_turn_delete()
|
|
elif path == "/api/logs/delete":
|
|
self._handle_log_mutate("delete")
|
|
elif path == "/api/logs/edit":
|
|
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_turn_delete(self) -> None:
|
|
"""Delete one conversation turn by id."""
|
|
raw = self._read_body()
|
|
try:
|
|
data = json.loads(raw.decode("utf-8")) if raw else {}
|
|
turn_id = int(data.get("id"))
|
|
except (ValueError, TypeError, AttributeError):
|
|
self._send(400, json.dumps({"ok": False, "error": "id required"}).encode(),
|
|
"application/json; charset=utf-8")
|
|
return
|
|
ok = monitor.delete_turn(turn_id)
|
|
self._send(200 if ok else 404,
|
|
json.dumps({"ok": ok}).encode(), "application/json; charset=utf-8")
|
|
|
|
def _handle_log_mutate(self, action: str) -> None:
|
|
"""Per-line log delete/edit by event id."""
|
|
raw = self._read_body()
|
|
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
|
|
# A lone connective ("그러면") is held here to see if the next utterance
|
|
# continues it; merged if it arrives within the window, else dropped.
|
|
self._pending_fragment: dict | None = None
|
|
self._fragment_window = float(os.environ.get("WSAI_FRAGMENT_MERGE_MS", "5000")) / 1000.0
|
|
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.4)),
|
|
"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 _persist_tts(self) -> None:
|
|
from . import state_store
|
|
state_store.patch("tts", {
|
|
"base": self._tts_base(),
|
|
"overrides": dict(getattr(self.tts, "emotion_overrides", {}) or {}),
|
|
})
|
|
|
|
def _persist_models(self) -> None:
|
|
from . import state_store
|
|
state_store.patch("models", {
|
|
"stt": getattr(self.stt, "model", None),
|
|
"llm": getattr(self.brain, "model", None),
|
|
})
|
|
|
|
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. ``emotion`` selects the target:
|
|
- "all" → set base to the values AND clear every per-emotion override,
|
|
so ALL emotions speak with these values (전체변경).
|
|
- "" / "base" → set the base/공통 values on the live TTS instance.
|
|
- a specific emotion → store (or, if ``reset``, clear) its override."""
|
|
emotion = data.get("emotion")
|
|
if emotion == "all":
|
|
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)))))
|
|
if getattr(self.tts, "emotion_overrides", None):
|
|
self.tts.emotion_overrides.clear() # all emotions inherit the new base
|
|
elif 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)
|
|
self._persist_tts()
|
|
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")
|
|
self._persist_models()
|
|
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")
|
|
self._persist_models()
|
|
return {**self.models_settings()["llm"], "changed": changed}
|
|
|
|
@staticmethod
|
|
def _step(turn, name: str, ms: float) -> None:
|
|
"""Record one finished timing step (STT / LLM / TTS) on a turn."""
|
|
st = turn.step(name)
|
|
st.ok, st.ms = True, float(ms)
|
|
turn._steps.append(st)
|
|
|
|
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()
|
|
self._step(turn, "STT", (time.monotonic() - t0) * 1000) # 인식(+디코드)
|
|
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""}
|
|
now = time.monotonic()
|
|
pending = self._pending_fragment
|
|
if pending and (now - pending["ts"]) > self._fragment_window:
|
|
pending = self._pending_fragment = None # too old — give up on it
|
|
if _is_fragment(heard):
|
|
# A lone connective ("그러면") — hold it and wait for the next
|
|
# utterance to continue it. If nothing follows within the window
|
|
# it's silently dropped (never answered).
|
|
self._pending_fragment = {"text": heard, "ts": now}
|
|
turn.thought("불완전한 말(연결어) — 이어질 말 대기 (없으면 폐기)")
|
|
turn.replied("[대기]")
|
|
turn.finish()
|
|
return {"heard": heard, "reply": "[대기]", "wav": b""}
|
|
if pending:
|
|
# A real utterance arrived in time — merge the held fragment in
|
|
# front and answer the whole thing ("그러면" + "뭐 먹지").
|
|
heard = (pending["text"].rstrip(" .,!?…~") + " " + heard).strip()
|
|
self._pending_fragment = None
|
|
turn.heard(heard)
|
|
t_llm = time.monotonic()
|
|
reply_text = self._think(heard)
|
|
self._step(turn, "LLM" if self.brain else "echo", (time.monotonic() - t_llm) * 1000)
|
|
turn.thought(_thought_summary(reply_text))
|
|
turn.replied(reply_text)
|
|
t_tts = time.monotonic()
|
|
out_path = self._submit(self.tts.synth(_speech_text(reply_text)))
|
|
with open(out_path, "rb") as f:
|
|
reply_wav = f.read()
|
|
self._step(turn, "TTS", (time.monotonic() - t_tts) * 1000) # 합성
|
|
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"LLM 응답 실패: {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;position:relative}
|
|
@keyframes rise{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
|
|
.tdel{margin-left:auto;background:none;border:none;color:var(--muted);cursor:pointer;font-size:14px;
|
|
line-height:1;padding:2px 4px;border-radius:6px}
|
|
.tdel:hover{color:var(--err);background:#2a1620}
|
|
.turn.active{border-color:#2b5d86;box-shadow:0 0 0 1px #14324a inset}
|
|
.turn.error{border-color:#5c2530}
|
|
.trow{display:flex;align-items:baseline;gap:10px;margin-bottom:8px}
|
|
.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}
|
|
.role-h{cursor:pointer;user-select:none}
|
|
.role-h .rcaret{display:inline-block;width:11px;color:var(--muted);font-weight:400}
|
|
.role-members{background:#0c141d;border-top:1px solid #16202b}
|
|
.role-members .lst-item{padding-left:26px}
|
|
.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 → LLM → 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>
|
|
<div class="comp" id="comp"></div>
|
|
<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.40x</b>
|
|
<input type="range" id="tSpeed" min="0.5" max="2.0" step="0.05" value="1.4"></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="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>
|
|
<button id="turnsClear" 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/LLM/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:'LLM', 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> '+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>'
|
|
+'<button class="tdel" data-id="'+t.id+'" title="이 대화 삭제">✕</button></div>'
|
|
+'<div class="line"><span class="tag">들음</span><span class="heard">'+(t.heard?esc(t.heard):'<i style="color:var(--muted)">(수신 대기)</i>')+'</span></div>'
|
|
+'<div class="line"><span class="tag">생각</span><span class="thought">'+(t.thought?esc(t.thought):'<i style="color:var(--muted)">…</i>')+'</span></div>'
|
|
+'<div class="line"><span class="tag">답변</span><span class="reply">'+(t.reply?esc(t.reply):'<i style="color:var(--muted)">…생각 중</i>')+'</span></div>'
|
|
+(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 removeTurn(id){
|
|
turns.delete(id);
|
|
const el=$('turn-'+id); if(el) el.remove();
|
|
if(turns.size===0) $('empty').style.display='block';
|
|
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==='[잡음]') || (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==='turn_deleted') removeTurn(ev.id);
|
|
else if(ev.type==='turns_cleared') { turns.clear(); $('turns').innerHTML=''; $('empty').style.display='block'; applyTurnFilter(); }
|
|
else if(ev.type==='log') { addEvent(ev); if(statusData){ statusData.errors_total=(statusData.errors_total||0)+(ev.level==='error'?1:0); $('s-errors').textContent=statusData.errors_total; } }
|
|
else if(ev.type==='logs_cleared') { logEvents=[]; renderLogs(); }
|
|
else if(ev.type==='log_deleted') { logEvents=logEvents.filter(e=>e.id!==ev.id); renderLogs(); }
|
|
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.4, 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' && key!=='all' && ttsState.overrides && ttsState.overrides[key])
|
|
return Object.assign({}, base, ttsState.overrides[key]);
|
|
return base; // 'all' and 'base' both edit the base values
|
|
}
|
|
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='';
|
|
const allOpt=document.createElement('option'); // 최상단 전체변경
|
|
allOpt.value='all'; allOpt.textContent='🌐 전체변경 (모든 감정)'; sel.appendChild(allOpt);
|
|
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 = ()=>{ const k=$('tEmotion').value; ttsSet(ttsValuesFor(k));
|
|
$('tStat').textContent = k==='all'
|
|
? '모든 감정을 한 번에 조절 중입니다 (적용 시 전체 반영).'
|
|
: (k==='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==='all'?'모든 감정':(key==='base'?'기본(공통)':'해당 감정'))+' 적용됨 · 다음 답변부터 반영'); }
|
|
else { $('tStat').textContent='적용 실패: '+(j.error||''); }
|
|
}catch(e){ $('tStat').textContent='적용 오류: '+e; }
|
|
};
|
|
$('tReset').onclick = async ()=>{
|
|
const key=$('tEmotion').value||'base';
|
|
if(key==='base' || key==='all'){ 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="bot">봇</option>'
|
|
+'<option value="voice">통화방</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(); }
|
|
const NO_RES='<div class="lst-item"><span class="rl">결과 없음 · 봇이 아는 멤버/역할만 검색됩니다</span></div>';
|
|
function memberRow(m){
|
|
return '<div class="lst-item"><span class="nm">'+esc(m.name)+(m.bot?' <span class="rl">(봇)</span>':'')+'</span>'
|
|
+'<button class="mini w" data-k="wu" data-id="'+esc(m.id)+'" data-nm="'+esc(m.name)+'">+화이트</button>'
|
|
+'<button class="mini b" data-k="bu" data-id="'+esc(m.id)+'" data-nm="'+esc(m.name)+'">+블랙</button></div>';
|
|
}
|
|
function renderResults(){
|
|
const type=$('lstType').value, q=$('lstSearch').value.trim().toLowerCase();
|
|
const members=(g.members||[]); const byId={}; members.forEach(m=>byId[m.id]=m);
|
|
const match=(n)=>!q||((n||'').toLowerCase().includes(q));
|
|
let html='';
|
|
if(type==='user' || type==='bot'){
|
|
const rows=members.filter(m=>(type==='bot'?m.bot:!m.bot)&&match(m.name)).slice(0,300);
|
|
html = rows.length ? rows.map(memberRow).join('') : NO_RES;
|
|
} else if(type==='voice'){
|
|
const vm=((botState&&botState.members)||[]).map(m=>({id:m.id,name:m.name,bot:!!(byId[m.id]&&byId[m.id].bot)}));
|
|
const rows=vm.filter(m=>match(m.name)).slice(0,300);
|
|
html = rows.length ? rows.map(memberRow).join('')
|
|
: '<div class="lst-item"><span class="rl">봇이 통화방에 없거나 참여자가 없습니다</span></div>';
|
|
} else { // role: 역할 등록 + 펼치면 역할 아래 유저(봇 포함) 등록
|
|
const roles=(g.roles||[]).filter(r=>match(r.name)).slice(0,300);
|
|
html = roles.length ? roles.map(r=>{
|
|
const rid=esc(r.id), rmem=members.filter(m=>(m.roleIds||[]).includes(r.id));
|
|
return '<div class="lst-role">'
|
|
+'<div class="lst-item"><span class="nm role-h" data-rid="'+rid+'"><b class="rcaret">▸</b> '+esc(r.name)+' <span class="rl">('+rmem.length+'명)</span></span>'
|
|
+'<button class="mini w" data-k="wr" data-id="'+rid+'" data-nm="'+esc(r.name)+'">+화이트</button>'
|
|
+'<button class="mini b" data-k="br" data-id="'+rid+'" data-nm="'+esc(r.name)+'">+블랙</button></div>'
|
|
+'<div class="role-members" data-rid="'+rid+'" style="display:none">'
|
|
+(rmem.length? rmem.map(memberRow).join('') : '<div class="lst-item"><span class="rl">이 역할의 멤버 없음</span></div>')
|
|
+'</div></div>';
|
|
}).join('') : NO_RES;
|
|
}
|
|
$('lstResults').innerHTML = html;
|
|
}
|
|
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 rh=e.target.closest('.role-h');
|
|
if(rh){ const box=$('lstResults').querySelector('.role-members[data-rid="'+rh.dataset.rid+'"]');
|
|
if(box){ const open=box.style.display==='none'; box.style.display=open?'block':'none';
|
|
const c=rh.querySelector('.rcaret'); if(c) c.textContent=open?'▾':'▸'; } return; }
|
|
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(); };
|
|
// 대화 로그: 개별 ✕ 삭제 + 전체 삭제.
|
|
$('turns').addEventListener('click', async (e)=>{
|
|
const b=e.target.closest('.tdel'); if(!b) return;
|
|
const id=Number(b.dataset.id); removeTurn(id); // optimistic
|
|
try{ await fetch('/api/turns/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})}); }catch(_){}
|
|
});
|
|
$('turnsClear').onclick=async ()=>{
|
|
if(!turns.size){ toast('삭제할 대화가 없습니다'); return; }
|
|
if(!confirm('대화 로그를 모두 삭제할까요?')) return;
|
|
try{ await fetch('/api/turns/clear',{method:'POST'}); toast('대화 로그를 모두 삭제했습니다'); }
|
|
catch(e){ toast('삭제 실패: '+e); }
|
|
};
|
|
|
|
async function pollBot(){
|
|
try{ renderBot(await (await fetch('/api/bot/state')).json()); }catch(e){}
|
|
}
|
|
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>
|
|
"""
|