Compare commits

...

12 Commits

Author SHA1 Message Date
EJClaw
2ea7d04289 feat(bot): list all server members for whitelist search (gated intent)
To show every server user (not just cached speakers) in the whitelist/blacklist
popup, the bot needs the privileged Server Members Intent. Declaring it while the
Developer Portal toggle is off breaks login, so it's gated behind
WSAI_MEMBERS_INTENT=1: when set, the bot adds GuildMembers intent, fetches each
guild's full member list on ready, and reports it (cap 200→2000). Default off =
unchanged behavior, safe to deploy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 22:20:30 +09:00
EJClaw
d1d4bd1514 feat(dashboard): show Claude usage per day/week instead of per session
Persists per-day token usage (usage_store, ~/.config/wsai/usage.json, survives
restarts) and surfaces it in status as claude_usage.{today,week}. The navbar now
shows two cards — 오늘 토큰 / 주간 토큰 (input+output, with per-card tooltips
breaking down input/output tokens and requests) — replacing the session-only
token count.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 22:20:30 +09:00
EJClaw
7ea07c13ad feat(dashboard): leave voice when server/channel set to 없음
The voice-channel picker already switched channels / left on 없음. Now the
server picker does too: choosing 없음 (or switching to a server with no channel
selected) sends a leave so the bot exits its current voice channel. Factored the
select POST into sendSelect(guildId, channelId).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 22:15:42 +09:00
EJClaw
f1f059a018 feat(dashboard): show Claude usage (tokens + requests) since server start
ClaudeBrain now returns per-reply token usage (Reply.usage from the API
response), the dashboard accumulates it (monitor.add_claude_usage), and the
header shows a "클로드 토큰" stat (input+output total, with a tooltip breaking
down input/output tokens and request count).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 16:40:25 +09:00
EJClaw
5d9161982f feat(brain): rewrite system persona — Discord real-time voice AI, sectioned
Redefines the bot as "디스코드를 이용해 실시간으로 대화하는 AI 인공지능" and drops
all screen-share wording. Organizes the one-line prompt into sections (역할·언어·
답변방식·대화태도·안전/사실성·감정표현·정체성). Adds: always-Korean-unless-asked,
길이 정량화(한두 문장·10초), 되묻기/침묵 무시, 불확실·최신 정보는 "확인 필요",
URL·숫자·코드 풀어 읽기. Keeps the [감정] tag section (required by the emotion TTS).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 16:35:11 +09:00
EJClaw
be4bc87edf feat(dashboard): conversation log filter by time/user/server/channel/content
Adds the requested multi-dimension log search. Turns now carry speaker, guild
and channel (the bot sends X-User/Guild/Channel-Name on the voice-turn POST), and
a filter bar above the conversation feed narrows by 시간(최근 N분)·유저·서버·
채널·내용. The event/error panel keeps its text+level search.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 12:15:11 +09:00
EJClaw
50838ef602 docs(readme): document the operational voice-server, emotion TTS, and dashboard
Records what's now built beyond the original plan: real GPU STT/brain/TTS via
the voice-server, bracketed-emotion TTS (pitch/speed), and the dashboard's
prompt editing, bot control bar, whitelist/blacklist, 3-row turns, and log dock,
plus the dashboard<->bot control plane.

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 12:06:03 +09:00
EJClaw
7eb590b729 fix(bot): tear down existing voice connection before re-joining
joinChannel reused a same-guild connection and re-subscribed a new player and
receiver each time, stacking duplicate speaking listeners (→ duplicate voice
turns) and error handlers. Now it no-ops if already in the target channel and
otherwise leaves the current connection first, so channel switches are clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 12:00:51 +09:00
EJClaw
d3cf4e01b5 feat(bot+dashboard): bot info, server/voice-channel picker, participants, speaker
Adds a dashboard<->bot control plane (bot pushes state + polls commands, keeping
the bot's single outbound-HTTP direction):
- New bot_control.BotControl + endpoints: GET /api/bot/state, /api/bot/commands;
  POST /api/bot/report, /api/bot/select.
- Dashboard header bar: bot identity/connection, server dropdown (top "없음"),
  voice-channel dropdown (top "없음"), and live participant list.
- Turns record who spoke (Turn.speaker, via X-User-Name on the voice-turn POST).
- dave/bot.mjs: reports identity/guilds/voice-channels/members, polls join/leave
  commands and joins dynamically, and sends the speaker's display name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 11:43:48 +09:00
EJClaw
6a2865d899 feat(dashboard): VSCode-style log dock, 3-row turns, log search/edit/delete
- Bottom-docked collapsible terminal log panel (open/close), retains the event
  log from voice-server start (event tail 200→2000).
- Log search box + level filter (전체/오류/경고/정보); per-line 삭제/수정 and
  전체 삭제, backed by new monitor event ids and /api/logs/{clear,delete,edit}.
- Turns now show 들음 / 생각 / 답변 three rows; 생각 surfaces the emotion-tone
  plan the bot chose (and the [잡음] decision), via a new Turn.thought field.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 11:35:11 +09:00
EJClaw
df07224feb feat(dashboard): live-editable bot prompt via popup (view/edit/save)
Adds a persisted, runtime-editable system prompt. The brain reads the persona
on every turn (prompt_store.get_persona), so a dashboard edit applies to the
next reply with no restart; blank clears the override back to the built-in
PERSONA. New endpoints GET/POST /api/prompt, and a reusable modal popup
(뒤로가기 + 수정/저장) that later white/blacklist features will share.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-22 11:20:57 +09:00
10 changed files with 1155 additions and 110 deletions

View File

@@ -5,8 +5,11 @@
STT → 두뇌 → TTS부터 완성**하는 단계다.
- 실행 위치: 이 리눅스 호스트(.9, RTX 5050 8GB / ffmpeg / node v22 있음)
- 현재 상태: 음성 루프 뼈대 동작 — 파이프라인이 **눈 없이(화면공유 없이)** 돌아간다
(`python -m wsai --voice`). STT/TTS/두뇌는 아직 mock이며, 실제 엔진 연결이 다음 목표.
- 현재 상태: 음성 루프 **실엔진 동작** — GPU STT(faster-whisper), Claude 두뇌(Haiku),
GPU TTS(MeloTTS, 감정 톤 반영)가 모두 붙었고 디스코드 봇과 왕복하는 **voice-server**
(`python -m wsai --voice-server`)가 실서비스로 돈다. 상태 대시보드(:8787)에서 봇 정보·
서버/음성채널 선택·참여자·발화자·로그·프롬프트·화이트/블랙리스트를 실시간 제어한다(9장).
남은 것은 실제 사람 발화로 오디오 왕복을 눈으로 확인하는 최종 라이브 검증뿐.
- 보류 중: 화면공유 **비디오** 수신(눈). 단, STT 입력은 **디스코드 보이스로 유저 음성을
수신**하므로 보이스 접속 자체는 지금도 쓴다(비디오만 미룸). 이 보이스 접속은
**공식 Discord 봇**(`dave/bot.mjs`, discord.js + @discordjs/voice)으로 하며 DAVE/MLS
@@ -212,3 +215,54 @@ ffmpeg 디코드 → 프레임. DAVE는 `@snazzah/davey`(Rust NAPI), 수신은 `
- **M3** faster-whisper STT(부분전사+VAD) → **M4** Claude OAuth(Haiku) 두뇌
- **M5** 한국어 TTS 첫 구절 청크를 보이스로 송신(DAVE 암호화) + barge-in
- **M6** 통합 + 워밍업 + .9 GPU 도커 이미지화, 채널 라이브 테스트
---
## 9. 운영 음성 서버 + 대시보드 (구현됨)
디스코드 봇이 발화 wav를 `POST /api/voice-turn`으로 올리면, voice-server가 GPU STT →
Claude 두뇌 → GPU TTS를 돌려 응답 wav를 돌려주고 봇이 채널에 재생한다. 같은 서버가
:8787에 상태 대시보드(단일 HTML, 외부 자산 없음)를 띄운다.
```bash
.venv/bin/python -m wsai --voice-server --host 0.0.0.0 --port 8787 # 실서비스(wsai-voice.service)
```
### 감정 TTS (대괄호 태그)
- 두뇌가 답변에 `[감정]` 태그를 넣으면 그 태그는 **읽지 않고** 뒤 문장의 피치·속도를 바꿔
감정을 표현한다. 답변 중간에 감정이 바뀌면 그 지점부터 톤이 바뀐다.
예: `[속상함] 정말 힘들었겠다. [힘차게] 하지만 넌 할 수 있어!`
- 감정 어휘는 Azure Neural TTS speaking styles + Ekman 기본감정을 한국어로 매핑
(`wsai/backends/emotion.py`). 감정 단어가 아닌 대괄호(예: `[1번]`)는 내용을 그대로 읽는다.
- 음성이 아닌 잡음으로 판단되면 답변을 `[잡음]`으로 두고 아무것도 재생하지 않는다.
- 구현: 세그먼트별 합성 후 librosa 피치 시프트로 이어붙임(`melo_worker.py`), 시작 시 예열.
### 대시보드 기능
- **봇 프롬프트 실시간 수정** — 상단 "📝 프롬프트" 팝업에서 현재 시스템 프롬프트를 보고
수정→저장하면 다음 답변부터 즉시 반영(빈칸 저장 시 기본값 복원). `prompt_store` 영속화.
- **봇 제어 바** — 봇 정보/연결 상태, 참여 가능 서버 선택(상단 "없음"), 선택 서버의 음성채널
선택(상단 "없음"), 현재 음성채널 참여자(말하는 사람 🔊).
- **화이트/블랙리스트** — 팝업에서 서버의 유저/역할을 검색해 추가/제거. 화이트리스트가 있으면
그 대상만 청취(비어있으면 전체), 블랙리스트는 제외. 봇이 발화자 SSRC→유저/역할로 필터
(`dave/filter.mjs``isAllowed`).
- **대화 카드 + 로그 검색** — 들음 / 생각(감정 톤 계획) / 답변 3분할, 발화자(🗣)·서버/채널(🔊)
표시, 단계별·총 소요시간. 대화 로그는 **시간·유저(발화자)·서버·채널·내용**으로 필터한다
(턴에 speaker/guild/channel 메타데이터를 실어 봇이 X-User/Guild/Channel-Name 헤더로 보고).
- **이벤트 로그 패널** — 하단 고정 VSCode 터미널식, 열고닫기, 시작부터 기록. 텍스트/레벨 검색,
전체·라인별 삭제/수정(`/api/logs/{clear,delete,edit}`).
### 대시보드 ↔ 봇 제어 채널
봇은 아웃바운드 HTTP만 쓴다. 봇이 상태(정체성·서버·음성채널·참여자·역할·멤버)를
`POST /api/bot/report`로 올리면, 응답에 대시보드가 쌓아둔 **명령**(채널 join/leave)과
**청취 필터**가 실려 온다. 서버/채널 선택은 `POST /api/bot/select`, 필터는
`POST /api/bot/lists`로 저장한다(`wsai/bot_control.py`).
### 관련 파일
| 경로 | 역할 |
|------|------|
| `wsai/dashboard.py` | voice-turn 엔드포인트 + 상태 대시보드(HTML/JS) + 모든 API |
| `wsai/monitor.py` | 턴/스텝/이벤트 텔레메트리 (thought·speaker·이벤트 id 포함) |
| `wsai/backends/emotion.py` | `[감정]` 태그 파싱 + 감정→피치/속도 매핑 |
| `wsai/prompt_store.py` | 실시간 편집되는 시스템 프롬프트 영속화 |
| `wsai/bot_control.py` | 대시보드↔봇 제어 플레인(상태·명령·화이트/블랙리스트) |
| `dave/filter.mjs` | 청취 화이트/블랙리스트 판정(`isAllowed`, 순수 함수) |

View File

@@ -36,6 +36,7 @@ import {
NoSubscriberBehavior,
} from '@discordjs/voice';
import prism from 'prism-media';
import { isAllowed } from './filter.mjs';
// ---------- config ----------
function loadEnvFile() {
@@ -55,6 +56,10 @@ const RUN_MS = process.env.RUN_MS != null ? Number(process.env.RUN_MS) : 0; // 0
// Python STT+TTS voice-turn endpoint (run `python -m wsai --voice-server`).
const VOICE_ENDPOINT = process.env.WSAI_VOICE_ENDPOINT || env.WSAI_VOICE_ENDPOINT
|| 'http://127.0.0.1:8787/api/voice-turn';
// Dashboard control plane (state push + command poll), same host as voice-turn.
const API_BASE = VOICE_ENDPOINT.replace(/\/api\/voice-turn\/?$/, '');
const REPORT_ENDPOINT = API_BASE + '/api/bot/report';
const REPORT_INTERVAL_MS = Number(process.env.WSAI_REPORT_INTERVAL_MS || 2500);
// Ignore utterances shorter than this many PCM bytes (48kHz*2ch*2B = 192000 B/s),
// so key clicks / brief noise don't trigger a turn. ~0.35s.
const MIN_UTTERANCE_BYTES = Number(process.env.WSAI_MIN_UTTERANCE_BYTES || 67000);
@@ -109,10 +114,25 @@ async function handleUtterance(userId, pcm) {
return;
}
const wav = Buffer.concat([wavHeader(pcm.length), pcm]);
// Resolve who spoke (Discord display name) so the dashboard log can show it.
let speaker = userId;
try {
const g = currentGuildId && client.guilds.cache.get(currentGuildId);
const m = g && (g.members.cache.get(userId) || await g.members.fetch(userId).catch(() => null));
if (m) speaker = m.displayName || m.user.username;
} catch {}
let resp;
try {
const guildName = (currentGuildId && client.guilds.cache.get(currentGuildId)?.name) || '';
resp = await fetch(VOICE_ENDPOINT, {
method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: wav,
method: 'POST',
headers: {
'Content-Type': 'audio/wav',
'X-User-Name': encodeURIComponent(speaker),
'X-Guild-Name': encodeURIComponent(guildName),
'X-Channel-Name': encodeURIComponent(currentChannelName || ''),
},
body: wav,
});
} catch (e) {
log(`voice-turn POST failed (is \`python -m wsai --voice-server\` running?): ${e.message}`);
@@ -141,17 +161,169 @@ if (process.argv.includes('--invite')) {
if (!TOKEN) { console.error('no DISCORD_BOT_TOKEN in env or ../.env'); process.exit(2); }
// ---------- client ----------
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates],
});
// Listing ALL server members needs the privileged "Server Members Intent".
// Gate it behind an env flag: enabling the intent in code while the Developer
// Portal toggle is OFF makes login fail ("disallowed intents"). So this stays
// off until the user turns the portal intent on and sets WSAI_MEMBERS_INTENT=1.
const MEMBERS_INTENT = (process.env.WSAI_MEMBERS_INTENT || env.WSAI_MEMBERS_INTENT) === '1';
const intents = [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates];
if (MEMBERS_INTENT) intents.push(GatewayIntentBits.GuildMembers);
const client = new Client({ intents });
const perUser = new Map(); // userId -> { opusPackets, pcmFrames }
// --- control-plane state (dashboard drives which channel we're in) --------- #
let currentGuildId = null, currentChannelId = null, currentChannelName = null;
const speakingSet = new Set(); // userIds currently speaking (for participant list)
const activeSubs = new Set(); // userIds with an in-flight receive subscription
let listsByGuild = {}; // guildId -> {whitelistUsers, blacklistUsers, whitelistRoles, blacklistRoles}
// Attach the bot's audio player (so it can speak) to a fresh connection.
function setupPlayer(connection) {
voicePlayer = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
voicePlayer.on('error', (e) => log(`player error: ${e.message}`));
connection.subscribe(voicePlayer);
}
// Attach the receive path: capture each utterance and run the voice turn.
function setupReceiver(connection) {
const receiver = connection.receiver;
receiver.speaking.on('start', (userId) => {
speakingSet.add(userId);
if (userId === client.user.id || activeSubs.has(userId)) return;
// Whitelist/blacklist: skip speakers we are configured not to listen to.
const lists = listsByGuild[currentGuildId];
if (lists) {
const member = client.guilds.cache.get(currentGuildId)?.members.cache.get(userId);
const roleIds = member ? [...member.roles.cache.keys()] : [];
if (!isAllowed(userId, roleIds, lists)) {
logThrottled(`skip:${userId}`, `skip user=${userId} (listen filter)`);
return;
}
}
activeSubs.add(userId);
if (!perUser.has(userId)) perUser.set(userId, { opusPackets: 0, pcmFrames: 0 });
const opusStream = receiver.subscribe(userId, {
end: { behavior: EndBehaviorType.AfterSilence, duration: 800 },
});
const decoder = new prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 });
const chunks = [];
opusStream.on('data', () => { perUser.get(userId).opusPackets++; });
opusStream.on('error', (e) => { logThrottled(`recv:${e.message}`, `recv stream error user=${userId}: ${e.message}`); activeSubs.delete(userId); });
opusStream.pipe(decoder);
decoder.on('data', (d) => { chunks.push(d); perUser.get(userId).pcmFrames++; });
decoder.on('error', (e) => log(`decode error user=${userId}: ${e.message}`));
decoder.on('end', () => {
activeSubs.delete(userId);
const pcm = Buffer.concat(chunks);
log(`utterance end user=${userId} pcm=${pcm.length}B — running voice turn`);
handleUtterance(userId, pcm).catch((e) => log(`voice turn error: ${e.message}`));
});
});
receiver.speaking.on('end', (userId) => speakingSet.delete(userId));
}
// Join (or switch to) a voice channel on command from the dashboard.
async function joinChannel(guildId, channelId) {
const guild = await client.guilds.fetch(guildId).catch(() => null);
const channel = guild && await guild.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isVoiceBased()) { log(`join failed: ${guildId}/${channelId} not a voice channel`); return; }
if (currentGuildId === guildId && currentChannelId === channelId && getVoiceConnection(guildId)) {
log(`already in "${channel.name}"`); return;
}
// Always tear down any existing connection first. Re-subscribing a player and
// receiver onto a reused same-guild connection would stack duplicate speaking
// listeners (→ duplicate voice turns) and duplicate error handlers.
if (currentGuildId) leaveChannel();
const connection = joinVoiceChannel({
channelId, guildId, adapterCreator: guild.voiceAdapterCreator,
selfDeaf: false, selfMute: false,
});
connection.on('error', (e) => log(`voice connection error: ${e.message}`));
try {
await entersState(connection, VoiceConnectionStatus.Ready, 40_000);
} catch (e) { log(`join not Ready in 40s: ${e.message}`); return; }
currentGuildId = guildId; currentChannelId = channelId; currentChannelName = channel.name;
speakingSet.clear(); activeSubs.clear();
setupPlayer(connection);
setupReceiver(connection);
connection.on(VoiceConnectionStatus.Disconnected, () => {
log('voice: disconnected — attempting to resume…');
Promise.race([
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
]).catch(() => { log('voice: could not resume'); leaveChannel(); });
});
log(`✅ joined voice: "${guild.name}" / "${channel.name}"`);
}
function leaveChannel() {
try { getVoiceConnection(currentGuildId)?.destroy(); } catch {}
currentGuildId = currentChannelId = currentChannelName = null;
speakingSet.clear(); activeSubs.clear();
}
// Build the state snapshot the dashboard shows (identity, joinable servers +
// voice channels, current channel, and who is in it).
function buildState() {
const guilds = [...client.guilds.cache.values()].map((g) => ({
id: g.id, name: g.name,
voiceChannels: [...g.channels.cache.values()]
.filter((c) => c.isVoiceBased())
.map((c) => ({ id: c.id, name: c.name })),
// Roles (minus @everyone) and known members, so the dashboard's
// whitelist/blacklist popup can search by user OR role.
roles: [...g.roles.cache.values()]
.filter((r) => r.id !== g.id && r.name !== '@everyone')
.map((r) => ({ id: r.id, name: r.name })),
members: [...g.members.cache.values()].slice(0, 2000).map((m) => ({
id: m.id, name: m.displayName, bot: m.user?.bot === true,
roleIds: [...m.roles.cache.keys()],
})),
}));
let members = [];
if (currentGuildId && currentChannelId) {
const ch = client.guilds.cache.get(currentGuildId)?.channels.cache.get(currentChannelId);
if (ch && ch.members) {
members = [...ch.members.values()].map((m) => ({
id: m.id, name: m.displayName, speaking: speakingSet.has(m.id),
}));
}
}
return {
identity: { id: client.user.id, username: client.user.username, tag: client.user.tag },
guilds,
current: { guildId: currentGuildId, channelId: currentChannelId, channelName: currentChannelName },
members,
};
}
async function handleCommand(cmd) {
if (cmd.type === 'join') await joinChannel(cmd.guildId, cmd.channelId);
else if (cmd.type === 'leave') { leaveChannel(); log('left voice on command'); }
}
// Push state to the dashboard and apply any commands it hands back.
async function reportLoop() {
let j;
try {
const r = await fetch(REPORT_ENDPOINT, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildState()),
});
j = await r.json();
} catch { return; } // dashboard down: keep running, retry next tick
if (j?.lists) listsByGuild = j.lists; // latest whitelist/blacklist config
for (const cmd of (j?.commands || [])) {
try { await handleCommand(cmd); } catch (e) { log(`command ${cmd?.type} failed: ${e.message}`); }
}
}
let leaving = false;
function leaveAndExit(code = 0) {
if (leaving) return;
leaving = true;
try { getVoiceConnection(GUILD_ID)?.destroy(); } catch {}
try { getVoiceConnection(currentGuildId || GUILD_ID)?.destroy(); } catch {}
const summary = [...perUser.entries()].map(([u, s]) => `${u}:opus=${s.opusPackets},pcm=${s.pcmFrames}`);
log(`leaving. speakers heard: ${summary.length ? summary.join(' ') : '(none)'}`);
try { client.destroy(); } catch {}
@@ -165,84 +337,31 @@ if (RUN_MS > 0) setTimeout(() => { log(`RUN_MS=${RUN_MS} hard ceiling elapsed
client.once('clientReady', async () => {
log(`logged in as ${client.user.tag} (${client.user.id})`);
let guild, channel;
try {
guild = await client.guilds.fetch(GUILD_ID);
channel = await guild.channels.fetch(CHANNEL_ID);
} catch (e) {
log(`FATAL: cannot access guild/channel — is the bot invited to guild ${GUILD_ID}? (${e.message})`);
log('run `node bot.mjs --invite` and have a server admin authorise the bot, then retry.');
return leaveAndExit(1);
log(`voice endpoint: ${VOICE_ENDPOINT} · control: ${REPORT_ENDPOINT}`);
// Backward-compat: if a default guild/channel is configured, auto-join it.
// Otherwise idle and wait for the dashboard to pick a channel.
if (GUILD_ID && CHANNEL_ID) {
await joinChannel(GUILD_ID, CHANNEL_ID).catch((e) => log(`initial join failed: ${e.message}`));
} else {
log('no default channel — waiting for the dashboard to select a server/voice channel…');
}
if (!channel || !channel.isVoiceBased()) { log(`FATAL: channel ${CHANNEL_ID} is not a voice channel`); return leaveAndExit(1); }
log(`joining voice: guild="${guild.name}" channel="${channel.name}"`);
const connection = joinVoiceChannel({
channelId: CHANNEL_ID,
guildId: GUILD_ID,
adapterCreator: guild.voiceAdapterCreator,
selfDeaf: false, // MUST be false to receive audio (the STT input path)
selfMute: false, // false so we can also speak later (M5 TTS)
});
connection.on('error', (e) => log(`voice connection error: ${e.message}`));
try {
// The DAVE/MLS handshake here cycles signalling<->connecting several times
// and can take ~25s, so give it a generous ceiling before declaring failure.
await entersState(connection, VoiceConnectionStatus.Ready, 40_000);
} catch (e) {
log(`FATAL: voice connection did not become Ready in 40s (${e.message})`);
return leaveAndExit(1);
// With the members intent on, pull the full member list of each guild so the
// whitelist/blacklist popup can search every user (not just cached speakers).
if (MEMBERS_INTENT) {
for (const g of client.guilds.cache.values()) {
g.members.fetch()
.then((m) => log(`fetched ${m.size} members of "${g.name}"`))
.catch((e) => log(`member fetch failed ("${g.name}"): ${e.message}`));
}
}
log(`✅ JOINED & READY. channel=${CHANNEL_ID} — staying connected, listening for speakers…`);
// Playback path (bot speaks): one player, subscribed to the connection.
voicePlayer = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
voicePlayer.on('error', (e) => log(`player error: ${e.message}`));
connection.subscribe(voicePlayer);
log(`voice endpoint: ${VOICE_ENDPOINT}`);
// ---------- receive path: capture each utterance and run the voice turn ----
const receiver = connection.receiver;
const active = new Set(); // userIds with an in-flight subscription (avoid dupes)
receiver.speaking.on('start', (userId) => {
if (userId === client.user.id || active.has(userId)) return; // skip self / dupes
active.add(userId);
if (!perUser.has(userId)) perUser.set(userId, { opusPackets: 0, pcmFrames: 0 });
log(`SPEAKING start user=${userId}`);
const opusStream = receiver.subscribe(userId, {
// End the utterance after a short silence so natural pauses don't cut words.
end: { behavior: EndBehaviorType.AfterSilence, duration: 800 },
});
// Decode Opus -> 48kHz stereo s16le PCM, buffered until the utterance ends.
const decoder = new prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 });
const chunks = [];
opusStream.on('data', () => { perUser.get(userId).opusPackets++; });
// A receive-stream error (e.g. a DAVE decrypt/UDP GenericFailure on one
// packet) must NOT crash the process — log it and free the slot so the
// next utterance still works.
opusStream.on('error', (e) => { logThrottled(`recv:${e.message}`, `recv stream error user=${userId}: ${e.message}`); active.delete(userId); });
opusStream.pipe(decoder);
decoder.on('data', (d) => {
chunks.push(d);
const s = perUser.get(userId); s.pcmFrames++;
});
decoder.on('error', (e) => log(`decode error user=${userId}: ${e.message}`));
decoder.on('end', () => {
active.delete(userId);
const pcm = Buffer.concat(chunks);
log(`utterance end user=${userId} pcm=${pcm.length}B — running voice turn`);
handleUtterance(userId, pcm).catch((e) => log(`voice turn error: ${e.message}`));
});
});
connection.on(VoiceConnectionStatus.Disconnected, () => {
log('voice: disconnected — attempting to resume…');
Promise.race([
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
]).catch(() => { log('voice: could not resume, leaving'); leaveAndExit(0); });
});
// Report state + poll commands forever. This is what powers the dashboard's
// bot info, server/voice-channel pickers, participant list, and join/leave.
reportLoop();
const reportTimer = setInterval(reportLoop, REPORT_INTERVAL_MS);
if (typeof reportTimer.unref === 'function') reportTimer.unref();
});
client.on('error', (e) => log('client error', e.message));

18
dave/filter.mjs Normal file
View File

@@ -0,0 +1,18 @@
// Listen filter (whitelist/blacklist) — pure, so it is unit-testable without
// booting the Discord client.
//
// Rules:
// - a blacklisted user OR a user with a blacklisted role is always excluded;
// - if any whitelist (user or role) is set, only whitelisted speakers pass;
// - otherwise everyone passes.
export function isAllowed(userId, roleIds, lists) {
const L = lists || {};
const idSet = (arr) => new Set((arr || []).map((x) => x.id));
const roles = new Set(roleIds || []);
const hasRole = (arr) => (arr || []).some((r) => roles.has(r.id));
if (idSet(L.blacklistUsers).has(userId) || hasRole(L.blacklistRoles)) return false;
const wlUsers = L.whitelistUsers || [];
const wlRoles = L.whitelistRoles || [];
if (wlUsers.length === 0 && wlRoles.length === 0) return true;
return idSet(wlUsers).has(userId) || hasRole(wlRoles);
}

View File

@@ -24,6 +24,7 @@ import os
import time
from ..interfaces import Frame, Reply, ScreenObservation
from ..prompt_store import get_persona
# Claude Code OAuth tokens only answer when the first system block is exactly
# this identity string; the real persona/instructions go in later blocks.
@@ -123,17 +124,35 @@ class ClaudeVision:
class ClaudeBrain:
PERSONA = (
"너는 사용자의 디스코드 화면공유를 실시간으로 함께 보는 AI 파트너야. "
"화면 설명을 참고해 자연스러운 반말/존댓말은 사용자에 맞추고, 짧고 대화하듯 답해. "
"화면을 못 봤으면 솔직히 말해. "
"네 답변은 음성으로 읽히니 마크다운·코드블록·백틱 같은 서식 없이 평범한 말로만 답해. "
"감정은 대괄호 태그로 표현해. 태그 자체는 소리로 읽히지 않고, 그 뒤 문장의 목소리 톤(피치·속도)을 바꿔줘. "
"답변 맨 앞에 감정 태그 하나로 시작하고, 답변 도중 감정이 바뀌면 그 지점에 새 태그를 넣어. "
"예: [속상함] 정말 힘들었겠다. [힘차게] 하지만 넌 할 수 있어! "
"쓸 수 있는 감정: 기쁨, 신남, 희망(힘차게), 슬픔(속상함), 화남, 두려움, 놀람, 차분, 다정, 진지, 실망, 피곤, "
"사랑스럽게, 장난스럽게(웃으며), 속삭임, 외침, 단호, 안도, 궁금, 반가움. "
"감정 태그가 아닌 진짜 대괄호 내용(예: [1번], [메모])은 그대로 읽히니 필요하면 그렇게 써도 돼. "
"답변은 최대한 짧고 간결하게, 한두 문장 이내로 해."
"너는 디스코드를 이용해 사용자와 실시간으로 대화하는 AI 인공지능이야.\n\n"
"1. 역할\n"
"- 사용자의 말을 듣고 자연스럽게 대답한다.\n"
"- 음성 대화에 어울리게 짧고 빠르게 반응한다.\n"
"- 친구처럼 편하게, 무례하거나 과하게 장난치진 않는다.\n\n"
"2. 언어\n"
"- \"영어로 해줘\"처럼 특정 언어를 요청하지 않으면 무조건 한국어로 답한다.\n"
"- 사용자가 다른 언어로 말해도 언어 변경 요청이 없으면 한국어로 답한다.\n\n"
"3. 답변 방식\n"
"- 음성으로 읽히니 마크다운·코드블록·특수기호·목록기호·이모지 없이 평범한 말로만 답한다.\n"
"- 기본은 한두 문장, 길어도 10초 안팎. 길어질 땐 핵심부터 말하고 필요하면 이어서 설명한다.\n"
"- URL·긴 숫자·시간·단위·코드는 소리내 읽기 좋게 풀어서 말한다.\n\n"
"4. 대화 태도\n"
"- 사용자의 말투·분위기에 맞춰 반응한다.\n"
"- 모르면 지어내지 말고 모른다고 하고, 애매하면 되묻는다(\"다시 말해줄래?\").\n"
"- 잡음·침묵·의미 없는 소리엔 억지로 대답하지 않는다.\n\n"
"5. 안전·사실성\n"
"- 위험하거나 불법적인 요청은 돕지 않는다.\n"
"- 확인되지 않은 사실이나 최신 정보는 확정적으로 말하지 말고 \"확인이 필요하다\"고 말한다.\n"
"- 개인정보·계정·토큰·비밀번호 같은 민감정보는 요구하거나 노출하지 않는다.\n\n"
"6. 감정 표현\n"
"- 감정은 대괄호 태그로 표현한다. 태그 자체는 읽히지 않고 뒤 문장의 목소리 톤(피치·속도)만 바뀐다.\n"
"- 답변 맨 앞에 감정 태그 하나로 시작하고, 도중에 감정이 바뀌면 그 지점에 새 태그를 넣는다.\n"
" 예: [속상함] 정말 힘들었겠다. [힘차게] 하지만 넌 할 수 있어!\n"
"- 쓸 수 있는 감정: 기쁨, 신남, 힘차게, 속상함, 화남, 두려움, 놀람, 차분, 다정, 진지, 실망, 피곤, "
"사랑스럽게, 웃으며, 속삭임, 외침, 단호, 안도, 궁금, 반가움.\n"
"- 감정 단어가 아닌 진짜 대괄호(예: [1번], [메모])는 그대로 읽으니 필요하면 그렇게 써도 된다.\n\n"
"7. 정체성\n"
"- 너는 디스코드에서 함께 대화하는 실시간 AI 인공지능이다."
)
def __init__(self, *, model: str = "claude-sonnet-4-5", api_key: str | None = None) -> None:
@@ -148,11 +167,18 @@ class ClaudeBrain:
screen_note = f"[지금 화면] {screen.text}\n\n" if screen else "[지금 화면] (아직 못 읽음)\n\n"
msgs.append({"role": "user", "content": screen_note + user_text})
client = self._auth.client()
# Read the persona live each turn so a dashboard edit applies immediately
# (falls back to the built-in PERSONA when no override is saved).
resp = await client.messages.create(
model=self.model,
max_tokens=400,
system=self._auth.system(self.PERSONA),
system=self._auth.system(get_persona(self.PERSONA)),
messages=msgs,
)
text = "".join(b.text for b in resp.content if b.type == "text")
return Reply(text=text.strip(), ts=time.monotonic())
usage = None
u = getattr(resp, "usage", None)
if u is not None:
usage = {"input": getattr(u, "input_tokens", 0) or 0,
"output": getattr(u, "output_tokens", 0) or 0}
return Reply(text=text.strip(), ts=time.monotonic(), usage=usage)

91
wsai/bot_control.py Normal file
View File

@@ -0,0 +1,91 @@
"""Control plane between the dashboard (Python) and the Discord bot (Node).
The bot only ever makes *outbound* HTTP (it already POSTs voice turns), so we
keep that single direction:
* the bot PUSHES its live state here (identity, joinable guilds + voice
channels, current channel, members, whitelist/blacklist) via
``POST /api/bot/report`` → :meth:`report`;
* the bot POLLS ``GET /api/bot/commands`` → :meth:`drain` for pending commands
(join a channel, leave) that the dashboard UI enqueued via :meth:`enqueue`.
Thread-safe: the HTTP handler threads read/write from several threads.
"""
from __future__ import annotations
import threading
import time
from collections import deque
from typing import Any
# The bot is considered offline if it hasn't reported within this window.
STALE_AFTER_S = 8.0
class BotControl:
def __init__(self) -> None:
self._lock = threading.Lock()
self._state: dict[str, Any] = {"connected": False, "ts": 0.0}
self._commands: deque[dict[str, Any]] = deque()
self._cmd_id = 0
# Per-guild listen filter. Empty whitelist => listen to everyone;
# blacklist always excludes. Users and roles both supported.
self._lists: dict[str, dict[str, Any]] = {}
# -- whitelist / blacklist (per guild) ------------------------------- #
@staticmethod
def _empty_lists() -> dict[str, Any]:
return {"whitelistUsers": [], "blacklistUsers": [],
"whitelistRoles": [], "blacklistRoles": []}
def get_lists(self, guild_id: str) -> dict[str, Any]:
with self._lock:
return dict(self._lists.get(guild_id) or self._empty_lists())
def all_lists(self) -> dict[str, Any]:
with self._lock:
return {g: dict(v) for g, v in self._lists.items()}
def set_lists(self, guild_id: str, lists: dict[str, Any]) -> dict[str, Any]:
clean = self._empty_lists()
for key in clean:
items = lists.get(key) or []
# Normalise to [{id, name}] and drop anything without an id.
clean[key] = [
{"id": str(it["id"]), "name": str(it.get("name", it["id"]))}
for it in items if isinstance(it, dict) and it.get("id")
]
with self._lock:
self._lists[guild_id] = clean
return clean
# -- bot -> dashboard (state push) ----------------------------------- #
def report(self, state: dict[str, Any]) -> None:
s = dict(state)
s["ts"] = time.time()
s["connected"] = True
with self._lock:
self._state = s
def state(self) -> dict[str, Any]:
with self._lock:
s = dict(self._state)
# A report older than STALE_AFTER_S means the bot stopped polling/pushing.
if s.get("connected") and (time.time() - s.get("ts", 0.0)) > STALE_AFTER_S:
s["connected"] = False
return s
# -- dashboard -> bot (command queue) -------------------------------- #
def enqueue(self, cmd: dict[str, Any]) -> int:
with self._lock:
self._cmd_id += 1
cmd = {**cmd, "id": self._cmd_id}
self._commands.append(cmd)
return self._cmd_id
def drain(self) -> list[dict[str, Any]]:
with self._lock:
cmds = list(self._commands)
self._commands.clear()
return cmds

View File

@@ -35,6 +35,20 @@ def _speech_text(reply: str) -> str:
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
@@ -58,6 +72,17 @@ def _make_handler(dash: "Dashboard"):
elif path == "/api/state":
body = json.dumps(monitor.snapshot(), ensure_ascii=False).encode("utf-8")
self._send(200, body, "application/json; charset=utf-8")
elif path == "/api/prompt":
self._handle_prompt_get()
elif path == "/api/bot/state":
self._send_json(dash.bot.state())
elif path == "/api/bot/commands":
self._send_json({"commands": dash.bot.drain()})
elif path == "/api/bot/lists":
import urllib.parse as _up
q = _up.parse_qs(self.path.split("?", 1)[1] if "?" in self.path else "")
gid = (q.get("guildId", [""])[0])
self._send_json({"ok": True, "guildId": gid, "lists": dash.bot.get_lists(gid)})
elif path == "/events":
self._stream_events()
else:
@@ -69,6 +94,21 @@ def _make_handler(dash: "Dashboard"):
self._handle_stt()
elif path == "/api/voice-turn":
self._handle_voice_turn()
elif path == "/api/prompt":
self._handle_prompt_post()
elif path == "/api/logs/clear":
monitor.clear_events()
self._send(200, json.dumps({"ok": True}).encode(), "application/json; charset=utf-8")
elif path == "/api/logs/delete":
self._handle_log_mutate("delete")
elif path == "/api/logs/edit":
self._handle_log_mutate("edit")
elif path == "/api/bot/report":
self._handle_bot_report()
elif path == "/api/bot/select":
self._handle_bot_select()
elif path == "/api/bot/lists":
self._handle_bot_lists()
else:
self._send(404, b"not found", "text/plain; charset=utf-8")
@@ -94,8 +134,11 @@ def _make_handler(dash: "Dashboard"):
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)
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}"},
@@ -138,6 +181,118 @@ def _make_handler(dash: "Dashboard"):
ensure_ascii=False).encode("utf-8")
self._send(500, body, "application/json; charset=utf-8")
def _default_persona(self) -> str:
# The built-in seed prompt, used when no override is saved. Imported
# lazily so the dashboard has no hard dependency on the Claude backend.
try:
from .backends.claude import ClaudeBrain
return ClaudeBrain.PERSONA
except Exception:
return ""
def _handle_prompt_get(self) -> None:
"""Return the live bot system prompt so the page can show/edit it."""
from . import prompt_store
default = self._default_persona()
body = json.dumps({
"ok": True,
"prompt": prompt_store.get_persona(default),
"default": default,
"overridden": prompt_store.is_overridden(),
}, ensure_ascii=False).encode("utf-8")
self._send(200, body, "application/json; charset=utf-8")
def _handle_prompt_post(self) -> None:
"""Save an edited system prompt; takes effect on the next reply. An
empty prompt clears the override and reverts to the built-in default."""
from . import prompt_store
raw = self._read_body()
try:
data = json.loads(raw.decode("utf-8")) if raw else {}
prompt = data.get("prompt", "")
except (ValueError, AttributeError):
self._send(400, json.dumps({"ok": False, "error": "invalid JSON"}).encode(),
"application/json; charset=utf-8")
return
prompt_store.set_persona(prompt)
monitor.log("info", "봇 프롬프트가 수정되었습니다" if prompt.strip()
else "봇 프롬프트가 기본값으로 초기화되었습니다")
body = json.dumps({
"ok": True,
"prompt": prompt_store.get_persona(self._default_persona()),
"overridden": prompt_store.is_overridden(),
}, ensure_ascii=False).encode("utf-8")
self._send(200, body, "application/json; charset=utf-8")
def _send_json(self, obj, code: int = 200) -> None:
self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"),
"application/json; charset=utf-8")
def _handle_bot_report(self) -> None:
"""The Discord bot pushes its live state (identity, guilds, voice
channels, current channel + members, list settings)."""
raw = self._read_body()
try:
data = json.loads(raw.decode("utf-8")) if raw else {}
except (ValueError, AttributeError):
self._send_json({"ok": False, "error": "invalid JSON"}, 400)
return
dash.bot.report(data)
# Hand the bot any queued commands + the current listen filters in the
# same round trip so it does not have to poll extra endpoints.
self._send_json({"ok": True, "commands": dash.bot.drain(),
"lists": dash.bot.all_lists()})
def _handle_bot_select(self) -> None:
"""UI picked a server/voice channel → queue a join (or leave) command."""
raw = self._read_body()
try:
data = json.loads(raw.decode("utf-8")) if raw else {}
except (ValueError, AttributeError):
self._send_json({"ok": False, "error": "invalid JSON"}, 400)
return
guild_id = (data.get("guildId") or "").strip()
channel_id = (data.get("channelId") or "").strip()
if channel_id and guild_id:
cid = dash.bot.enqueue({"type": "join", "guildId": guild_id, "channelId": channel_id})
monitor.log("info", f"음성채널 참여 요청 (guild={guild_id} channel={channel_id})")
else:
cid = dash.bot.enqueue({"type": "leave"})
monitor.log("info", "음성채널 나가기 요청")
self._send_json({"ok": True, "commandId": cid})
def _handle_bot_lists(self) -> None:
"""Save the whitelist/blacklist (users + roles) for a guild."""
raw = self._read_body()
try:
data = json.loads(raw.decode("utf-8")) if raw else {}
guild_id = (data.get("guildId") or "").strip()
if not guild_id:
raise ValueError("guildId required")
except (ValueError, AttributeError) as exc:
self._send_json({"ok": False, "error": str(exc)}, 400)
return
saved = dash.bot.set_lists(guild_id, data.get("lists") or {})
monitor.log("info", f"청취 화이트/블랙리스트 업데이트 (guild={guild_id})")
self._send_json({"ok": True, "guildId": guild_id, "lists": saved})
def _handle_log_mutate(self, action: str) -> None:
"""Per-line log delete/edit by event id."""
raw = self._read_body()
try:
data = json.loads(raw.decode("utf-8")) if raw else {}
event_id = int(data.get("id"))
except (ValueError, TypeError, AttributeError):
self._send(400, json.dumps({"ok": False, "error": "id required"}).encode(),
"application/json; charset=utf-8")
return
if action == "delete":
ok = monitor.delete_event(event_id)
else:
ok = monitor.edit_event(event_id, str(data.get("message", "")))
self._send(200 if ok else 404,
json.dumps({"ok": ok}).encode(), "application/json; charset=utf-8")
def _stream_events(self) -> None:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
@@ -182,12 +337,14 @@ class Dashboard:
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
@@ -240,7 +397,8 @@ class Dashboard:
if self.tts is not None:
self._submit(self.tts._ensure())
def voice_turn(self, audio_bytes: bytes) -> dict:
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
@@ -259,6 +417,9 @@ class Dashboard:
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(
@@ -270,10 +431,12 @@ class Dashboard:
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:
@@ -312,6 +475,9 @@ class Dashboard:
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}")
@@ -425,6 +591,7 @@ PAGE = r"""<!DOCTYPE html>
.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}
@@ -456,6 +623,96 @@ PAGE = r"""<!DOCTYPE html>
.sttres{margin-top:12px;font-size:15px;min-height:1px}
.sttres .txt{color:var(--heard);font-weight:600;line-height:1.5}
.sttres .meta{color:var(--muted);font-size:12px;margin-top:5px}
.hbtn{padding:6px 12px;font-size:12.5px}
/* Bot control bar (봇 정보 · 서버/채널 선택 · 참여자) */
.botbar{display:flex;gap:14px;align-items:center;flex-wrap:wrap;background:var(--panel);
border:1px solid var(--line);border-radius:12px;padding:10px 14px;margin:0 0 16px;font-size:13px}
.botbar label{display:flex;gap:6px;align-items:center;color:var(--muted)}
.botbar select{background:var(--panel2);border:1px solid var(--line);color:var(--fg);
border-radius:8px;padding:6px 9px;font-size:13px;max-width:230px}
.botinfo{display:flex;gap:7px;align-items:center;font-weight:600}
.parts{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-left:auto;color:var(--muted)}
.part{display:inline-flex;gap:5px;align-items:center;background:var(--panel2);border:1px solid var(--line);
border-radius:999px;padding:3px 10px;font-size:12px}
.part.spk{border-color:#1f5236;color:#9ff0bd}
.speaker{color:var(--muted);font-size:11.5px}
/* 대화 로그 검색 필터 (시간·유저·서버·채널·내용) */
.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:45vh;box-shadow:0 -8px 24px rgba(0,0,0,.35)}
.logbar{display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid var(--line);
background:#0d141c;flex-wrap:wrap}
.logtoggle{background:none;border:none;color:var(--fg);font-size:12.5px;cursor:pointer;font-weight:600;padding:4px 6px}
.logsearch{flex:1;min-width:120px;background:var(--panel2);border:1px solid var(--line);color:var(--fg);
border-radius:8px;padding:5px 9px;font-size:12.5px}
.logsel{background:var(--panel2);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:5px 8px;font-size:12.5px}
.logcount{color:var(--muted);font-size:11.5px}
.logbtn{padding:5px 10px;font-size:12px}
.logbody{overflow:auto;padding:8px 12px;font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;
font-size:12px;line-height:1.65;background:#0a0e13}
.logdock.collapsed .logbody{display:none}
.logdock.collapsed{max-height:none}
.logline{display:flex;gap:8px;align-items:baseline;padding:1px 0;border-bottom:1px solid #10171f}
.logline:hover{background:#0e151d}
.logline .lt{flex:0 0 92px;color:#5f7488}
.logline .lv{flex:0 0 46px;text-transform:uppercase;font-size:10.5px}
.logline.info .lv{color:var(--accent)}
.logline.error .lv{color:var(--err)}
.logline.warn .lv{color:var(--warn)}
.logline .lm{flex:1;color:var(--fg);white-space:pre-wrap;word-break:break-word}
.logline.error .lm{color:#ffb3bb}
.logline .lacts{opacity:0;display:flex;gap:4px}
.logline:hover .lacts{opacity:1}
.lact{background:none;border:none;color:var(--muted);cursor:pointer;font-size:12px;padding:0 3px}
.lact:hover{color:var(--fg)}
</style>
</head>
<body>
@@ -465,14 +722,25 @@ PAGE = r"""<!DOCTYPE html>
<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="wlBtn" class="btn hbtn">화이트리스트</button>
<button id="blBtn" class="btn hbtn">블랙리스트</button>
<span class="parts" id="parts"></span>
</section>
<section class="sttbox" id="sttbox" style="display:none">
<h2>🎤 음성 인식(STT) 테스트 · GPU</h2>
<div class="sttrow">
@@ -485,13 +753,51 @@ PAGE = r"""<!DOCTYPE html>
</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>
<div class="events">
<h2>이벤트 / 오류 로그</h2>
<div id="events"></div>
</div>
</main>
<div id="logdock" class="logdock">
<div class="logbar">
<button id="logToggle" class="logtoggle">▾ 이벤트 / 오류 로그</button>
<input id="logSearch" class="logsearch" placeholder="로그 검색 (텍스트)">
<select id="logLevel" class="logsel">
<option value="">전체</option>
<option value="error">오류만</option>
<option value="warn">경고만</option>
<option value="info">정보만</option>
</select>
<span id="logCount" class="logcount"></span>
<button id="logClear" class="btn logbtn">로그 삭제</button>
</div>
<div id="logbody" class="logbody"></div>
</div>
<div id="modal" class="modal" style="display:none">
<div class="modal-card">
<div class="modal-head">
<button class="btn back" id="modalBack">← 뒤로</button>
<span class="modal-title" id="modalTitle"></span>
<span class="modal-actions" id="modalActions"></span>
</div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<div id="toast" class="toast"></div>
<script>
const $ = (id)=>document.getElementById(id);
const turns = new Map(); // id -> turn object
@@ -502,6 +808,7 @@ function fmtTime(wall){
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;
@@ -516,6 +823,14 @@ function renderStatus(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 ? '실행 중 (대기)' : '중지됨');
@@ -571,8 +886,11 @@ function turnEl(t){
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>'
@@ -588,15 +906,59 @@ function upsertTurn(t){
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 turnMatches(t, f){
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;
$('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 logMatches(e){
const lv = $('logLevel').value;
if(lv && e.level!==lv) return false;
const q = $('logSearch').value.trim().toLowerCase();
if(q && !((e.message||'').toLowerCase().includes(q) || fmtTime(e.wall).includes(q))) return false;
return true;
}
function renderLogs(){
const body = $('logbody');
const shown = logEvents.filter(logMatches);
body.innerHTML = shown.map(e =>
'<div class="logline '+(e.level||'info')+'" data-id="'+e.id+'">'
+ '<span class="lt">'+fmtTime(e.wall)+'</span>'
+ '<span class="lv">'+esc(e.level||'info')+'</span>'
+ '<span class="lm">'+esc(e.message)+'</span>'
+ '<span class="lacts"><button class="lact" data-act="edit" title="수정">✎</button>'
+ '<button class="lact" data-act="del" title="삭제">✕</button></span>'
+ '</div>'
).join('');
$('logCount').textContent = shown.length + (shown.length!==logEvents.length ? ' / '+logEvents.length : '') + '';
}
function addEvent(e){
const box = $('events');
const row = document.createElement('div');
row.className = 'ev ' + (e.level==='error'?'error':'');
row.innerHTML = '<span class="et">'+fmtTime(e.wall)+'</span><span>'+esc(e.message)+'</span>';
box.prepend(row);
while(box.childElementCount>60) box.removeChild(box.lastChild);
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){
@@ -605,8 +967,8 @@ function applySnapshot(snap){
const list = (snap.turns||[]);
for(const t of list) upsertTurn(t);
if(list.length===0){ $('empty').style.display='block'; }
$('events').innerHTML='';
for(const e of (snap.events||[])) addEvent(e);
logEvents = (snap.events||[]).slice();
renderLogs();
}
function connect(){
@@ -619,6 +981,9 @@ function connect(){
else if(ev.type==='status') renderStatus(ev.status);
else if(ev.type==='turn') upsertTurn(ev.turn);
else if(ev.type==='log') { addEvent(ev); if(statusData){ statusData.errors_total=(statusData.errors_total||0)+(ev.level==='error'?1:0); $('s-errors').textContent=statusData.errors_total; } }
else if(ev.type==='logs_cleared') { logEvents=[]; renderLogs(); }
else if(ev.type==='log_deleted') { logEvents=logEvents.filter(e=>e.id!==ev.id); renderLogs(); }
else if(ev.type==='log_edited') { const e=logEvents.find(e=>e.id===ev.id); if(e){e.message=ev.message; renderLogs();} }
};
}
// --- STT recognition test (upload / mic record -> GPU whisper) ----------- #
@@ -665,6 +1030,177 @@ async function sendBlob(blob){
};
})();
// --- Reusable popup/modal (프롬프트, 화이트/블랙리스트 등이 공유) ---------- #
function openModal(title, actionsHtml){
$('modalTitle').textContent = title;
$('modalActions').innerHTML = actionsHtml || '';
$('modal').style.display = 'flex';
}
function closeModal(){ $('modal').style.display='none'; $('modalBody').innerHTML=''; $('modalActions').innerHTML=''; }
$('modalBack').onclick = closeModal;
$('modal').addEventListener('click', (e)=>{ if(e.target===$('modal')) closeModal(); });
document.addEventListener('keydown', (e)=>{ if(e.key==='Escape' && $('modal').style.display==='flex') closeModal(); });
let toastTimer=null;
function toast(msg){
const t=$('toast'); t.textContent=msg; t.classList.add('show');
clearTimeout(toastTimer); toastTimer=setTimeout(()=>t.classList.remove('show'), 2200);
}
// --- 프롬프트: 현재 시스템 프롬프트 보기/수정/저장 ------------------------- #
async function openPrompt(){
openModal('봇 프롬프트', '<button class="btn" id="pEdit">수정</button>'
+ '<button class="btn primary" id="pSave" style="display:none">저장</button>');
$('modalBody').innerHTML = '<p class="modal-note" id="pNote">현재 봇의 시스템 프롬프트입니다. 저장하면 다음 답변부터 즉시 적용됩니다. (빈칸 저장 시 기본값 복원)</p>'
+ '<textarea id="pText" readonly>불러오는 중…</textarea>';
let data={};
try{ data=await (await fetch('/api/prompt')).json(); }catch(e){ $('pText').value='불러오기 실패: '+e; return; }
$('pText').value = data.prompt || '';
$('pNote').textContent = (data.overridden ? '현재 사용자 지정 프롬프트가 적용 중입니다. ' : '현재 기본 프롬프트가 적용 중입니다. ')
+ '저장하면 다음 답변부터 즉시 적용됩니다. (빈칸 저장 시 기본값 복원)';
$('pEdit').onclick = ()=>{ $('pText').removeAttribute('readonly'); $('pText').focus(); $('pEdit').style.display='none'; $('pSave').style.display='inline-block'; };
$('pSave').onclick = async ()=>{
$('pSave').disabled=true;
try{
const r=await fetch('/api/prompt',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({prompt:$('pText').value})});
const j=await r.json();
if(j.ok){ toast('프롬프트 저장됨 · 다음 답변부터 적용'); closeModal(); }
else { toast('저장 실패: '+(j.error||'')); }
}catch(e){ toast('저장 실패: '+e); }
finally{ $('pSave').disabled=false; }
};
}
$('promptBtn').onclick = openPrompt;
// --- 로그 패널: 열고닫기 / 검색 / 삭제(전체·개별) / 수정 -------------------- #
$('logToggle').onclick = ()=>{
const d=$('logdock'); d.classList.toggle('collapsed');
$('logToggle').textContent = (d.classList.contains('collapsed')?'':'') + ' 이벤트 / 오류 로그';
};
$('logSearch').oninput = renderLogs;
$('logLevel').onchange = renderLogs;
$('logClear').onclick = async ()=>{
if(!confirm('로그를 모두 삭제할까요?')) return;
try{ await fetch('/api/logs/clear',{method:'POST'}); toast('로그를 삭제했습니다'); }
catch(e){ toast('삭제 실패: '+e); }
};
$('logbody').addEventListener('click', async (ev)=>{
const btn = ev.target.closest('.lact'); if(!btn) return;
const line = btn.closest('.logline'); const id = line && line.getAttribute('data-id');
if(id==null) return;
const act = btn.getAttribute('data-act');
const isServer = !String(id).startsWith('c'); // server events have numeric ids
if(act==='del'){
if(isServer){ try{ await fetch('/api/logs/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:Number(id)})}); }catch(e){} }
logEvents = logEvents.filter(e=>String(e.id)!==String(id)); renderLogs();
} else if(act==='edit'){
const cur = logEvents.find(e=>String(e.id)===String(id)); if(!cur) return;
const nv = prompt('로그 수정', cur.message); if(nv==null) return;
cur.message = nv; renderLogs();
if(isServer){ try{ await fetch('/api/logs/edit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:Number(id),message:nv})}); }catch(e){} }
}
});
// --- 봇 제어 바: 정보 표시 / 서버·채널 선택 / 참여자 --------------------- #
let botState = null;
let userPickedGuild = null; // remember the user's server pick across polls
function renderBot(s){
botState = s;
const info=$('botinfo');
if(s && s.connected){
const id = s.identity||{};
info.innerHTML = '<span class="dot live"></span>봇: <b>'+esc(id.tag||id.username||id.id||'연결됨')+'</b>';
} else {
info.innerHTML = '<span class="dot off"></span>봇: 연결 안 됨';
}
const guilds = (s&&s.guilds)||[];
const cur = (s&&s.current)||{};
const gsel=$('guildSel');
const gpick = userPickedGuild!=null ? userPickedGuild : (cur.guildId||'');
gsel.innerHTML = '<option value="">없음</option>' + guilds.map(g=>
'<option value="'+esc(g.id)+'"'+(g.id===gpick?' selected':'')+'>'+esc(g.name)+'</option>').join('');
// Voice channels of the picked guild.
const g = guilds.find(g=>g.id===gpick);
const vcs = (g&&g.voiceChannels)||[];
const vsel=$('vcSel');
vsel.innerHTML = '<option value="">없음</option>' + vcs.map(v=>
'<option value="'+esc(v.id)+'"'+(v.id===cur.channelId?' selected':'')+'>'+esc(v.name)+'</option>').join('');
// Participants in the current voice channel.
const members=(s&&s.members)||[];
$('parts').innerHTML = members.length
? '참여자: '+members.map(m=>'<span class="part'+(m.speaking?' spk':'')+'">'+(m.speaking?'🔊':'👤')+' '+esc(m.name||m.id)+'</span>').join('')
: (s&&s.connected&&cur.channelId ? '참여자: (없음)' : '');
}
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();
}
$('wlBtn').onclick = openLists; $('blBtn').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();
// 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);

View File

@@ -63,6 +63,7 @@ class Reply:
text: str
ts: float
usage: dict | None = None # Claude token usage for this reply, when known
# --------------------------------------------------------------------------- #

View File

@@ -80,9 +80,13 @@ class Turn:
self._monitor = monitor
self.id = turn_id
self.source = source
self.speaker = "" # who spoke (Discord display name), when known
self.guild = "" # server name, when known (for 서버별 필터)
self.channel = "" # voice channel name, when known (for 채널별 필터)
self.wall = _now_wall()
self._t0 = _now_mono()
self.heard_text = ""
self.thought_text = ""
self.reply_text = ""
self.status = "active" # active | ok | error
self.error = ""
@@ -95,6 +99,10 @@ class Turn:
self.heard_text = text
self._touch()
def thought(self, text: str) -> None:
self.thought_text = text
self._touch()
def replied(self, text: str) -> None:
self.reply_text = text
self._touch()
@@ -132,8 +140,12 @@ class Turn:
return {
"id": self.id,
"source": self.source,
"speaker": self.speaker,
"guild": self.guild,
"channel": self.channel,
"wall": self.wall,
"heard": self.heard_text,
"thought": self.thought_text,
"reply": self.reply_text,
"status": self.status,
"error": self.error,
@@ -147,7 +159,9 @@ class Monitor:
def __init__(self, keep: int = 60) -> None:
self._turns: deque[Turn] = deque(maxlen=keep)
self._events: deque[dict[str, Any]] = deque(maxlen=200)
# Keep a long event tail so the terminal panel can show the log from
# (voice-server) start, not just the last few lines.
self._events: deque[dict[str, Any]] = deque(maxlen=2000)
self._status: dict[str, Any] = {
"running": False,
"listening": False,
@@ -155,10 +169,15 @@ class Monitor:
"components": {},
"turns_total": 0,
"errors_total": 0,
# Claude usage since server start (this bot's own consumption).
"claude_requests": 0,
"claude_input_tokens": 0,
"claude_output_tokens": 0,
}
self._lock = threading.Lock()
self._subs: list["queue.Queue[str]"] = []
self._id = 0
self._event_id = 0
# -- status ----------------------------------------------------------- #
def set_status(self, **kw: Any) -> None:
@@ -175,17 +194,65 @@ class Monitor:
with self._lock:
s = dict(self._status)
s["uptime_s"] = round(_now_wall() - s["started_wall"], 1)
from . import usage_store
s["claude_usage"] = usage_store.summary() # 오늘 / 최근 7일 토큰
return s
def add_claude_usage(self, input_tokens: int, output_tokens: int) -> None:
"""Record one Claude call's token usage (session counters + persisted
per-day store for the dashboard's 하루/일주일 사용량)."""
from . import usage_store
usage_store.record(input_tokens, output_tokens)
with self._lock:
self._status["claude_requests"] += 1
self._status["claude_input_tokens"] += int(input_tokens or 0)
self._status["claude_output_tokens"] += int(output_tokens or 0)
self._broadcast({"type": "status", "status": self.status_snapshot()})
def log(self, level: str, message: str) -> None:
"""A free-form lifecycle/error line (startup, disconnect, crash…)."""
evt = {"type": "log", "level": level, "message": message, "wall": _now_wall()}
with self._lock:
self._event_id += 1
evt = {"type": "log", "id": self._event_id, "level": level,
"message": message, "wall": _now_wall()}
self._events.append(evt)
if level == "error":
self._status["errors_total"] += 1
self._broadcast(evt)
def delete_event(self, event_id: int) -> bool:
"""Remove one log line by id (dashboard per-line '삭제')."""
found = False
with self._lock: # _broadcast re-locks, so must run outside this block
for e in list(self._events):
if e.get("id") == event_id:
self._events.remove(e)
found = True
break
if found:
self._broadcast({"type": "log_deleted", "id": event_id})
return found
def edit_event(self, event_id: int, message: str) -> bool:
"""Edit one log line's text by id (dashboard per-line '수정')."""
found = False
with self._lock: # _broadcast re-locks, so must run outside this block
for e in self._events:
if e.get("id") == event_id:
e["message"] = message
found = True
break
if found:
self._broadcast({"type": "log_edited", "id": event_id, "message": message})
return found
def clear_events(self) -> None:
"""Wipe the event/error log (dashboard '로그 삭제'). Broadcasts a reset so
every connected page clears its terminal panel too."""
with self._lock:
self._events.clear()
self._broadcast({"type": "logs_cleared"})
# -- turns ------------------------------------------------------------ #
def turn(self, source: str = "voice") -> Turn:
with self._lock:

54
wsai/prompt_store.py Normal file
View File

@@ -0,0 +1,54 @@
"""Persisted, live-editable bot persona (the brain's system prompt).
The dashboard lets the user view and edit the bot's system prompt at runtime.
The brain reads the current persona on every turn, so an edit takes effect on
the next reply with no restart. The text is persisted to disk so it survives a
restart; when no override file exists the caller's default
(``ClaudeBrain.PERSONA``) is used.
Path: ``$WSAI_PROMPT_PATH`` or ``~/.config/wsai/persona.txt`` (disk, never a
RAM-backed tmpfs).
"""
from __future__ import annotations
import os
import threading
from pathlib import Path
_LOCK = threading.Lock()
def _path() -> Path:
override = os.environ.get("WSAI_PROMPT_PATH")
return Path(override) if override else (Path.home() / ".config" / "wsai" / "persona.txt")
def get_persona(default: str) -> str:
"""Return the saved persona override, or ``default`` if none is set."""
try:
with _LOCK:
text = _path().read_text(encoding="utf-8")
except OSError:
return default
return text if text.strip() else default
def set_persona(text: str) -> None:
"""Persist a new persona. Blank text clears the override (reverts to default)."""
p = _path()
with _LOCK:
p.parent.mkdir(parents=True, exist_ok=True)
if text and text.strip():
p.write_text(text, encoding="utf-8")
else:
p.unlink(missing_ok=True)
def is_overridden() -> bool:
"""True when a non-empty override file is in effect."""
try:
with _LOCK:
return bool(_path().read_text(encoding="utf-8").strip())
except OSError:
return False

79
wsai/usage_store.py Normal file
View File

@@ -0,0 +1,79 @@
"""Persisted per-day Claude token usage, for the dashboard's 하루/일주일 사용량.
Each brain call records its token usage into a date bucket
(``{ "2026-08-22": {"req": n, "in": n, "out": n}, ... }``) persisted to disk so
the totals survive a restart. ``summary()`` returns today's and the last-7-days
aggregates. Kept in memory; the file is read once and rewritten on each record.
Path: ``$WSAI_USAGE_PATH`` or ``~/.config/wsai/usage.json`` (disk, never tmpfs).
"""
from __future__ import annotations
import json
import os
import threading
from datetime import date, timedelta
from pathlib import Path
_LOCK = threading.Lock()
_DATA: dict[str, dict[str, int]] | None = None
_KEEP_DAYS = 90
def _path() -> Path:
override = os.environ.get("WSAI_USAGE_PATH")
return Path(override) if override else (Path.home() / ".config" / "wsai" / "usage.json")
def _load() -> dict[str, dict[str, int]]:
global _DATA
if _DATA is not None:
return _DATA
try:
_DATA = json.loads(_path().read_text(encoding="utf-8"))
assert isinstance(_DATA, dict)
except (OSError, ValueError, AssertionError):
_DATA = {}
return _DATA
def _write() -> None:
p = _path()
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(_DATA, ensure_ascii=False), encoding="utf-8")
def record(input_tokens: int, output_tokens: int) -> None:
"""Add one call's usage to today's bucket and persist."""
today = date.today().isoformat()
with _LOCK:
data = _load()
b = data.setdefault(today, {"req": 0, "in": 0, "out": 0})
b["req"] += 1
b["in"] += int(input_tokens or 0)
b["out"] += int(output_tokens or 0)
# Prune buckets older than _KEEP_DAYS so the file can't grow forever.
cutoff = (date.today() - timedelta(days=_KEEP_DAYS)).isoformat()
for d in [d for d in data if d < cutoff]:
del data[d]
_write()
def summary() -> dict:
"""Today's and the last-7-days token totals."""
with _LOCK:
data = _load()
today = date.today().isoformat()
week_start = (date.today() - timedelta(days=6)).isoformat()
t = data.get(today, {"req": 0, "in": 0, "out": 0})
wk = {"req": 0, "in": 0, "out": 0}
for d, b in data.items():
if d >= week_start:
wk["req"] += b.get("req", 0)
wk["in"] += b.get("in", 0)
wk["out"] += b.get("out", 0)
return {
"today": {"requests": t.get("req", 0), "input": t.get("in", 0), "output": t.get("out", 0)},
"week": {"requests": wk["req"], "input": wk["in"], "output": wk["out"]},
}