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
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:
parent
fec77e2737
commit
ec6d8c4cfe
6 changed files with 106 additions and 22 deletions
9
db.js
9
db.js
|
|
@ -19,6 +19,7 @@ db.exec(`
|
||||||
sender_token TEXT UNIQUE NOT NULL,
|
sender_token TEXT UNIQUE NOT NULL,
|
||||||
tts_voice TEXT NOT NULL DEFAULT 'ru-RU-DmitryNeural',
|
tts_voice TEXT NOT NULL DEFAULT 'ru-RU-DmitryNeural',
|
||||||
display_duration_seconds INTEGER NOT NULL DEFAULT 10,
|
display_duration_seconds INTEGER NOT NULL DEFAULT 10,
|
||||||
|
ai_name TEXT NOT NULL DEFAULT 'Альтушка Ирина',
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -36,6 +37,9 @@ const existingColumns = db.prepare('PRAGMA table_info(streamers)').all().map((c)
|
||||||
if (!existingColumns.includes('display_duration_seconds')) {
|
if (!existingColumns.includes('display_duration_seconds')) {
|
||||||
db.exec('ALTER TABLE streamers ADD COLUMN display_duration_seconds INTEGER NOT NULL DEFAULT 10');
|
db.exec('ALTER TABLE streamers ADD COLUMN display_duration_seconds INTEGER NOT NULL DEFAULT 10');
|
||||||
}
|
}
|
||||||
|
if (!existingColumns.includes('ai_name')) {
|
||||||
|
db.exec("ALTER TABLE streamers ADD COLUMN ai_name TEXT NOT NULL DEFAULT 'Альтушка Ирина'");
|
||||||
|
}
|
||||||
|
|
||||||
function generateToken() {
|
function generateToken() {
|
||||||
return crypto.randomBytes(16).toString('hex');
|
return crypto.randomBytes(16).toString('hex');
|
||||||
|
|
@ -86,6 +90,10 @@ function updateDisplayDuration(streamerId, seconds) {
|
||||||
db.prepare('UPDATE streamers SET display_duration_seconds = ? WHERE id = ?').run(seconds, streamerId);
|
db.prepare('UPDATE streamers SET display_duration_seconds = ? WHERE id = ?').run(seconds, streamerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateAiName(streamerId, aiName) {
|
||||||
|
db.prepare('UPDATE streamers SET ai_name = ? WHERE id = ?').run(aiName, streamerId);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
findStreamerByOverlayToken,
|
findStreamerByOverlayToken,
|
||||||
findStreamerBySenderToken,
|
findStreamerBySenderToken,
|
||||||
|
|
@ -93,4 +101,5 @@ module.exports = {
|
||||||
getViewerName,
|
getViewerName,
|
||||||
setViewerName,
|
setViewerName,
|
||||||
updateDisplayDuration,
|
updateDisplayDuration,
|
||||||
|
updateAiName,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,9 @@
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
.settings-row input#ai-name {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
.settings-row button {
|
.settings-row button {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -101,10 +104,21 @@
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<input id="display-duration" type="number" min="1" max="120" />
|
<input id="display-duration" type="number" min="1" max="120" />
|
||||||
<span>сек.</span>
|
<span>сек.</span>
|
||||||
<button id="save-duration">Сохранить</button>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Имя ИИ-помощника</h2>
|
||||||
|
<p>Как зовут вашего ИИ-помощника, который отвечает зрителям (по умолчанию «Альтушка Ирина»).</p>
|
||||||
|
<div class="settings-row">
|
||||||
|
<input id="ai-name" type="text" maxlength="60" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<button id="save-settings">Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="settings-status"></div>
|
<div id="settings-status"></div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<p id="logout">выйти</p>
|
<p id="logout">выйти</p>
|
||||||
|
|
||||||
|
|
@ -126,15 +140,17 @@
|
||||||
document.getElementById('overlay-url').value = data.overlayUrl;
|
document.getElementById('overlay-url').value = data.overlayUrl;
|
||||||
document.getElementById('sender-url').value = data.senderUrl;
|
document.getElementById('sender-url').value = data.senderUrl;
|
||||||
document.getElementById('display-duration').value = data.displayDurationSeconds;
|
document.getElementById('display-duration').value = data.displayDurationSeconds;
|
||||||
|
document.getElementById('ai-name').value = data.aiName;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('save-duration').addEventListener('click', async () => {
|
document.getElementById('save-settings').addEventListener('click', async () => {
|
||||||
const statusEl = document.getElementById('settings-status');
|
const statusEl = document.getElementById('settings-status');
|
||||||
const seconds = Number(document.getElementById('display-duration').value);
|
const seconds = Number(document.getElementById('display-duration').value);
|
||||||
|
const aiName = document.getElementById('ai-name').value;
|
||||||
const res = await fetch('/api/settings', {
|
const res = await fetch('/api/settings', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ displayDurationSeconds: seconds }),
|
body: JSON.stringify({ displayDurationSeconds: seconds, aiName }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
statusEl.textContent = 'Сохранено';
|
statusEl.textContent = 'Сохранено';
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,15 @@ function playNotificationSound() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function playSpeech(base64Audio, mimeType) {
|
function playSpeech(base64Audio, mimeType, onEnded) {
|
||||||
const audio = new Audio(`data:${mimeType};base64,${base64Audio}`);
|
const audio = new Audio(`data:${mimeType};base64,${base64Audio}`);
|
||||||
audio.play().catch((err) => console.error('Could not play TTS audio:', err));
|
const done = () => { if (onEnded) onEnded(); };
|
||||||
|
audio.addEventListener('ended', done);
|
||||||
|
audio.addEventListener('error', done);
|
||||||
|
audio.play().catch((err) => {
|
||||||
|
console.error('Could not play TTS audio:', err);
|
||||||
|
done();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function advanceQueue() {
|
function advanceQueue() {
|
||||||
|
|
@ -62,14 +68,37 @@ function advanceQueue() {
|
||||||
box.classList.add('visible');
|
box.classList.add('visible');
|
||||||
|
|
||||||
playNotificationSound();
|
playNotificationSound();
|
||||||
if (current.audio) {
|
|
||||||
playSpeech(current.audio.data, current.audio.mimeType);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
// The message stays up for at least durationMs (streamer's configured
|
||||||
|
// display time), but if its TTS audio is still playing when that timer
|
||||||
|
// fires (e.g. a long question read aloud), we must not start the next
|
||||||
|
// message's audio on top of it — so we also wait for the audio to
|
||||||
|
// actually finish before advancing.
|
||||||
|
let minTimeElapsed = false;
|
||||||
|
let audioFinished = !current.audio;
|
||||||
|
|
||||||
|
const maybeAdvance = () => {
|
||||||
|
if (!minTimeElapsed || !audioFinished) return;
|
||||||
box.classList.remove('visible');
|
box.classList.remove('visible');
|
||||||
current = null;
|
current = null;
|
||||||
setTimeout(advanceQueue, FADE_MS);
|
setTimeout(advanceQueue, FADE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
current.startAudio = (audio) => {
|
||||||
|
audioFinished = false;
|
||||||
|
playSpeech(audio.data, audio.mimeType, () => {
|
||||||
|
audioFinished = true;
|
||||||
|
maybeAdvance();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (current.audio) {
|
||||||
|
current.startAudio(current.audio);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
minTimeElapsed = true;
|
||||||
|
maybeAdvance();
|
||||||
}, current.durationMs || 10000);
|
}, current.durationMs || 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,7 +116,7 @@ socket.on('display_message', (payload) => {
|
||||||
socket.on('display_audio', (payload) => {
|
socket.on('display_audio', (payload) => {
|
||||||
const audio = { data: payload.audio, mimeType: payload.mimeType };
|
const audio = { data: payload.audio, mimeType: payload.mimeType };
|
||||||
if (current && current.id === payload.id) {
|
if (current && current.id === payload.id) {
|
||||||
playSpeech(audio.data, audio.mimeType);
|
current.startAudio(audio);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const queued = queue.find((item) => item.id === payload.id);
|
const queued = queue.find((item) => item.id === payload.id);
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@
|
||||||
|
|
||||||
<div class="checkbox-row">
|
<div class="checkbox-row">
|
||||||
<input id="ai-reply" type="checkbox" />
|
<input id="ai-reply" type="checkbox" />
|
||||||
<label for="ai-reply">Задать вопрос Альтушке Ирине 🤖</label>
|
<label for="ai-reply" id="ai-reply-label">Задать вопрос Альтушке Ирине 🤖</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="send">Отправить</button>
|
<button id="send">Отправить</button>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ 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 aiReplyCheckbox = document.getElementById('ai-reply');
|
||||||
|
const aiReplyLabel = document.getElementById('ai-reply-label');
|
||||||
const sendBtn = document.getElementById('send');
|
const sendBtn = document.getElementById('send');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
|
|
||||||
|
|
@ -51,7 +52,16 @@ async function handleCredentialResponse(response) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAiName() {
|
||||||
|
const res = await fetch(`/api/streamer-info?token=${encodeURIComponent(token)}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const { aiName } = await res.json();
|
||||||
|
if (aiName) aiReplyLabel.textContent = `Задать вопрос: ${aiName} 🤖`;
|
||||||
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
|
loadAiName();
|
||||||
|
|
||||||
const configRes = await fetch('/config');
|
const configRes = await fetch('/config');
|
||||||
const { googleClientId } = await configRes.json();
|
const { googleClientId } = await configRes.json();
|
||||||
|
|
||||||
|
|
|
||||||
40
server.js
40
server.js
|
|
@ -16,6 +16,7 @@ const {
|
||||||
getViewerName,
|
getViewerName,
|
||||||
setViewerName,
|
setViewerName,
|
||||||
updateDisplayDuration,
|
updateDisplayDuration,
|
||||||
|
updateAiName,
|
||||||
} = require('./db');
|
} = require('./db');
|
||||||
const { censorText } = require('./profanity');
|
const { censorText } = require('./profanity');
|
||||||
|
|
||||||
|
|
@ -125,6 +126,7 @@ app.get('/api/dashboard', requireAuth, (req, res) => {
|
||||||
senderUrl: `${origin}/s/${streamer.sender_token}`,
|
senderUrl: `${origin}/s/${streamer.sender_token}`,
|
||||||
ttsVoice: streamer.tts_voice,
|
ttsVoice: streamer.tts_voice,
|
||||||
displayDurationSeconds: streamer.display_duration_seconds,
|
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) {
|
if (!Number.isInteger(seconds) || seconds < 1 || seconds > 120) {
|
||||||
return res.status(400).json({ error: 'displayDurationSeconds must be an integer between 1 and 120' });
|
return res.status(400).json({ error: 'displayDurationSeconds must be an integer between 1 and 120' });
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDisplayDuration(streamer.id, seconds);
|
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 });
|
res.json({ displayDurationSeconds: seconds });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -156,6 +166,14 @@ app.get('/api/viewer-name', requireAuth, (req, res) => {
|
||||||
res.json({ name });
|
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) => {
|
app.get('/overlay/:token', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, 'public', 'overlay.html'));
|
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 {
|
try {
|
||||||
const content = fs.readFileSync(AI_PROMPT_PATH, 'utf8').trim();
|
basePrompt = fs.readFileSync(AI_PROMPT_PATH, 'utf8').trim() || FALLBACK_AI_PROMPT;
|
||||||
return content || FALLBACK_AI_PROMPT;
|
|
||||||
} catch {
|
} 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', {
|
const res = await fetch('https://api.deepseek.com/chat/completions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -218,7 +237,7 @@ async function getAiReply(text) {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'deepseek-chat',
|
model: 'deepseek-chat',
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: getAiSystemPrompt() },
|
{ role: 'system', content: getAiSystemPrompt(aiName) },
|
||||||
{ role: 'user', content: text },
|
{ role: 'user', content: text },
|
||||||
],
|
],
|
||||||
max_tokens: 150,
|
max_tokens: 150,
|
||||||
|
|
@ -295,18 +314,19 @@ io.on('connection', (socket) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (aiReply && DEEPSEEK_API_KEY) {
|
if (aiReply && DEEPSEEK_API_KEY) {
|
||||||
|
const aiName = streamer.ai_name || 'Альтушка Ирина';
|
||||||
// Wait for the AI reply text before showing anything, so the viewer's
|
// 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
|
// message and the AI reply land in the overlay queue back-to-back
|
||||||
// instead of the reply trailing in later with an awkward gap.
|
// instead of the reply trailing in later with an awkward gap.
|
||||||
getAiReply(message)
|
getAiReply(message, aiName)
|
||||||
.then((replyText) => {
|
.then((replyText) => {
|
||||||
broadcastOriginal();
|
broadcastOriginal();
|
||||||
|
|
||||||
const aiMessageId = crypto.randomUUID();
|
const aiMessageId = crypto.randomUUID();
|
||||||
console.log(`[ai] streamer=${streamer.id} id=${aiMessageId} reply="${replyText}"`);
|
console.log(`[ai] streamer=${streamer.id} id=${aiMessageId} reply="${replyText}"`);
|
||||||
broadcastMessage(
|
broadcastMessage(
|
||||||
room, aiMessageId, replyText, 'Альтушка Ирина 🤖', durationMs,
|
room, aiMessageId, replyText, `${aiName} 🤖`, durationMs,
|
||||||
`Альтушка Ирина отвечает. ${replyText}`,
|
`${aiName} отвечает. ${replyText}`,
|
||||||
AI_VOICE
|
AI_VOICE
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue