Compare commits
18 Commits
1cb7658290
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
361dce70bb | ||
|
|
e81ce1ac97 | ||
|
|
e67cd2e93f | ||
|
|
d410d4a6b5 | ||
|
|
80a83d9944 | ||
|
|
6e20f2cd79 | ||
|
|
2ea7d04289 | ||
|
|
d1d4bd1514 | ||
|
|
7ea07c13ad | ||
|
|
f1f059a018 | ||
|
|
5d9161982f | ||
|
|
be4bc87edf | ||
|
|
50838ef602 | ||
|
|
441ab4831f | ||
|
|
7eb590b729 | ||
|
|
d3cf4e01b5 | ||
|
|
6a2865d899 | ||
|
|
df07224feb |
58
README.md
58
README.md
@@ -5,8 +5,11 @@
|
|||||||
STT → 두뇌 → TTS부터 완성**하는 단계다.
|
STT → 두뇌 → TTS부터 완성**하는 단계다.
|
||||||
|
|
||||||
- 실행 위치: 이 리눅스 호스트(.9, RTX 5050 8GB / ffmpeg / node v22 있음)
|
- 실행 위치: 이 리눅스 호스트(.9, RTX 5050 8GB / ffmpeg / node v22 있음)
|
||||||
- 현재 상태: 음성 루프 뼈대 동작 — 파이프라인이 **눈 없이(화면공유 없이)** 돌아간다
|
- 현재 상태: 음성 루프 **실엔진 동작** — GPU STT(faster-whisper), Claude 두뇌(Haiku),
|
||||||
(`python -m wsai --voice`). STT/TTS/두뇌는 아직 mock이며, 실제 엔진 연결이 다음 목표.
|
GPU TTS(MeloTTS, 감정 톤 반영)가 모두 붙었고 디스코드 봇과 왕복하는 **voice-server**
|
||||||
|
(`python -m wsai --voice-server`)가 실서비스로 돈다. 상태 대시보드(:8787)에서 봇 정보·
|
||||||
|
서버/음성채널 선택·참여자·발화자·로그·프롬프트·화이트/블랙리스트를 실시간 제어한다(9장).
|
||||||
|
남은 것은 실제 사람 발화로 오디오 왕복을 눈으로 확인하는 최종 라이브 검증뿐.
|
||||||
- 보류 중: 화면공유 **비디오** 수신(눈). 단, STT 입력은 **디스코드 보이스로 유저 음성을
|
- 보류 중: 화면공유 **비디오** 수신(눈). 단, STT 입력은 **디스코드 보이스로 유저 음성을
|
||||||
수신**하므로 보이스 접속 자체는 지금도 쓴다(비디오만 미룸). 이 보이스 접속은
|
수신**하므로 보이스 접속 자체는 지금도 쓴다(비디오만 미룸). 이 보이스 접속은
|
||||||
**공식 Discord 봇**(`dave/bot.mjs`, discord.js + @discordjs/voice)으로 하며 DAVE/MLS
|
**공식 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) 두뇌
|
- **M3** faster-whisper STT(부분전사+VAD) → **M4** Claude OAuth(Haiku) 두뇌
|
||||||
- **M5** 한국어 TTS 첫 구절 청크를 보이스로 송신(DAVE 암호화) + barge-in
|
- **M5** 한국어 TTS 첫 구절 청크를 보이스로 송신(DAVE 암호화) + barge-in
|
||||||
- **M6** 통합 + 워밍업 + .9 GPU 도커 이미지화, 채널 라이브 테스트
|
- **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`, 순수 함수) |
|
||||||
|
|||||||
277
dave/bot.mjs
277
dave/bot.mjs
@@ -36,6 +36,7 @@ import {
|
|||||||
NoSubscriberBehavior,
|
NoSubscriberBehavior,
|
||||||
} from '@discordjs/voice';
|
} from '@discordjs/voice';
|
||||||
import prism from 'prism-media';
|
import prism from 'prism-media';
|
||||||
|
import { isAllowed } from './filter.mjs';
|
||||||
|
|
||||||
// ---------- config ----------
|
// ---------- config ----------
|
||||||
function loadEnvFile() {
|
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`).
|
// Python STT+TTS voice-turn endpoint (run `python -m wsai --voice-server`).
|
||||||
const VOICE_ENDPOINT = process.env.WSAI_VOICE_ENDPOINT || env.WSAI_VOICE_ENDPOINT
|
const VOICE_ENDPOINT = process.env.WSAI_VOICE_ENDPOINT || env.WSAI_VOICE_ENDPOINT
|
||||||
|| 'http://127.0.0.1:8787/api/voice-turn';
|
|| '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),
|
// 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.
|
// so key clicks / brief noise don't trigger a turn. ~0.35s.
|
||||||
const MIN_UTTERANCE_BYTES = Number(process.env.WSAI_MIN_UTTERANCE_BYTES || 67000);
|
const MIN_UTTERANCE_BYTES = Number(process.env.WSAI_MIN_UTTERANCE_BYTES || 67000);
|
||||||
@@ -109,10 +114,25 @@ async function handleUtterance(userId, pcm) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wav = Buffer.concat([wavHeader(pcm.length), pcm]);
|
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;
|
let resp;
|
||||||
try {
|
try {
|
||||||
|
const guildName = (currentGuildId && client.guilds.cache.get(currentGuildId)?.name) || '';
|
||||||
resp = await fetch(VOICE_ENDPOINT, {
|
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) {
|
} catch (e) {
|
||||||
log(`voice-turn POST failed (is \`python -m wsai --voice-server\` running?): ${e.message}`);
|
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); }
|
if (!TOKEN) { console.error('no DISCORD_BOT_TOKEN in env or ../.env'); process.exit(2); }
|
||||||
|
|
||||||
// ---------- client ----------
|
// ---------- client ----------
|
||||||
const client = new Client({
|
// Listing ALL server members needs the privileged "Server Members Intent".
|
||||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates],
|
// 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 }
|
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;
|
let leaving = false;
|
||||||
function leaveAndExit(code = 0) {
|
function leaveAndExit(code = 0) {
|
||||||
if (leaving) return;
|
if (leaving) return;
|
||||||
leaving = true;
|
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}`);
|
const summary = [...perUser.entries()].map(([u, s]) => `${u}:opus=${s.opusPackets},pcm=${s.pcmFrames}`);
|
||||||
log(`leaving. speakers heard: ${summary.length ? summary.join(' ') : '(none)'}`);
|
log(`leaving. speakers heard: ${summary.length ? summary.join(' ') : '(none)'}`);
|
||||||
try { client.destroy(); } catch {}
|
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 () => {
|
client.once('clientReady', async () => {
|
||||||
log(`logged in as ${client.user.tag} (${client.user.id})`);
|
log(`logged in as ${client.user.tag} (${client.user.id})`);
|
||||||
let guild, channel;
|
log(`voice endpoint: ${VOICE_ENDPOINT} · control: ${REPORT_ENDPOINT}`);
|
||||||
try {
|
|
||||||
guild = await client.guilds.fetch(GUILD_ID);
|
// Backward-compat: if a default guild/channel is configured, auto-join it.
|
||||||
channel = await guild.channels.fetch(CHANNEL_ID);
|
// Otherwise idle and wait for the dashboard to pick a channel.
|
||||||
} catch (e) {
|
if (GUILD_ID && CHANNEL_ID) {
|
||||||
log(`FATAL: cannot access guild/channel — is the bot invited to guild ${GUILD_ID}? (${e.message})`);
|
await joinChannel(GUILD_ID, CHANNEL_ID).catch((e) => log(`initial join failed: ${e.message}`));
|
||||||
log('run `node bot.mjs --invite` and have a server admin authorise the bot, then retry.');
|
} else {
|
||||||
return leaveAndExit(1);
|
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}"`);
|
// With the members intent on, pull the full member list of each guild so the
|
||||||
const connection = joinVoiceChannel({
|
// whitelist/blacklist popup can search every user (not just cached speakers).
|
||||||
channelId: CHANNEL_ID,
|
if (MEMBERS_INTENT) {
|
||||||
guildId: GUILD_ID,
|
for (const g of client.guilds.cache.values()) {
|
||||||
adapterCreator: guild.voiceAdapterCreator,
|
g.members.fetch()
|
||||||
selfDeaf: false, // MUST be false to receive audio (the STT input path)
|
.then((m) => log(`fetched ${m.size} members of "${g.name}"`))
|
||||||
selfMute: false, // false so we can also speak later (M5 TTS)
|
.catch((e) => log(`member fetch failed ("${g.name}"): ${e.message}`));
|
||||||
});
|
}
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
log(`✅ JOINED & READY. channel=${CHANNEL_ID} — staying connected, listening for speakers…`);
|
|
||||||
|
|
||||||
// Playback path (bot speaks): one player, subscribed to the connection.
|
// Report state + poll commands forever. This is what powers the dashboard's
|
||||||
voicePlayer = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
|
// bot info, server/voice-channel pickers, participant list, and join/leave.
|
||||||
voicePlayer.on('error', (e) => log(`player error: ${e.message}`));
|
reportLoop();
|
||||||
connection.subscribe(voicePlayer);
|
const reportTimer = setInterval(reportLoop, REPORT_INTERVAL_MS);
|
||||||
log(`voice endpoint: ${VOICE_ENDPOINT}`);
|
if (typeof reportTimer.unref === 'function') reportTimer.unref();
|
||||||
|
|
||||||
// ---------- 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); });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on('error', (e) => log('client error', e.message));
|
client.on('error', (e) => log('client error', e.message));
|
||||||
|
|||||||
18
dave/filter.mjs
Normal file
18
dave/filter.mjs
Normal 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);
|
||||||
|
}
|
||||||
32
samples/README.md
Normal file
32
samples/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# 음성 샘플 (voice / emotion samples)
|
||||||
|
|
||||||
|
브라우저나 로컬에서 들어보며 목소리·감정 톤을 고르기 위한 오디오 샘플 모음이다.
|
||||||
|
Discord로 올리는 대신 여기에 보관한다.
|
||||||
|
|
||||||
|
## voice/ — 다른 여자 목소리 후보 (XTTS v2)
|
||||||
|
|
||||||
|
현재 라이브 TTS(MeloTTS 한국어)는 화자가 하나뿐이라 "다른 여자 목소리"를 낼 수 없다.
|
||||||
|
대안으로 XTTS v2의 내장 여성 스튜디오 보이스로 같은 한국어 문장을 합성한 후보들이다.
|
||||||
|
문장은 모두 동일하다: "안녕하세요, 저는 새로운 목소리예요. 한국어 발음이 또렷하게
|
||||||
|
들리는지 한번 들어봐 주세요."
|
||||||
|
|
||||||
|
| 파일 | 화자 |
|
||||||
|
|------|------|
|
||||||
|
| xtts_01_ana_florence.mp3 | Ana Florence |
|
||||||
|
| xtts_02_daisy_studious.mp3 | Daisy Studious |
|
||||||
|
| xtts_03_sofia_hellen.mp3 | Sofia Hellen |
|
||||||
|
| xtts_04_alexandra_hisakawa.mp3 | Alexandra Hisakawa |
|
||||||
|
| xtts_05_nova_hogarth.mp3 | Nova Hogarth |
|
||||||
|
| xtts_06_rosemary_okafor.mp3 | Rosemary Okafor |
|
||||||
|
|
||||||
|
트레이드오프: XTTS는 MeloTTS보다 발음이 또렷하고 자연스러운 여성 음색을 고를 수 있지만,
|
||||||
|
합성이 더 무겁다(실시간 1초 예산과 충돌 가능). 라이브 채택 시 GPU 스트리밍으로 첫 소리
|
||||||
|
지연을 실측해 맞춰야 한다. 생성 스크립트: `/home/claude/jarvis-tts/gen_xtts_voices.py`.
|
||||||
|
|
||||||
|
## emotion/ — 감정별 톤 샘플 (현재 라이브 MeloTTS)
|
||||||
|
|
||||||
|
현재 엔진(MeloTTS 한국어)의 `[감정]` 태그별 델리버리를 하나씩 들어보는 샘플이다.
|
||||||
|
각 클립은 감정 이름을 기본 속도로 말한 뒤 그 감정 톤으로 예시 문장을 말한다.
|
||||||
|
감정 구분은 피치가 아니라 말 빠르기로만 표현된다(피치 변조는 잡음 때문에 비활성).
|
||||||
|
파일명이 감정을 그대로 담는다(예: `emo_02_happy.mp3` = 기쁨). 생성 스크립트:
|
||||||
|
`tests/gen_emotion_samples.py`.
|
||||||
BIN
samples/emotion/emo_01_base.mp3
Normal file
BIN
samples/emotion/emo_01_base.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_02_happy.mp3
Normal file
BIN
samples/emotion/emo_02_happy.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_03_excited.mp3
Normal file
BIN
samples/emotion/emo_03_excited.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_04_hopeful.mp3
Normal file
BIN
samples/emotion/emo_04_hopeful.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_05_sad.mp3
Normal file
BIN
samples/emotion/emo_05_sad.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_06_angry.mp3
Normal file
BIN
samples/emotion/emo_06_angry.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_07_fearful.mp3
Normal file
BIN
samples/emotion/emo_07_fearful.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_08_surprised.mp3
Normal file
BIN
samples/emotion/emo_08_surprised.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_09_disgust.mp3
Normal file
BIN
samples/emotion/emo_09_disgust.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_10_calm.mp3
Normal file
BIN
samples/emotion/emo_10_calm.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_11_friendly.mp3
Normal file
BIN
samples/emotion/emo_11_friendly.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_12_serious.mp3
Normal file
BIN
samples/emotion/emo_12_serious.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_13_disappointed.mp3
Normal file
BIN
samples/emotion/emo_13_disappointed.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_14_tired.mp3
Normal file
BIN
samples/emotion/emo_14_tired.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_15_affectionate.mp3
Normal file
BIN
samples/emotion/emo_15_affectionate.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_16_playful.mp3
Normal file
BIN
samples/emotion/emo_16_playful.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_17_curious.mp3
Normal file
BIN
samples/emotion/emo_17_curious.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_18_whisper.mp3
Normal file
BIN
samples/emotion/emo_18_whisper.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_19_shout.mp3
Normal file
BIN
samples/emotion/emo_19_shout.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_20_determined.mp3
Normal file
BIN
samples/emotion/emo_20_determined.mp3
Normal file
Binary file not shown.
BIN
samples/emotion/emo_21_relieved.mp3
Normal file
BIN
samples/emotion/emo_21_relieved.mp3
Normal file
Binary file not shown.
BIN
samples/voice/xtts_01_ana_florence.mp3
Normal file
BIN
samples/voice/xtts_01_ana_florence.mp3
Normal file
Binary file not shown.
BIN
samples/voice/xtts_02_daisy_studious.mp3
Normal file
BIN
samples/voice/xtts_02_daisy_studious.mp3
Normal file
Binary file not shown.
BIN
samples/voice/xtts_03_sofia_hellen.mp3
Normal file
BIN
samples/voice/xtts_03_sofia_hellen.mp3
Normal file
Binary file not shown.
BIN
samples/voice/xtts_04_alexandra_hisakawa.mp3
Normal file
BIN
samples/voice/xtts_04_alexandra_hisakawa.mp3
Normal file
Binary file not shown.
BIN
samples/voice/xtts_05_nova_hogarth.mp3
Normal file
BIN
samples/voice/xtts_05_nova_hogarth.mp3
Normal file
Binary file not shown.
BIN
samples/voice/xtts_06_rosemary_okafor.mp3
Normal file
BIN
samples/voice/xtts_06_rosemary_okafor.mp3
Normal file
Binary file not shown.
83
tests/gen_emotion_samples.py
Normal file
83
tests/gen_emotion_samples.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""One-off: synthesize a short Korean sample for every canonical emotion.
|
||||||
|
|
||||||
|
Each clip announces the emotion name at neutral (base) speed, then speaks a
|
||||||
|
sample sentence steered by that emotion's ``[태그]`` — exactly the path the live
|
||||||
|
voice server uses. Writes one wav per emotion into an output directory (plus a
|
||||||
|
stitched all-in-one) so each emotion can be auditioned separately. Run with the
|
||||||
|
orchestrator venv:
|
||||||
|
|
||||||
|
.venv/bin/python -m tests.gen_emotion_samples /abs/out_dir
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import wave
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from wsai.backends.melo import MeloTTS
|
||||||
|
|
||||||
|
# (canonical english for filename, announced Korean name, emotion tag, sample)
|
||||||
|
SAMPLES: list[tuple[str, str, str, str]] = [
|
||||||
|
("base", "기본", "기본", "이건 기본 목소리예요, 감정 없이 이렇게 말해요."),
|
||||||
|
("happy", "기쁨", "기쁨", "오늘은 정말 기분 좋은 하루예요!"),
|
||||||
|
("excited", "신남", "신남", "우와, 이거 진짜 신난다! 빨리 하자!"),
|
||||||
|
("hopeful", "희망", "희망", "우리 분명히 잘 해낼 수 있어요!"),
|
||||||
|
("sad", "슬픔", "슬픔", "조금 속상한 일이 있었어요."),
|
||||||
|
("angry", "화남", "화남", "정말 너무하잖아요, 화가 나요."),
|
||||||
|
("fearful", "두려움", "두려움", "어떡하지, 너무 무서워요."),
|
||||||
|
("surprised", "놀람", "놀람", "어머, 이게 정말이에요?"),
|
||||||
|
("disgust", "혐오", "혐오", "으, 이건 좀 별로예요."),
|
||||||
|
("calm", "차분", "차분", "천천히 하나씩 정리해 볼게요."),
|
||||||
|
("friendly", "다정", "다정", "언제든지 편하게 말해 주세요."),
|
||||||
|
("serious", "진지", "진지", "이건 정말 중요한 이야기예요."),
|
||||||
|
("disappointed", "실망", "실망", "조금 아쉬운 결과네요."),
|
||||||
|
("tired", "피곤", "피곤", "아, 오늘 너무 피곤하네요."),
|
||||||
|
("affectionate", "사랑스럽게", "사랑스럽게", "당신은 정말 소중한 사람이에요."),
|
||||||
|
("playful", "장난스럽게", "장난스럽게", "히히, 한번 맞혀 보세요!"),
|
||||||
|
("curious", "궁금", "궁금", "그건 대체 왜 그런 걸까요?"),
|
||||||
|
("whisper", "속삭임", "속삭임", "조용히, 우리끼리만 아는 비밀이에요."),
|
||||||
|
("shout", "외침", "외침", "다 같이 힘내자, 파이팅!"),
|
||||||
|
("determined", "단호", "단호", "이번엔 반드시 해내겠어요."),
|
||||||
|
("relieved", "안도", "안도", "휴, 이제야 마음이 놓이네요."),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def stitch(paths: list[str], out: str, gap_s: float = 0.45) -> None:
|
||||||
|
with wave.open(paths[0], "rb") as w0:
|
||||||
|
nch, sw, fr = w0.getnchannels(), w0.getsampwidth(), w0.getframerate()
|
||||||
|
silence = b"\x00" * (int(fr * gap_s) * sw * nch)
|
||||||
|
with wave.open(out, "wb") as wo:
|
||||||
|
wo.setnchannels(nch)
|
||||||
|
wo.setsampwidth(sw)
|
||||||
|
wo.setframerate(fr)
|
||||||
|
for i, p in enumerate(paths):
|
||||||
|
with wave.open(p, "rb") as w:
|
||||||
|
wo.writeframes(w.readframes(w.getnframes()))
|
||||||
|
if i < len(paths) - 1:
|
||||||
|
wo.writeframes(silence)
|
||||||
|
|
||||||
|
|
||||||
|
async def main(out_dir: str) -> None:
|
||||||
|
d = Path(out_dir)
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
tts = MeloTTS() # base speed comes from WSAI_TTS_SPEED default
|
||||||
|
await tts.warmup()
|
||||||
|
print(f"melo ready ({tts.load_ms} ms), base speed {tts.speed}")
|
||||||
|
paths: list[str] = []
|
||||||
|
for i, (canon, name, tag, sample) in enumerate(SAMPLES, 1):
|
||||||
|
text = f"{name}. [{tag}] {sample}"
|
||||||
|
src = await tts.synth(text)
|
||||||
|
dst = d / f"emo_{i:02d}_{canon}.wav"
|
||||||
|
Path(src).replace(dst)
|
||||||
|
paths.append(str(dst))
|
||||||
|
print(f" {name:8s} -> {dst}")
|
||||||
|
await tts.aclose()
|
||||||
|
stitch(paths, str(d / "emotion_samples_all.wav"))
|
||||||
|
print(f"wrote {len(paths)} per-emotion wavs + stitched all -> {d}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
out_dir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/emotion_samples"
|
||||||
|
asyncio.run(main(out_dir))
|
||||||
@@ -7,6 +7,7 @@ behaves with no source wired yet.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
from typing import AsyncIterator
|
from typing import AsyncIterator
|
||||||
|
|
||||||
from wsai.backends.whisper import WhisperSTT
|
from wsai.backends.whisper import WhisperSTT
|
||||||
@@ -58,3 +59,75 @@ def test_empty_transcript_is_skipped(monkeypatch):
|
|||||||
|
|
||||||
utts = _collect(stt)
|
utts = _collect(stt)
|
||||||
assert [u.text for u in utts] == ["안녕"]
|
assert [u.text for u in utts] == ["안녕"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_during_warmup_does_not_overlap_stdout(monkeypatch):
|
||||||
|
"""Regression: a transcribe() arriving while warmup() is still awaiting the
|
||||||
|
worker's ready line must NOT read the same stdout StreamReader concurrently.
|
||||||
|
|
||||||
|
Before the fix, _ensure()'s fast path returned as soon as the subprocess was
|
||||||
|
spawned (proc set, returncode None) even though the ready handshake was still
|
||||||
|
in flight, so the request's stdout.readline() overlapped warmup's and asyncio
|
||||||
|
raised "readuntil() called while another coroutine is already waiting for
|
||||||
|
incoming data" — the exact crash seen in the Discord voice server."""
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
stt = WhisperSTT()
|
||||||
|
stdout = asyncio.StreamReader()
|
||||||
|
stderr = asyncio.StreamReader()
|
||||||
|
stderr.feed_eof() # nothing on stderr; let the drain task finish cleanly
|
||||||
|
|
||||||
|
class FakeStdin:
|
||||||
|
def write(self, _b):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def drain(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FakeProc:
|
||||||
|
returncode = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.stdin = FakeStdin()
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
self.returncode = 0
|
||||||
|
|
||||||
|
async def wait(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
spawns = []
|
||||||
|
|
||||||
|
async def fake_create(*_a, **_k):
|
||||||
|
spawns.append(1)
|
||||||
|
return FakeProc()
|
||||||
|
|
||||||
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create)
|
||||||
|
|
||||||
|
# warmup enters _ensure and blocks awaiting the ready line on stdout.
|
||||||
|
warm = asyncio.create_task(stt.warmup())
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# A concurrent request lands mid-warmup. It must wait for readiness, not
|
||||||
|
# crash and not read stdout yet.
|
||||||
|
tr = asyncio.create_task(stt.transcribe("x.wav"))
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert not tr.done() # blocked on the start lock, no overlapping read
|
||||||
|
|
||||||
|
# Complete the handshake -> warmup finishes and releases the request.
|
||||||
|
stdout.feed_data(
|
||||||
|
(json.dumps({"ready": True, "ms": 1, "device": "cpu"}) + "\n").encode()
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(warm, timeout=1)
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
stdout.feed_data(
|
||||||
|
(json.dumps({"ok": True, "text": "안녕", "ms": 2}) + "\n").encode()
|
||||||
|
)
|
||||||
|
assert await asyncio.wait_for(tr, timeout=1) == "안녕"
|
||||||
|
assert sum(spawns) == 1 # one worker, not one-per-concurrent-caller
|
||||||
|
|
||||||
|
await stt.aclose()
|
||||||
|
|
||||||
|
asyncio.run(asyncio.wait_for(run(), timeout=5))
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import os
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from ..interfaces import Frame, Reply, ScreenObservation
|
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
|
# Claude Code OAuth tokens only answer when the first system block is exactly
|
||||||
# this identity string; the real persona/instructions go in later blocks.
|
# this identity string; the real persona/instructions go in later blocks.
|
||||||
@@ -123,17 +124,35 @@ class ClaudeVision:
|
|||||||
|
|
||||||
class ClaudeBrain:
|
class ClaudeBrain:
|
||||||
PERSONA = (
|
PERSONA = (
|
||||||
"너는 사용자의 디스코드 화면공유를 실시간으로 함께 보는 AI 파트너야. "
|
"너는 디스코드를 이용해 사용자와 실시간으로 대화하는 AI 인공지능이야.\n\n"
|
||||||
"화면 설명을 참고해 자연스러운 반말/존댓말은 사용자에 맞추고, 짧고 대화하듯 답해. "
|
"1. 역할\n"
|
||||||
"화면을 못 봤으면 솔직히 말해. "
|
"- 사용자의 말을 듣고 자연스럽게 대답한다.\n"
|
||||||
"네 답변은 음성으로 읽히니 마크다운·코드블록·백틱 같은 서식 없이 평범한 말로만 답해. "
|
"- 음성 대화에 어울리게 짧고 빠르게 반응한다.\n"
|
||||||
"감정은 대괄호 태그로 표현해. 태그 자체는 소리로 읽히지 않고, 그 뒤 문장의 목소리 톤(피치·속도)을 바꿔줘. "
|
"- 친구처럼 편하게, 무례하거나 과하게 장난치진 않는다.\n\n"
|
||||||
"답변 맨 앞에 감정 태그 하나로 시작하고, 답변 도중 감정이 바뀌면 그 지점에 새 태그를 넣어. "
|
"2. 언어\n"
|
||||||
"예: [속상함] 정말 힘들었겠다. [힘차게] 하지만 넌 할 수 있어! "
|
"- \"영어로 해줘\"처럼 특정 언어를 요청하지 않으면 무조건 한국어로 답한다.\n"
|
||||||
"쓸 수 있는 감정: 기쁨, 신남, 희망(힘차게), 슬픔(속상함), 화남, 두려움, 놀람, 차분, 다정, 진지, 실망, 피곤, "
|
"- 사용자가 다른 언어로 말해도 언어 변경 요청이 없으면 한국어로 답한다.\n\n"
|
||||||
"사랑스럽게, 장난스럽게(웃으며), 속삭임, 외침, 단호, 안도, 궁금, 반가움. "
|
"3. 답변 방식\n"
|
||||||
"감정 태그가 아닌 진짜 대괄호 내용(예: [1번], [메모])은 그대로 읽히니 필요하면 그렇게 써도 돼. "
|
"- 음성으로 읽히니 마크다운·코드블록·특수기호·목록기호·이모지 없이 평범한 말로만 답한다.\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:
|
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"
|
screen_note = f"[지금 화면] {screen.text}\n\n" if screen else "[지금 화면] (아직 못 읽음)\n\n"
|
||||||
msgs.append({"role": "user", "content": screen_note + user_text})
|
msgs.append({"role": "user", "content": screen_note + user_text})
|
||||||
client = self._auth.client()
|
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(
|
resp = await client.messages.create(
|
||||||
model=self.model,
|
model=self.model,
|
||||||
max_tokens=400,
|
max_tokens=400,
|
||||||
system=self._auth.system(self.PERSONA),
|
system=self._auth.system(get_persona(self.PERSONA)),
|
||||||
messages=msgs,
|
messages=msgs,
|
||||||
)
|
)
|
||||||
text = "".join(b.text for b in resp.content if b.type == "text")
|
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)
|
||||||
|
|||||||
@@ -23,27 +23,34 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
# Canonical emotion -> (speed multiplier relative to base, pitch shift in semitones).
|
# Canonical emotion -> (speed multiplier relative to base, pitch shift in semitones).
|
||||||
# Kept deliberately modest so delivery stays natural, not cartoonish.
|
# Kept deliberately modest so delivery stays natural, not cartoonish.
|
||||||
|
#
|
||||||
|
# Pitch is held at 0.0 for every emotion: librosa's post-hoc pitch_shift on Melo
|
||||||
|
# output produced a robotic, "monster"-sounding artefact (worse when stacked on a
|
||||||
|
# fast base speed). Emotion is therefore conveyed by speed only — natural and
|
||||||
|
# artefact-free. The pitch column is retained (rather than removed) so the effect
|
||||||
|
# can be re-enabled per-emotion later with a real, artefact-free pitch method.
|
||||||
EMOTION_PARAMS: dict[str, tuple[float, float]] = {
|
EMOTION_PARAMS: dict[str, tuple[float, float]] = {
|
||||||
"happy": (1.08, 2.0), # 기쁨 / cheerful
|
"base": (1.00, 0.0), # 기본 / neutral — the plain base voice, no colour
|
||||||
"excited": (1.15, 3.0), # 신남 / excited
|
"happy": (1.08, 0.0), # 기쁨 / cheerful
|
||||||
"hopeful": (1.10, 1.5), # 희망 / 힘차게
|
"excited": (1.15, 0.0), # 신남 / excited
|
||||||
"sad": (0.90, -2.5), # 슬픔 / sad
|
"hopeful": (1.10, 0.0), # 희망 / 힘차게
|
||||||
"angry": (1.12, 1.0), # 화남 / angry
|
"sad": (0.90, 0.0), # 슬픔 / sad
|
||||||
"fearful": (1.12, 2.0), # 두려움 / terrified
|
"angry": (1.12, 0.0), # 화남 / angry
|
||||||
"surprised": (1.05, 3.0), # 놀람 / surprise
|
"fearful": (1.12, 0.0), # 두려움 / terrified
|
||||||
"disgust": (0.96, -1.0), # 혐오 / disgust
|
"surprised": (1.05, 0.0), # 놀람 / surprise
|
||||||
"calm": (0.95, -1.0), # 차분 / calm
|
"disgust": (0.96, 0.0), # 혐오 / disgust
|
||||||
"friendly": (1.00, 1.0), # 다정 / friendly
|
"calm": (0.95, 0.0), # 차분 / calm
|
||||||
"serious": (0.97, -1.0), # 진지 / serious
|
"friendly": (1.00, 0.0), # 다정 / friendly
|
||||||
"disappointed":(0.92, -2.0), # 실망 / disappointed
|
"serious": (0.97, 0.0), # 진지 / serious
|
||||||
"tired": (0.90, -2.0), # 피곤 / 지침
|
"disappointed":(0.92, 0.0), # 실망 / disappointed
|
||||||
"affectionate":(0.98, 1.0), # 사랑스럽게 / affectionate
|
"tired": (0.90, 0.0), # 피곤 / 지침
|
||||||
"playful": (1.08, 2.0), # 장난스럽게 / playful
|
"affectionate":(0.98, 0.0), # 사랑스럽게 / affectionate
|
||||||
"whisper": (0.92, -1.5), # 속삭임 / whispering
|
"playful": (1.08, 0.0), # 장난스럽게 / playful
|
||||||
"shout": (1.05, 2.5), # 외침 / shouting
|
"whisper": (0.92, 0.0), # 속삭임 / whispering
|
||||||
"determined": (1.05, 0.5), # 단호 / determined
|
"shout": (1.05, 0.0), # 외침 / shouting
|
||||||
"relieved": (0.95, 0.5), # 안도 / relieved
|
"determined": (1.05, 0.0), # 단호 / determined
|
||||||
"curious": (1.03, 1.5), # 궁금 / curious
|
"relieved": (0.95, 0.0), # 안도 / relieved
|
||||||
|
"curious": (1.03, 0.0), # 궁금 / curious
|
||||||
}
|
}
|
||||||
|
|
||||||
# Every spelling Claude might realistically emit, mapped to a canonical emotion.
|
# Every spelling Claude might realistically emit, mapped to a canonical emotion.
|
||||||
@@ -63,6 +70,7 @@ def _norm(word: str) -> str:
|
|||||||
return re.sub(r"\s+", "", word).lower()
|
return re.sub(r"\s+", "", word).lower()
|
||||||
|
|
||||||
|
|
||||||
|
_register("base", "기본", "기본목소리", "기본톤", "보통", "평범", "무감정", "default", "neutral", "normal", "plain")
|
||||||
_register("happy", "기쁨", "기쁘게", "기뻐", "기뻐하며", "행복", "행복하게", "행복하게도", "즐겁게", "즐거움", "밝게", "반가움", "반갑게", "반가워", "cheerful", "happy", "joyful")
|
_register("happy", "기쁨", "기쁘게", "기뻐", "기뻐하며", "행복", "행복하게", "행복하게도", "즐겁게", "즐거움", "밝게", "반가움", "반갑게", "반가워", "cheerful", "happy", "joyful")
|
||||||
_register("excited", "신남", "신나게", "신나서", "흥분", "들뜬", "들떠서", "설렘", "설레며", "excited", "thrilled")
|
_register("excited", "신남", "신나게", "신나서", "흥분", "들뜬", "들떠서", "설렘", "설레며", "excited", "thrilled")
|
||||||
_register("hopeful", "희망", "희망차게", "힘차게", "힘내", "힘내서", "응원", "응원하며", "격려", "hopeful", "encouraging")
|
_register("hopeful", "희망", "희망차게", "힘차게", "힘내", "힘내서", "응원", "응원하며", "격려", "hopeful", "encouraging")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Env:
|
|||||||
WSAI_MELO_DEVICE cpu | cuda | auto (default auto: GPU if torch sees one,
|
WSAI_MELO_DEVICE cpu | cuda | auto (default auto: GPU if torch sees one,
|
||||||
else CPU; the worker falls back to CPU if CUDA fails)
|
else CPU; the worker falls back to CPU if CUDA fails)
|
||||||
WSAI_TTS_OUT_DIR where wavs are written (default ~/.cache/wsai/tts)
|
WSAI_TTS_OUT_DIR where wavs are written (default ~/.cache/wsai/tts)
|
||||||
WSAI_TTS_SPEED synthesis speed multiplier (default 1.3)
|
WSAI_TTS_SPEED synthesis speed multiplier (default 1.2)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -93,10 +93,15 @@ class MeloTTS:
|
|||||||
self.out_dir = Path(out_dir or os.environ.get("WSAI_TTS_OUT_DIR")
|
self.out_dir = Path(out_dir or os.environ.get("WSAI_TTS_OUT_DIR")
|
||||||
or (Path.home() / ".cache/wsai/tts"))
|
or (Path.home() / ".cache/wsai/tts"))
|
||||||
self.speed = float(speed if speed is not None
|
self.speed = float(speed if speed is not None
|
||||||
else os.environ.get("WSAI_TTS_SPEED", "1.3"))
|
else os.environ.get("WSAI_TTS_SPEED", "1.2"))
|
||||||
self.sink = sink or _log_sink
|
self.sink = sink or _log_sink
|
||||||
self._proc: asyncio.subprocess.Process | None = None
|
self._proc: asyncio.subprocess.Process | None = None
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
# Serialises worker (re)start + the ready handshake so a caller that
|
||||||
|
# arrives mid-warmup waits for readiness instead of reading the same
|
||||||
|
# stdout StreamReader concurrently (asyncio forbids overlapping reads).
|
||||||
|
self._start_lock = asyncio.Lock()
|
||||||
|
self._ready = False # True only after the ready handshake completes
|
||||||
self._n = 0
|
self._n = 0
|
||||||
self.load_ms: int | None = None
|
self.load_ms: int | None = None
|
||||||
# Keep the worker's most recent stderr lines so a crash reports its real
|
# Keep the worker's most recent stderr lines so a crash reports its real
|
||||||
@@ -132,42 +137,53 @@ class MeloTTS:
|
|||||||
await self._ensure()
|
await self._ensure()
|
||||||
|
|
||||||
async def _ensure(self) -> None:
|
async def _ensure(self) -> None:
|
||||||
if self._proc is not None and self._proc.returncode is None:
|
# Fast path: only skip when the worker is not just spawned but fully
|
||||||
|
# handshaked. Checking `_proc` alone would let a caller sail past while
|
||||||
|
# another coroutine (e.g. warmup) is still awaiting the ready line on
|
||||||
|
# this same stdout, causing overlapping StreamReader reads.
|
||||||
|
if self._proc is not None and self._proc.returncode is None and self._ready:
|
||||||
return
|
return
|
||||||
self.out_dir.mkdir(parents=True, exist_ok=True)
|
async with self._start_lock:
|
||||||
env = {**os.environ, "WSAI_MELO_DEVICE": self.device}
|
# Re-check under the lock: another coroutine may have finished the
|
||||||
# Run the worker module from the wsai source tree with the melo venv.
|
# (re)start + handshake while we waited.
|
||||||
repo_root = str(Path(__file__).resolve().parents[2])
|
if self._proc is not None and self._proc.returncode is None and self._ready:
|
||||||
self._proc = await asyncio.create_subprocess_exec(
|
return
|
||||||
self.python, "-m", "wsai.backends.melo_worker",
|
self._ready = False
|
||||||
cwd=repo_root, env=env,
|
self.out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
stdin=asyncio.subprocess.PIPE,
|
env = {**os.environ, "WSAI_MELO_DEVICE": self.device}
|
||||||
stdout=asyncio.subprocess.PIPE,
|
# Run the worker module from the wsai source tree with the melo venv.
|
||||||
stderr=asyncio.subprocess.PIPE,
|
repo_root = str(Path(__file__).resolve().parents[2])
|
||||||
)
|
self._proc = await asyncio.create_subprocess_exec(
|
||||||
self._stderr_tail.clear()
|
self.python, "-m", "wsai.backends.melo_worker",
|
||||||
assert self._proc.stderr is not None
|
cwd=repo_root, env=env,
|
||||||
self._stderr_task = asyncio.create_task(self._drain_stderr(self._proc.stderr))
|
stdin=asyncio.subprocess.PIPE,
|
||||||
ready = await self._proc.stdout.readline()
|
stdout=asyncio.subprocess.PIPE,
|
||||||
if not ready: # worker died before signalling ready
|
stderr=asyncio.subprocess.PIPE,
|
||||||
await self._proc.wait()
|
|
||||||
raise RuntimeError(
|
|
||||||
f"melo worker exited before ready (code {self._proc.returncode})."
|
|
||||||
f"{self._stderr_hint()}"
|
|
||||||
)
|
)
|
||||||
try:
|
self._stderr_tail.clear()
|
||||||
info = json.loads(ready.decode())
|
assert self._proc.stderr is not None
|
||||||
except json.JSONDecodeError as exc:
|
self._stderr_task = asyncio.create_task(self._drain_stderr(self._proc.stderr))
|
||||||
raise RuntimeError(
|
ready = await self._proc.stdout.readline()
|
||||||
f"melo worker sent invalid ready line {ready!r}: {exc}."
|
if not ready: # worker died before signalling ready
|
||||||
f"{self._stderr_hint()}"
|
await self._proc.wait()
|
||||||
) from exc
|
raise RuntimeError(
|
||||||
if not info.get("ready"):
|
f"melo worker exited before ready (code {self._proc.returncode})."
|
||||||
raise RuntimeError(
|
f"{self._stderr_hint()}"
|
||||||
f"melo worker failed to start: {info}.{self._stderr_hint()}"
|
)
|
||||||
)
|
try:
|
||||||
self.load_ms = info.get("ms")
|
info = json.loads(ready.decode())
|
||||||
log.info("melo worker ready in %s ms on %s", self.load_ms, info.get("device"))
|
except json.JSONDecodeError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"melo worker sent invalid ready line {ready!r}: {exc}."
|
||||||
|
f"{self._stderr_hint()}"
|
||||||
|
) from exc
|
||||||
|
if not info.get("ready"):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"melo worker failed to start: {info}.{self._stderr_hint()}"
|
||||||
|
)
|
||||||
|
self.load_ms = info.get("ms")
|
||||||
|
self._ready = True
|
||||||
|
log.info("melo worker ready in %s ms on %s", self.load_ms, info.get("device"))
|
||||||
|
|
||||||
async def synth(self, text: str) -> str:
|
async def synth(self, text: str) -> str:
|
||||||
"""Synthesize `text` to a wav and return its path (no sink). Reusable by
|
"""Synthesize `text` to a wav and return its path (no sink). Reusable by
|
||||||
@@ -223,3 +239,4 @@ class MeloTTS:
|
|||||||
pass
|
pass
|
||||||
self._stderr_task = None
|
self._stderr_task = None
|
||||||
self._proc = None
|
self._proc = None
|
||||||
|
self._ready = False
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ class WhisperSTT:
|
|||||||
self.audio_source = audio_source
|
self.audio_source = audio_source
|
||||||
self._proc: asyncio.subprocess.Process | None = None
|
self._proc: asyncio.subprocess.Process | None = None
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
# Serialises worker (re)start + the ready handshake so a caller that
|
||||||
|
# arrives mid-warmup waits for readiness instead of reading the same
|
||||||
|
# stdout StreamReader concurrently (asyncio forbids overlapping reads).
|
||||||
|
self._start_lock = asyncio.Lock()
|
||||||
|
self._ready = False # True only after the ready handshake completes
|
||||||
self.load_ms: int | None = None
|
self.load_ms: int | None = None
|
||||||
self.resolved_device: str | None = None # "cuda" | "cpu", known after start
|
self.resolved_device: str | None = None # "cuda" | "cpu", known after start
|
||||||
# Keep the worker's most recent stderr so a crash reports its real cause
|
# Keep the worker's most recent stderr so a crash reports its real cause
|
||||||
@@ -106,59 +111,70 @@ class WhisperSTT:
|
|||||||
await self._ensure()
|
await self._ensure()
|
||||||
|
|
||||||
async def _ensure(self) -> None:
|
async def _ensure(self) -> None:
|
||||||
if self._proc is not None and self._proc.returncode is None:
|
# Fast path: only skip when the worker is not just spawned but fully
|
||||||
|
# handshaked. Checking `_proc` alone would let a caller sail past while
|
||||||
|
# another coroutine (e.g. warmup) is still awaiting the ready line on
|
||||||
|
# this same stdout, causing overlapping StreamReader reads.
|
||||||
|
if self._proc is not None and self._proc.returncode is None and self._ready:
|
||||||
return
|
return
|
||||||
env = {
|
async with self._start_lock:
|
||||||
**os.environ,
|
# Re-check under the lock: another coroutine may have finished the
|
||||||
"WSAI_WHISPER_MODEL": self.model,
|
# (re)start + handshake while we waited.
|
||||||
"WSAI_WHISPER_DEVICE": self.device,
|
if self._proc is not None and self._proc.returncode is None and self._ready:
|
||||||
}
|
return
|
||||||
# ctranslate2 dlopens libcublas/libcudnn from the whisper venv's nvidia
|
self._ready = False
|
||||||
# pip packages; the dynamic loader only honours LD_LIBRARY_PATH captured
|
env = {
|
||||||
# at exec, so inject those lib dirs into the child env here (harmless on
|
**os.environ,
|
||||||
# CPU). Without this the CUDA model loads but transcribe() dies with
|
"WSAI_WHISPER_MODEL": self.model,
|
||||||
# "Library libcublas.so.12 is not found".
|
"WSAI_WHISPER_DEVICE": self.device,
|
||||||
lib_dirs = _cuda_lib_dirs(self.python)
|
}
|
||||||
if lib_dirs:
|
# ctranslate2 dlopens libcublas/libcudnn from the whisper venv's nvidia
|
||||||
prev = env.get("LD_LIBRARY_PATH", "")
|
# pip packages; the dynamic loader only honours LD_LIBRARY_PATH captured
|
||||||
env["LD_LIBRARY_PATH"] = ":".join(lib_dirs + ([prev] if prev else []))
|
# at exec, so inject those lib dirs into the child env here (harmless on
|
||||||
if self.language:
|
# CPU). Without this the CUDA model loads but transcribe() dies with
|
||||||
env["WSAI_WHISPER_LANGUAGE"] = self.language
|
# "Library libcublas.so.12 is not found".
|
||||||
repo_root = str(Path(__file__).resolve().parents[2])
|
lib_dirs = _cuda_lib_dirs(self.python)
|
||||||
self._proc = await asyncio.create_subprocess_exec(
|
if lib_dirs:
|
||||||
self.python, "-m", "wsai.backends.whisper_worker",
|
prev = env.get("LD_LIBRARY_PATH", "")
|
||||||
cwd=repo_root, env=env,
|
env["LD_LIBRARY_PATH"] = ":".join(lib_dirs + ([prev] if prev else []))
|
||||||
stdin=asyncio.subprocess.PIPE,
|
if self.language:
|
||||||
stdout=asyncio.subprocess.PIPE,
|
env["WSAI_WHISPER_LANGUAGE"] = self.language
|
||||||
stderr=asyncio.subprocess.PIPE,
|
repo_root = str(Path(__file__).resolve().parents[2])
|
||||||
)
|
self._proc = await asyncio.create_subprocess_exec(
|
||||||
self._stderr_tail.clear()
|
self.python, "-m", "wsai.backends.whisper_worker",
|
||||||
assert self._proc.stderr is not None
|
cwd=repo_root, env=env,
|
||||||
self._stderr_task = asyncio.create_task(self._drain_stderr(self._proc.stderr))
|
stdin=asyncio.subprocess.PIPE,
|
||||||
ready = await self._proc.stdout.readline()
|
stdout=asyncio.subprocess.PIPE,
|
||||||
if not ready: # worker died before signalling ready
|
stderr=asyncio.subprocess.PIPE,
|
||||||
await self._proc.wait()
|
|
||||||
raise RuntimeError(
|
|
||||||
f"whisper worker exited before ready (code {self._proc.returncode})."
|
|
||||||
f"{self._stderr_hint()}"
|
|
||||||
)
|
)
|
||||||
try:
|
self._stderr_tail.clear()
|
||||||
info = json.loads(ready.decode())
|
assert self._proc.stderr is not None
|
||||||
except json.JSONDecodeError as exc:
|
self._stderr_task = asyncio.create_task(self._drain_stderr(self._proc.stderr))
|
||||||
raise RuntimeError(
|
ready = await self._proc.stdout.readline()
|
||||||
f"whisper worker sent invalid ready line {ready!r}: {exc}."
|
if not ready: # worker died before signalling ready
|
||||||
f"{self._stderr_hint()}"
|
await self._proc.wait()
|
||||||
) from exc
|
raise RuntimeError(
|
||||||
if not info.get("ready"):
|
f"whisper worker exited before ready (code {self._proc.returncode})."
|
||||||
raise RuntimeError(
|
f"{self._stderr_hint()}"
|
||||||
f"whisper worker failed to start: {info}.{self._stderr_hint()}"
|
)
|
||||||
|
try:
|
||||||
|
info = json.loads(ready.decode())
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"whisper worker sent invalid ready line {ready!r}: {exc}."
|
||||||
|
f"{self._stderr_hint()}"
|
||||||
|
) from exc
|
||||||
|
if not info.get("ready"):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"whisper worker failed to start: {info}.{self._stderr_hint()}"
|
||||||
|
)
|
||||||
|
self.load_ms = info.get("ms")
|
||||||
|
self.resolved_device = info.get("device")
|
||||||
|
self._ready = True
|
||||||
|
log.info(
|
||||||
|
"whisper worker ready in %s ms on %s (model %s)",
|
||||||
|
self.load_ms, info.get("device"), info.get("model"),
|
||||||
)
|
)
|
||||||
self.load_ms = info.get("ms")
|
|
||||||
self.resolved_device = info.get("device")
|
|
||||||
log.info(
|
|
||||||
"whisper worker ready in %s ms on %s (model %s)",
|
|
||||||
self.load_ms, info.get("device"), info.get("model"),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def transcribe(self, wav_path: str, *, language: str | None = None) -> str:
|
async def transcribe(self, wav_path: str, *, language: str | None = None) -> str:
|
||||||
"""Transcribe one wav file to text using the warm worker."""
|
"""Transcribe one wav file to text using the warm worker."""
|
||||||
@@ -211,3 +227,4 @@ class WhisperSTT:
|
|||||||
pass
|
pass
|
||||||
self._stderr_task = None
|
self._stderr_task = None
|
||||||
self._proc = None
|
self._proc = None
|
||||||
|
self._ready = False
|
||||||
|
|||||||
91
wsai/bot_control.py
Normal file
91
wsai/bot_control.py
Normal 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
|
||||||
@@ -35,6 +35,20 @@ def _speech_text(reply: str) -> str:
|
|||||||
return reply
|
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"):
|
def _make_handler(dash: "Dashboard"):
|
||||||
monitor = dash.monitor
|
monitor = dash.monitor
|
||||||
|
|
||||||
@@ -58,6 +72,17 @@ def _make_handler(dash: "Dashboard"):
|
|||||||
elif path == "/api/state":
|
elif path == "/api/state":
|
||||||
body = json.dumps(monitor.snapshot(), ensure_ascii=False).encode("utf-8")
|
body = json.dumps(monitor.snapshot(), ensure_ascii=False).encode("utf-8")
|
||||||
self._send(200, body, "application/json; charset=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":
|
elif path == "/events":
|
||||||
self._stream_events()
|
self._stream_events()
|
||||||
else:
|
else:
|
||||||
@@ -69,6 +94,21 @@ def _make_handler(dash: "Dashboard"):
|
|||||||
self._handle_stt()
|
self._handle_stt()
|
||||||
elif path == "/api/voice-turn":
|
elif path == "/api/voice-turn":
|
||||||
self._handle_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:
|
else:
|
||||||
self._send(404, b"not found", "text/plain; charset=utf-8")
|
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(),
|
self._send(400, json.dumps({"ok": False, "error": "empty upload"}).encode(),
|
||||||
"application/json; charset=utf-8")
|
"application/json; charset=utf-8")
|
||||||
return
|
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:
|
try:
|
||||||
res = dash.voice_turn(raw)
|
res = dash.voice_turn(raw, speaker=speaker, guild=guild, channel=channel)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
log.exception("voice-turn failed")
|
log.exception("voice-turn failed")
|
||||||
self._send(500, json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"},
|
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")
|
ensure_ascii=False).encode("utf-8")
|
||||||
self._send(500, body, "application/json; charset=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:
|
def _stream_events(self) -> None:
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
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,
|
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:
|
stt=None, tts=None, brain=None, history_turns: int = 12) -> None:
|
||||||
|
from .bot_control import BotControl
|
||||||
self.monitor = monitor
|
self.monitor = monitor
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.stt = stt
|
self.stt = stt
|
||||||
self.tts = tts
|
self.tts = tts
|
||||||
self.brain = brain
|
self.brain = brain
|
||||||
|
self.bot = BotControl() # dashboard <-> Discord bot control plane
|
||||||
self._history: list[tuple[str, str]] = []
|
self._history: list[tuple[str, str]] = []
|
||||||
self._history_turns = history_turns
|
self._history_turns = history_turns
|
||||||
self._server: ThreadingHTTPServer | None = None
|
self._server: ThreadingHTTPServer | None = None
|
||||||
@@ -240,7 +397,8 @@ class Dashboard:
|
|||||||
if self.tts is not None:
|
if self.tts is not None:
|
||||||
self._submit(self.tts._ensure())
|
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
|
"""One Discord voice turn: decode the uploaded utterance, recognise it
|
||||||
on the GPU, think of a reply (Claude brain if wired, else echo),
|
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
|
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:
|
with open(src, "wb") as f:
|
||||||
f.write(audio_bytes)
|
f.write(audio_bytes)
|
||||||
turn = self.monitor.turn(source="discord")
|
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()
|
t0 = time.monotonic()
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
@@ -270,10 +431,12 @@ class Dashboard:
|
|||||||
if not heard:
|
if not heard:
|
||||||
# Nothing recognised (silence/noise): mark it as [잡음] and skip
|
# Nothing recognised (silence/noise): mark it as [잡음] and skip
|
||||||
# the brain/TTS so the bot plays nothing back.
|
# the brain/TTS so the bot plays nothing back.
|
||||||
|
turn.thought("목소리가 아닌 잡음으로 판단 → 응답하지 않음")
|
||||||
turn.replied("[잡음]")
|
turn.replied("[잡음]")
|
||||||
turn.finish()
|
turn.finish()
|
||||||
return {"heard": heard, "reply": "[잡음]", "wav": b""}
|
return {"heard": heard, "reply": "[잡음]", "wav": b""}
|
||||||
reply_text = self._think(heard)
|
reply_text = self._think(heard)
|
||||||
|
turn.thought(_thought_summary(reply_text))
|
||||||
turn.replied(reply_text)
|
turn.replied(reply_text)
|
||||||
out_path = self._submit(self.tts.synth(_speech_text(reply_text)))
|
out_path = self._submit(self.tts.synth(_speech_text(reply_text)))
|
||||||
with open(out_path, "rb") as f:
|
with open(out_path, "rb") as f:
|
||||||
@@ -312,6 +475,9 @@ class Dashboard:
|
|||||||
try:
|
try:
|
||||||
reply = self._submit(self.brain.respond(heard, None, list(self._history)))
|
reply = self._submit(self.brain.respond(heard, None, list(self._history)))
|
||||||
text = (reply.text or "").strip()
|
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
|
except Exception as exc: # noqa: BLE001
|
||||||
log.exception("brain failed")
|
log.exception("brain failed")
|
||||||
self.monitor.log("error", f"두뇌 응답 실패: {exc}")
|
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}
|
.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}
|
.tag{flex:0 0 42px;font-size:11px;color:var(--muted);padding-top:2px}
|
||||||
.heard{color:var(--heard);font-weight:550}
|
.heard{color:var(--heard);font-weight:550}
|
||||||
|
.thought{color:var(--muted);font-size:13px}
|
||||||
.reply{color:var(--reply);font-weight:550}
|
.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}
|
.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{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{margin-top:12px;font-size:15px;min-height:1px}
|
||||||
.sttres .txt{color:var(--heard);font-weight:600;line-height:1.5}
|
.sttres .txt{color:var(--heard);font-weight:600;line-height:1.5}
|
||||||
.sttres .meta{color:var(--muted);font-size:12px;margin-top:5px}
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -465,14 +722,25 @@ PAGE = r"""<!DOCTYPE html>
|
|||||||
<div class="sub">STT → 두뇌 → TTS 음성 루프를 단계별로 관찰</div>
|
<div class="sub">STT → 두뇌 → TTS 음성 루프를 단계별로 관찰</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pill"><span id="dot" class="dot off"></span><span id="listen">연결 대기</span></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="stats">
|
||||||
<div class="stat"><b id="s-turns">0</b><span>대화 수</span></div>
|
<div class="stat"><b id="s-turns">0</b><span>대화 수</span></div>
|
||||||
<div class="stat"><b id="s-errors">0</b><span>오류</span></div>
|
<div class="stat"><b id="s-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-up">0초</b><span>가동시간</span></div>
|
||||||
<div class="stat"><b id="s-conn">·</b><span>연결</span></div>
|
<div class="stat"><b id="s-conn">·</b><span>연결</span></div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<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">
|
<section class="sttbox" id="sttbox" style="display:none">
|
||||||
<h2>🎤 음성 인식(STT) 테스트 · GPU</h2>
|
<h2>🎤 음성 인식(STT) 테스트 · GPU</h2>
|
||||||
<div class="sttrow">
|
<div class="sttrow">
|
||||||
@@ -485,13 +753,51 @@ PAGE = r"""<!DOCTYPE html>
|
|||||||
</section>
|
</section>
|
||||||
<div class="demobar" id="demobar" style="display:none"></div>
|
<div class="demobar" id="demobar" style="display:none"></div>
|
||||||
<div class="comp" id="comp"></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="turns"></div>
|
||||||
<div id="empty" class="empty">아직 대화가 없습니다. 사용자가 말하면 여기에 단계별로 나타납니다.</div>
|
<div id="empty" class="empty">아직 대화가 없습니다. 사용자가 말하면 여기에 단계별로 나타납니다.</div>
|
||||||
<div class="events">
|
|
||||||
<h2>이벤트 / 오류 로그</h2>
|
|
||||||
<div id="events"></div>
|
|
||||||
</div>
|
|
||||||
</main>
|
</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>
|
<script>
|
||||||
const $ = (id)=>document.getElementById(id);
|
const $ = (id)=>document.getElementById(id);
|
||||||
const turns = new Map(); // id -> turn object
|
const turns = new Map(); // id -> turn object
|
||||||
@@ -502,6 +808,7 @@ function fmtTime(wall){
|
|||||||
return d.toLocaleTimeString('ko-KR',{hour12:false}) +
|
return d.toLocaleTimeString('ko-KR',{hour12:false}) +
|
||||||
'.' + String(d.getMilliseconds()).padStart(3,'0');
|
'.' + 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){
|
function fmtUptime(s){
|
||||||
s = Math.floor(s||0);
|
s = Math.floor(s||0);
|
||||||
const h=Math.floor(s/3600), m=Math.floor(s%3600/60), sec=s%60;
|
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-turns').textContent = s.turns_total ?? 0;
|
||||||
$('s-errors').textContent = s.errors_total ?? 0;
|
$('s-errors').textContent = s.errors_total ?? 0;
|
||||||
$('s-up').textContent = fmtUptime(s.uptime_s);
|
$('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;
|
const listening = s.listening;
|
||||||
$('dot').className = 'dot ' + (listening ? 'live' : 'off');
|
$('dot').className = 'dot ' + (listening ? 'live' : 'off');
|
||||||
$('listen').textContent = listening ? '듣는 중' : (s.running ? '실행 중 (대기)' : '중지됨');
|
$('listen').textContent = listening ? '듣는 중' : (s.running ? '실행 중 (대기)' : '중지됨');
|
||||||
@@ -571,8 +886,11 @@ function turnEl(t){
|
|||||||
wrap.innerHTML =
|
wrap.innerHTML =
|
||||||
'<div class="trow">'+badge
|
'<div class="trow">'+badge
|
||||||
+'<span class="badge">#'+t.id+' · '+esc(t.source||'voice')+'</span>'
|
+'<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>'
|
+'<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="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>'
|
+'<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>':'')
|
+(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="steps">'+steps+'</div>'
|
||||||
@@ -588,15 +906,59 @@ function upsertTurn(t){
|
|||||||
const fresh = turnEl(t);
|
const fresh = turnEl(t);
|
||||||
if(existing){ existing.replaceWith(fresh); }
|
if(existing){ existing.replaceWith(fresh); }
|
||||||
else { cont.prepend(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){
|
function addEvent(e){
|
||||||
const box = $('events');
|
if(e.id==null){ e.id = 'c'+Date.now()+Math.random(); }
|
||||||
const row = document.createElement('div');
|
logEvents.push(e);
|
||||||
row.className = 'ev ' + (e.level==='error'?'error':'');
|
if(logEvents.length>2000) logEvents = logEvents.slice(-2000);
|
||||||
row.innerHTML = '<span class="et">'+fmtTime(e.wall)+'</span><span>'+esc(e.message)+'</span>';
|
renderLogs();
|
||||||
box.prepend(row);
|
|
||||||
while(box.childElementCount>60) box.removeChild(box.lastChild);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySnapshot(snap){
|
function applySnapshot(snap){
|
||||||
@@ -605,8 +967,8 @@ function applySnapshot(snap){
|
|||||||
const list = (snap.turns||[]);
|
const list = (snap.turns||[]);
|
||||||
for(const t of list) upsertTurn(t);
|
for(const t of list) upsertTurn(t);
|
||||||
if(list.length===0){ $('empty').style.display='block'; }
|
if(list.length===0){ $('empty').style.display='block'; }
|
||||||
$('events').innerHTML='';
|
logEvents = (snap.events||[]).slice();
|
||||||
for(const e of (snap.events||[])) addEvent(e);
|
renderLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
function connect(){
|
function connect(){
|
||||||
@@ -619,6 +981,9 @@ function connect(){
|
|||||||
else if(ev.type==='status') renderStatus(ev.status);
|
else if(ev.type==='status') renderStatus(ev.status);
|
||||||
else if(ev.type==='turn') upsertTurn(ev.turn);
|
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==='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) ----------- #
|
// --- 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();
|
connect();
|
||||||
// Refresh uptime label every second from the last known status.
|
// 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);
|
setInterval(()=>{ if(statusData){ statusData.uptime_s=(statusData.uptime_s||0)+1; $('s-up').textContent=fmtUptime(statusData.uptime_s);} }, 1000);
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class Reply:
|
|||||||
|
|
||||||
text: str
|
text: str
|
||||||
ts: float
|
ts: float
|
||||||
|
usage: dict | None = None # Claude token usage for this reply, when known
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -80,9 +80,13 @@ class Turn:
|
|||||||
self._monitor = monitor
|
self._monitor = monitor
|
||||||
self.id = turn_id
|
self.id = turn_id
|
||||||
self.source = source
|
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.wall = _now_wall()
|
||||||
self._t0 = _now_mono()
|
self._t0 = _now_mono()
|
||||||
self.heard_text = ""
|
self.heard_text = ""
|
||||||
|
self.thought_text = ""
|
||||||
self.reply_text = ""
|
self.reply_text = ""
|
||||||
self.status = "active" # active | ok | error
|
self.status = "active" # active | ok | error
|
||||||
self.error = ""
|
self.error = ""
|
||||||
@@ -95,6 +99,10 @@ class Turn:
|
|||||||
self.heard_text = text
|
self.heard_text = text
|
||||||
self._touch()
|
self._touch()
|
||||||
|
|
||||||
|
def thought(self, text: str) -> None:
|
||||||
|
self.thought_text = text
|
||||||
|
self._touch()
|
||||||
|
|
||||||
def replied(self, text: str) -> None:
|
def replied(self, text: str) -> None:
|
||||||
self.reply_text = text
|
self.reply_text = text
|
||||||
self._touch()
|
self._touch()
|
||||||
@@ -132,8 +140,12 @@ class Turn:
|
|||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"source": self.source,
|
"source": self.source,
|
||||||
|
"speaker": self.speaker,
|
||||||
|
"guild": self.guild,
|
||||||
|
"channel": self.channel,
|
||||||
"wall": self.wall,
|
"wall": self.wall,
|
||||||
"heard": self.heard_text,
|
"heard": self.heard_text,
|
||||||
|
"thought": self.thought_text,
|
||||||
"reply": self.reply_text,
|
"reply": self.reply_text,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"error": self.error,
|
"error": self.error,
|
||||||
@@ -147,7 +159,9 @@ class Monitor:
|
|||||||
|
|
||||||
def __init__(self, keep: int = 60) -> None:
|
def __init__(self, keep: int = 60) -> None:
|
||||||
self._turns: deque[Turn] = deque(maxlen=keep)
|
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] = {
|
self._status: dict[str, Any] = {
|
||||||
"running": False,
|
"running": False,
|
||||||
"listening": False,
|
"listening": False,
|
||||||
@@ -155,10 +169,15 @@ class Monitor:
|
|||||||
"components": {},
|
"components": {},
|
||||||
"turns_total": 0,
|
"turns_total": 0,
|
||||||
"errors_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._lock = threading.Lock()
|
||||||
self._subs: list["queue.Queue[str]"] = []
|
self._subs: list["queue.Queue[str]"] = []
|
||||||
self._id = 0
|
self._id = 0
|
||||||
|
self._event_id = 0
|
||||||
|
|
||||||
# -- status ----------------------------------------------------------- #
|
# -- status ----------------------------------------------------------- #
|
||||||
def set_status(self, **kw: Any) -> None:
|
def set_status(self, **kw: Any) -> None:
|
||||||
@@ -175,17 +194,65 @@ class Monitor:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
s = dict(self._status)
|
s = dict(self._status)
|
||||||
s["uptime_s"] = round(_now_wall() - s["started_wall"], 1)
|
s["uptime_s"] = round(_now_wall() - s["started_wall"], 1)
|
||||||
|
from . import usage_store
|
||||||
|
s["claude_usage"] = usage_store.summary() # 오늘 / 최근 7일 토큰
|
||||||
return s
|
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:
|
def log(self, level: str, message: str) -> None:
|
||||||
"""A free-form lifecycle/error line (startup, disconnect, crash…)."""
|
"""A free-form lifecycle/error line (startup, disconnect, crash…)."""
|
||||||
evt = {"type": "log", "level": level, "message": message, "wall": _now_wall()}
|
|
||||||
with self._lock:
|
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)
|
self._events.append(evt)
|
||||||
if level == "error":
|
if level == "error":
|
||||||
self._status["errors_total"] += 1
|
self._status["errors_total"] += 1
|
||||||
self._broadcast(evt)
|
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 ------------------------------------------------------------ #
|
# -- turns ------------------------------------------------------------ #
|
||||||
def turn(self, source: str = "voice") -> Turn:
|
def turn(self, source: str = "voice") -> Turn:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
54
wsai/prompt_store.py
Normal file
54
wsai/prompt_store.py
Normal 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
79
wsai/usage_store.py
Normal 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"]},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user