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

@ -1,5 +1,6 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const express = require('express');
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 SESSION_SECRET = process.env.SESSION_SECRET;
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) {
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) => {
const { role, token } = socket.handshake.query;
const connectedUser = socket.request.session.user;
@ -196,7 +235,7 @@ io.on('connection', (socket) => {
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);
if (!streamer) {
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
// queues messages and matches this to the right one by id, since it may
// 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) => {
io.to(room).emit('display_audio', {
id: messageId,
@ -249,6 +288,36 @@ io.on('connection', (socket) => {
.catch((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);
});
}
});
});
});