181 lines
12 KiB
JavaScript
181 lines
12 KiB
JavaScript
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' });
|
||
|
||
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 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: 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 = detailed ? content.trim() : shortReply(content);
|
||
onText(answer);
|
||
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');
|
||
}
|