Let streamers name their AI assistant; fix overlay TTS audio overlap
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 30s

Streamer dashboard gains an editable AI assistant name (default
"Альтушка Ирина"), stored per-streamer and used for the overlay label,
TTS intro, and DeepSeek system prompt. Also fixes a bug where a long
message's TTS audio could still be playing when the next queued
message started its own audio — the overlay queue now waits for the
current audio to actually finish (not just the configured display
duration) before advancing.
This commit is contained in:
vrubelroman 2026-07-08 17:28:36 +00:00
parent fec77e2737
commit ec6d8c4cfe
6 changed files with 106 additions and 22 deletions

View file

@ -16,6 +16,7 @@ const {
getViewerName,
setViewerName,
updateDisplayDuration,
updateAiName,
} = require('./db');
const { censorText } = require('./profanity');
@ -125,6 +126,7 @@ app.get('/api/dashboard', requireAuth, (req, res) => {
senderUrl: `${origin}/s/${streamer.sender_token}`,
ttsVoice: streamer.tts_voice,
displayDurationSeconds: streamer.display_duration_seconds,
aiName: streamer.ai_name,
});
});
@ -141,8 +143,16 @@ app.post('/api/settings', requireAuth, (req, res) => {
if (!Number.isInteger(seconds) || seconds < 1 || seconds > 120) {
return res.status(400).json({ error: 'displayDurationSeconds must be an integer between 1 and 120' });
}
updateDisplayDuration(streamer.id, seconds);
if (typeof req.body.aiName === 'string') {
const aiName = req.body.aiName.trim().slice(0, 60);
if (!aiName) {
return res.status(400).json({ error: 'aiName must not be empty' });
}
updateAiName(streamer.id, aiName);
}
res.json({ displayDurationSeconds: seconds });
});
@ -156,6 +166,14 @@ app.get('/api/viewer-name', requireAuth, (req, res) => {
res.json({ name });
});
app.get('/api/streamer-info', (req, res) => {
const streamer = findStreamerBySenderToken(req.query.token);
if (!streamer) {
return res.status(404).json({ error: 'unknown link' });
}
res.json({ aiName: streamer.ai_name });
});
app.get('/overlay/:token', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'overlay.html'));
});
@ -199,16 +217,17 @@ function broadcastMessage(room, id, text, from, durationMs, ttsText, voice) {
});
}
function getAiSystemPrompt() {
function getAiSystemPrompt(aiName) {
let basePrompt;
try {
const content = fs.readFileSync(AI_PROMPT_PATH, 'utf8').trim();
return content || FALLBACK_AI_PROMPT;
basePrompt = fs.readFileSync(AI_PROMPT_PATH, 'utf8').trim() || FALLBACK_AI_PROMPT;
} catch {
return FALLBACK_AI_PROMPT;
basePrompt = FALLBACK_AI_PROMPT;
}
return `Тебя зовут ${aiName}. ${basePrompt}`;
}
async function getAiReply(text) {
async function getAiReply(text, aiName) {
const res = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: {
@ -218,7 +237,7 @@ async function getAiReply(text) {
body: JSON.stringify({
model: 'deepseek-chat',
messages: [
{ role: 'system', content: getAiSystemPrompt() },
{ role: 'system', content: getAiSystemPrompt(aiName) },
{ role: 'user', content: text },
],
max_tokens: 150,
@ -295,18 +314,19 @@ io.on('connection', (socket) => {
};
if (aiReply && DEEPSEEK_API_KEY) {
const aiName = streamer.ai_name || 'Альтушка Ирина';
// Wait for the AI reply text before showing anything, so the viewer's
// message and the AI reply land in the overlay queue back-to-back
// instead of the reply trailing in later with an awkward gap.
getAiReply(message)
getAiReply(message, aiName)
.then((replyText) => {
broadcastOriginal();
const aiMessageId = crypto.randomUUID();
console.log(`[ai] streamer=${streamer.id} id=${aiMessageId} reply="${replyText}"`);
broadcastMessage(
room, aiMessageId, replyText, 'Альтушка Ирина 🤖', durationMs,
`Альтушка Ирина отвечает. ${replyText}`,
room, aiMessageId, replyText, `${aiName} 🤖`, durationMs,
`${aiName} отвечает. ${replyText}`,
AI_VOICE
);
})