Files

194 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import WebSocket from 'ws';
import { tools, validateTool } from './tools.mjs';
const env = process.env;
const sttKeyterms = (env.ELEVEN_STT_KEYTERMS || '').split(',').map(term => term.trim()).filter(Boolean).slice(0, 50);
const sttUrl = env.ELEVEN_STT_URL || 'wss://api.elevenlabs.io/v1/speech-to-text/realtime';
const ttsUrl = env.ELEVEN_TTS_URL || 'wss://api.elevenlabs.io/v1/text-to-dialogue/stream-input';
const flashUrl = env.ELEVEN_FLASH_URL || 'wss://api.elevenlabs.io/v1/text-to-speech/{voice_id}/stream-input';
const llmUrl = env.OPENROUTER_URL || 'https://openrouter.ai/api/v1/chat/completions';
function endpoint(base, params) {
const url = new URL(base);
for (const [key, value] of Object.entries(params)) {
for (const item of Array.isArray(value) ? value : [value]) url.searchParams.append(key, item);
}
return url.toString();
}
function connect(url, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) return reject(new Error('aborted'));
const ws = new WebSocket(url, { headers: { 'xi-api-key': env.ELEVENLABS_API_KEY }, handshakeTimeout: 10000, maxPayload: 2 * 1024 * 1024 });
const abort = () => ws.terminate();
signal.addEventListener('abort', abort, { once: true });
ws.once('close', () => signal.removeEventListener('abort', abort));
ws.once('open', () => resolve(ws));
ws.once('error', reject);
});
}
export async function openStt(settings, signal, onPartial, onFinal, onFailure) {
const ws = await connect(endpoint(sttUrl, { model_id: settings.sttModel, audio_format: 'pcm_16000', language_code: 'ru', commit_strategy: 'manual', filter_background_audio: 'true', keyterms: sttKeyterms }), signal);
let final = false;
ws.on('message', data => {
try {
const msg = JSON.parse(data.toString());
if (msg.message_type === 'partial_transcript') onPartial(msg.text || '');
else if (msg.message_type === 'committed_transcript') { final = true; onFinal(msg.text || ''); }
else if (msg.error || /(?:error|rate_limited|auth_error|quota_exceeded)/.test(msg.message_type || '')) onFailure();
} catch { onFailure(); }
});
ws.on('error', onFailure);
ws.on('close', () => { if (!final && !signal.aborted) onFailure(); });
return ws;
}
export async function openTts(settings, signal, onAudio) {
const flash = settings.ttsModel === 'eleven_flash_v2_5';
const url = flash ? flashUrl.replace('{voice_id}', encodeURIComponent(settings.voiceId)) : ttsUrl;
const ws = await connect(endpoint(url, { model_id: settings.ttsModel, output_format: 'pcm_24000', ...(flash ? { auto_mode: 'true' } : { language_code: 'ru' }) }), signal);
let settled = false;
let resolveDone, rejectDone;
const done = new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; });
// The caller may still be generating text when the provider fails.
done.catch(() => {});
ws.on('message', data => {
try {
const msg = JSON.parse(data.toString());
if (msg.error || msg.type === 'error') throw new Error('tts_error');
if (msg.audio) onAudio(Buffer.from(msg.audio, 'base64'));
if (msg.is_final || msg.isFinal) { settled = true; resolveDone(); }
} catch { settled = true; rejectDone(new Error('tts_error')); }
});
ws.on('error', () => { if (!settled) { settled = true; rejectDone(new Error('tts_error')); } });
ws.on('close', () => { if (!settled) { settled = true; rejectDone(new Error('tts_closed')); } });
ws.send(JSON.stringify(flash ? { text: ' ' } : { voices: [settings.voiceId] }));
const keepalive = setInterval(() => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(flash ? { text: ' ' } : { keep_alive: true })); }, 10000);
ws.once('close', () => clearInterval(keepalive));
return { ws, done, text(text) { if (ws.readyState !== WebSocket.OPEN) throw new Error('tts_closed'); ws.send(JSON.stringify(flash ? { text } : { inputs: [{ text, voice_id: settings.voiceId }] })); }, finish() { if (ws.readyState !== WebSocket.OPEN) throw new Error('tts_closed'); ws.send(JSON.stringify(flash ? { text: '' } : { close_socket: true })); } };
}
const sentenceSegments = new Intl.Segmenter('ru', { granularity: 'sentence' });
const deliveryTags = ['excited', 'curious', 'warmly', 'sympathetic', 'mischievously'];
const deliveryPrompt = `Обычные вопросы, команды и подтверждения озвучивай нейтрально. Если контекст явно эмоциональный, можешь начать окончательный ответ одним тегом подачи: ${deliveryTags.map(tag => `[${tag}]`).join(', ')}. Подбирай его по смыслу и настроению реплики. Используй сдержанную интонацию, не ставь теги внутри ответа, не добавляй звуковые эффекты и не комментируй выбор подачи.`;
function spokenReply(content, detailed, expressive) {
const lead = /^\s*\[([a-z]+)\](?=\s|$)/iu.exec(content);
const tag = lead?.[1].toLowerCase();
const clean = content.replace(/\[[a-z][a-z ,'-]{0,63}\]|\[[a-z][a-z ,'-]{0,63}$/giu, '').replace(/\s+/gu, ' ').trim();
const answer = detailed ? clean : shortReply(clean);
const audio = answer && expressive && deliveryTags.includes(tag) ? `[${tag}] ${answer}` : answer;
return { answer, audio };
}
function shortReply(text) {
const compact = Array.from(sentenceSegments.segment(text.trim()), part => part.segment).slice(0, 2).join('').trim();
if (compact.length <= 300) return compact;
const end = compact.lastIndexOf(' ', 297);
return compact.slice(0, end > 170 ? end : 297).trimEnd() + '…';
}
export async function streamAnswer(settings, history, userText, signal, onText, executeDeviceTool) {
if (env.LLM_MODE === 'echo') { onText(userText); return userText; }
const question = userText.toLowerCase().replace(/[?!.…]+$/u, '').trim();
if (/^(?:какая (?:это|у тебя) модель|на какой модели ты работаешь|какая у тебя языковая модель)$/u.test(question)) {
const model = settings.llmModel === 'anthropic/claude-haiku-5.5' ? 'Claude Haiku 5.5' : settings.llmModel;
const answer = `Я работаю на ${model}.`;
onText(answer);
return answer;
}
if (!env.OPENROUTER_API_KEY) throw new Error('llm_config');
const expressive = settings.ttsModel === 'eleven_v4_turbo' || settings.ttsModel === 'eleven_v3_conversational';
const moscowDate = new Intl.DateTimeFormat('ru-RU', { timeZone: 'Europe/Moscow', dateStyle: 'long' }).format(new Date());
const systemPrompt = `${settings.systemPrompt}\nСегодня ${moscowDate} (Москва). Используй веб-поиск для актуальных сведений и когда требуется проверить сомнительные факты. Твоя языковая модель: ${settings.llmModel}. Обычно отвечай одной короткой фразой, максимум двумя предложениями. Подробности давай по просьбе. На простой вопрос отвечай сразу, без служебных оговорок, и не возвращайся к предыдущим темам без просьбы. При неясности задай один короткий уточняющий вопрос. Вызывай инструменты без вступительных фраз. Сообщай результат команды один раз. Упоминай громкость, только если её спросили или попросили изменить. Подтверждай трек, только если инструмент вернул актуальный трек. Отвечай для озвучивания без Markdown и ссылок, если их не попросили.`;
const messages = [{ role: 'system', content: expressive ? `${systemPrompt}\n${deliveryPrompt}` : systemPrompt }, ...history.slice(-10).flatMap(t => [{ role: 'user', content: t.user }, { role: 'assistant', content: t.assistant }]), { role: 'user', content: userText }];
const detailed = /подробн|развернут|пошагов|детальн/iu.test(userText);
let totalCalls = 0;
for (let round = 0; round < 4; round++) {
const response = await fetch(llmUrl, { method: 'POST', signal, headers: { Authorization: `Bearer ${env.OPENROUTER_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: settings.llmModel, messages, tools, stream: true, max_tokens: detailed ? 800 : 220, ...(settings.llmModel === 'anthropic/claude-haiku-5.5' ? { reasoning: { enabled: false, effort: 'low' } } : {}) }) });
if (!response.ok || !response.body) throw new Error('llm_error');
let buffer = '', content = '', done = false, finishReason;
const calls = new Map();
const decoder = new TextDecoder();
const consume = event => {
const payload = event.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
if (!payload) return;
if (payload === '[DONE]') { done = true; return; }
const message = JSON.parse(payload);
if (message.error) throw new Error('llm_error');
const choice = message.choices?.[0];
if (!choice) return;
if (choice.finish_reason) finishReason = choice.finish_reason;
const delta = choice.delta || {};
if (typeof delta.content === 'string' && delta.content) {
content += delta.content;
if (content.length > 12000) throw new Error('llm_limit');
}
for (const part of delta.tool_calls || []) {
if (!Number.isInteger(part.index) || part.index < 0 || part.index >= 4) throw new Error('llm_limit');
const call = calls.get(part.index) || { id: '', type: 'function', function: { name: '', arguments: '' } };
if (part.id) call.id += part.id;
if (part.type && part.type !== 'function') throw new Error('llm_error');
if (part.function?.name) call.function.name += part.function.name;
if (part.function?.arguments) call.function.arguments += part.function.arguments;
if (call.id.length > 512 || call.function.name.length > 128 || call.function.arguments.length > 8192) throw new Error('llm_limit');
calls.set(part.index, call);
}
};
for await (const chunk of response.body) {
buffer += decoder.decode(chunk, { stream: true });
if (buffer.length > 65536) throw new Error('llm_limit');
let match;
while ((match = /\r?\n\r?\n/.exec(buffer))) { consume(buffer.slice(0, match.index)); buffer = buffer.slice(match.index + match[0].length); }
}
buffer += decoder.decode();
if (buffer.trim()) consume(buffer);
if (!done || !finishReason) throw new Error('llm_incomplete');
if (!calls.size) {
if (!['stop', 'length'].includes(finishReason) || !content.trim()) throw new Error('llm_incomplete');
const { answer, audio } = spokenReply(content, detailed, expressive);
if (!answer) throw new Error('llm_incomplete');
onText(audio);
return answer;
}
if (finishReason !== 'tool_calls') throw new Error('llm_incomplete');
const ordered = [...calls.entries()].sort((a, b) => a[0] - b[0]).map(([, call]) => call);
if ((totalCalls += ordered.length) > 4 || round === 3 || ordered.some(c => !c.id || !c.function.name)) throw new Error('llm_limit');
messages.push({ role: 'assistant', content: content || null, tool_calls: ordered });
const results = new Map();
const groups = [
ordered.filter(call => call.function.name === 'station_volume'),
ordered.filter(call => call.function.name !== 'station_volume'),
];
await Promise.all(groups.map(async group => {
for (const call of group) {
signal.throwIfAborted();
let result;
try {
const args = validateTool(call.function.name, JSON.parse(call.function.arguments));
if (typeof executeDeviceTool !== 'function') throw new Error('device_unavailable');
result = await executeDeviceTool(call.function.name, args);
signal.throwIfAborted();
if (JSON.stringify(result ?? {}).length > 8192) result = { error: 'result_too_large' };
} catch (error) {
if (signal.aborted) throw error;
result = { error: 'tool_failed' };
}
results.set(call.id, result ?? { ok: true });
}
}));
if (ordered.length === 1 && ordered[0].function.name === 'spotify_play') {
const result = results.get(ordered[0].id);
const title = result.selection?.name?.trim().slice(0, 110);
let answer;
if (result.accepted === true) answer = title ? `Включаю «${title}».` : 'Включаю музыку.';
if (result.error === 'spotify_not_found') answer = 'В Spotify ничего не нашла.';
if (answer) { onText(answer); return answer; }
}
for (const call of ordered) {
messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(results.get(call.id)) });
}
}
throw new Error('llm_limit');
}