Add optional AI reply via DeepSeek
Some checks failed
CI/CD Pipeline / build-and-deploy (push) Failing after 3s
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:
parent
03a3a4c4af
commit
2799dfdf42
6 changed files with 108 additions and 5 deletions
10
.env.example
10
.env.example
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
5
ai-prompt.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
Ты дружелюбный ИИ-ассистент в чате стрима. Тебе присылают сообщение зрителя,
|
||||||
|
адресованное стримеру. Отвечай коротко (1-3 предложения), живо и по делу —
|
||||||
|
твой ответ будет прочитан вслух на стриме и показан на экране, поэтому не
|
||||||
|
пиши длинных текстов, списков или markdown-разметки.
|
||||||
|
При этом старайся шутить.
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
73
server.js
73
server.js
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue