diff --git a/.env.example b/.env.example index c7774d1..2f38a74 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,13 @@ SESSION_SECRET=placeholder-session-secret-change-me # same engine as t2sTelegramBot). Used for newly created streamers; each # streamer's own voice is stored in the database and can be changed later. 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 diff --git a/Dockerfile b/Dockerfile index 583a3e1..14bc2cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ WORKDIR /app COPY package.json package-lock.json* ./ RUN npm ci --omit=dev -COPY server.js db.js ./ +COPY server.js db.js ai-prompt.txt ./ COPY public ./public EXPOSE 3000 diff --git a/ai-prompt.txt b/ai-prompt.txt new file mode 100644 index 0000000..670d42f --- /dev/null +++ b/ai-prompt.txt @@ -0,0 +1,5 @@ +Ты дружелюбный ИИ-ассистент в чате стрима. Тебе присылают сообщение зрителя, +адресованное стримеру. Отвечай коротко (1-3 предложения), живо и по делу — +твой ответ будет прочитан вслух на стриме и показан на экране, поэтому не +пиши длинных текстов, списков или markdown-разметки. +При этом старайся шутить. diff --git a/public/send.html b/public/send.html index 6160adf..fd8d32a 100644 --- a/public/send.html +++ b/public/send.html @@ -71,6 +71,18 @@ color: #888; 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 { display: none; } @@ -94,7 +106,12 @@ -
+ +
+ + +
+
diff --git a/public/send.js b/public/send.js index dfca437..7916ab0 100644 --- a/public/send.js +++ b/public/send.js @@ -5,6 +5,7 @@ const userName = document.getElementById('user-name'); const logoutBtn = document.getElementById('logout'); const displayNameInput = document.getElementById('display-name'); const messageInput = document.getElementById('message'); +const aiReplyCheckbox = document.getElementById('ai-reply'); const sendBtn = document.getElementById('send'); const statusEl = document.getElementById('status'); @@ -74,7 +75,8 @@ sendBtn.addEventListener('click', () => { const text = messageInput.value.trim(); if (!text) return; 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 = ''; statusEl.textContent = 'Отправлено'; setTimeout(() => { statusEl.textContent = ''; }, 1500); diff --git a/server.js b/server.js index ed19d9c..f62837e 100644 --- a/server.js +++ b/server.js @@ -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); + }); + } }); }); });