Add optional AI reply via DeepSeek
Some checks failed
CI/CD Pipeline / build-and-deploy (push) Failing after 3s

Viewers can check a box on the send page to get a short AI-generated
reply to their message, voiced with a distinct TTS voice and queued
on the overlay after the original message. The system prompt lives
in ai-prompt.txt (editable without touching code) instead of being
hardcoded. TTS for both viewer messages and AI replies now announces
who it's from ("Сообщение от X." / "Ответ от ИИ.") before the text.
No-ops entirely if DEEPSEEK_API_KEY isn't set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-07-01 17:23:41 +00:00
parent 03a3a4c4af
commit 2799dfdf42
6 changed files with 108 additions and 5 deletions

View file

@ -15,3 +15,13 @@ SESSION_SECRET=placeholder-session-secret-change-me
# same engine as t2sTelegramBot). Used for newly created streamers; each # same engine as t2sTelegramBot). Used for newly created streamers; each
# streamer's own voice is stored in the database and can be changed later. # streamer's own voice is stored in the database and can be changed later.
TTS_VOICE=ru-RU-DmitryNeural TTS_VOICE=ru-RU-DmitryNeural
# Optional: DeepSeek API key (https://platform.deepseek.com) for the "AI reply"
# checkbox on the viewer send page. If unset, the feature silently no-ops even
# if a viewer checks the box. The system prompt used is in ai-prompt.txt.
DEEPSEEK_API_KEY=
# Voice used for AI replies specifically, deliberately different from a
# streamer's own TTS_VOICE so listeners can tell an AI reply apart from a
# human viewer's message.
AI_VOICE=ru-RU-SvetlanaNeural

View file

@ -8,7 +8,7 @@ WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
COPY server.js db.js ./ COPY server.js db.js ai-prompt.txt ./
COPY public ./public COPY public ./public
EXPOSE 3000 EXPOSE 3000

5
ai-prompt.txt Normal file
View file

@ -0,0 +1,5 @@
Ты дружелюбный ИИ-ассистент в чате стрима. Тебе присылают сообщение зрителя,
адресованное стримеру. Отвечай коротко (1-3 предложения), живо и по делу —
твой ответ будет прочитан вслух на стриме и показан на экране, поэтому не
пиши длинных текстов, списков или markdown-разметки.
При этом старайся шутить.

View file

@ -71,6 +71,18 @@
color: #888; color: #888;
min-height: 20px; min-height: 20px;
} }
.checkbox-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
font-size: 14px;
}
.checkbox-row label {
display: inline;
margin-bottom: 0;
color: inherit;
}
#hidden-until-login { #hidden-until-login {
display: none; display: none;
} }
@ -94,7 +106,12 @@
<input id="display-name" type="text" maxlength="60" placeholder="Имя..." /> <input id="display-name" type="text" maxlength="60" placeholder="Имя..." />
<textarea id="message" placeholder="Текст для стрима..." maxlength="500"></textarea> <textarea id="message" placeholder="Текст для стрима..." maxlength="500"></textarea>
<br />
<div class="checkbox-row">
<input id="ai-reply" type="checkbox" />
<label for="ai-reply">Получить ответ от ИИ 🤖</label>
</div>
<button id="send">Отправить</button> <button id="send">Отправить</button>
<div id="status"></div> <div id="status"></div>
</div> </div>

View file

@ -5,6 +5,7 @@ const userName = document.getElementById('user-name');
const logoutBtn = document.getElementById('logout'); const logoutBtn = document.getElementById('logout');
const displayNameInput = document.getElementById('display-name'); const displayNameInput = document.getElementById('display-name');
const messageInput = document.getElementById('message'); const messageInput = document.getElementById('message');
const aiReplyCheckbox = document.getElementById('ai-reply');
const sendBtn = document.getElementById('send'); const sendBtn = document.getElementById('send');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
@ -74,7 +75,8 @@ sendBtn.addEventListener('click', () => {
const text = messageInput.value.trim(); const text = messageInput.value.trim();
if (!text) return; if (!text) return;
const name = displayNameInput.value.trim(); const name = displayNameInput.value.trim();
socket.emit('send_message', { token, text, name }); const aiReply = aiReplyCheckbox.checked;
socket.emit('send_message', { token, text, name, aiReply });
messageInput.value = ''; messageInput.value = '';
statusEl.textContent = 'Отправлено'; statusEl.textContent = 'Отправлено';
setTimeout(() => { statusEl.textContent = ''; }, 1500); setTimeout(() => { statusEl.textContent = ''; }, 1500);

View file

@ -1,5 +1,6 @@
require('dotenv').config(); require('dotenv').config();
const fs = require('fs');
const path = require('path'); const path = require('path');
const express = require('express'); const express = require('express');
const session = require('express-session'); const session = require('express-session');
@ -21,6 +22,10 @@ const PORT = process.env.PORT || 3000;
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const SESSION_SECRET = process.env.SESSION_SECRET; const SESSION_SECRET = process.env.SESSION_SECRET;
const DEFAULT_TTS_VOICE = process.env.TTS_VOICE || 'ru-RU-DmitryNeural'; const DEFAULT_TTS_VOICE = process.env.TTS_VOICE || 'ru-RU-DmitryNeural';
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY;
const AI_VOICE = process.env.AI_VOICE || 'ru-RU-SvetlanaNeural';
const AI_PROMPT_PATH = path.join(__dirname, 'ai-prompt.txt');
const FALLBACK_AI_PROMPT = 'Ты дружелюбный ИИ-ассистент в чате стрима. Отвечай коротко и по делу.';
if (!GOOGLE_CLIENT_ID) { if (!GOOGLE_CLIENT_ID) {
console.error('Missing GOOGLE_CLIENT_ID in .env (see .env.example)'); console.error('Missing GOOGLE_CLIENT_ID in .env (see .env.example)');
@ -177,6 +182,40 @@ function synthesizeSpeech(text, voice) {
}); });
} }
function getAiSystemPrompt() {
try {
const content = fs.readFileSync(AI_PROMPT_PATH, 'utf8').trim();
return content || FALLBACK_AI_PROMPT;
} catch {
return FALLBACK_AI_PROMPT;
}
}
async function getAiReply(text) {
const res = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${DEEPSEEK_API_KEY}`,
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: [
{ role: 'system', content: getAiSystemPrompt() },
{ role: 'user', content: text },
],
max_tokens: 150,
temperature: 0.8,
}),
});
if (!res.ok) {
throw new Error(`DeepSeek API error: ${res.status} ${await res.text()}`);
}
const data = await res.json();
return data.choices[0].message.content.trim();
}
io.on('connection', (socket) => { io.on('connection', (socket) => {
const { role, token } = socket.handshake.query; const { role, token } = socket.handshake.query;
const connectedUser = socket.request.session.user; const connectedUser = socket.request.session.user;
@ -196,7 +235,7 @@ io.on('connection', (socket) => {
console.log(`[socket] disconnected id=${socket.id} reason=${reason}`); console.log(`[socket] disconnected id=${socket.id} reason=${reason}`);
}); });
socket.on('send_message', ({ token: senderToken, text, name } = {}) => { socket.on('send_message', ({ token: senderToken, text, name, aiReply } = {}) => {
const streamer = findStreamerBySenderToken(senderToken); const streamer = findStreamerBySenderToken(senderToken);
if (!streamer) { if (!streamer) {
socket.emit('send_error', 'Unknown link'); socket.emit('send_error', 'Unknown link');
@ -238,7 +277,7 @@ io.on('connection', (socket) => {
// instead of waiting on the round trip to the TTS service. The overlay // instead of waiting on the round trip to the TTS service. The overlay
// queues messages and matches this to the right one by id, since it may // queues messages and matches this to the right one by id, since it may
// arrive before that message's turn to display. // arrive before that message's turn to display.
synthesizeSpeech(message, streamer.tts_voice || DEFAULT_TTS_VOICE) synthesizeSpeech(`Сообщение от ${displayName}. ${message}`, streamer.tts_voice || DEFAULT_TTS_VOICE)
.then((audioBuffer) => { .then((audioBuffer) => {
io.to(room).emit('display_audio', { io.to(room).emit('display_audio', {
id: messageId, id: messageId,
@ -249,6 +288,36 @@ io.on('connection', (socket) => {
.catch((err) => { .catch((err) => {
console.error('[tts] synthesis failed:', err); console.error('[tts] synthesis failed:', err);
}); });
if (aiReply && DEEPSEEK_API_KEY) {
getAiReply(message)
.then((replyText) => {
const aiMessageId = crypto.randomUUID();
console.log(`[ai] streamer=${streamer.id} id=${aiMessageId} reply="${replyText}"`);
io.to(room).emit('display_message', {
id: aiMessageId,
text: replyText,
from: '🤖 ИИ',
at: Date.now(),
durationMs: (streamer.display_duration_seconds || 10) * 1000,
});
synthesizeSpeech(`Ответ от ИИ. ${replyText}`, AI_VOICE)
.then((audioBuffer) => {
io.to(room).emit('display_audio', {
id: aiMessageId,
audio: audioBuffer.toString('base64'),
mimeType: 'audio/mpeg',
});
})
.catch((err) => {
console.error('[tts] AI reply synthesis failed:', err);
});
})
.catch((err) => {
console.error('[ai] DeepSeek reply failed:', err);
});
}
}); });
}); });
}); });