147 lines
9.2 KiB
JavaScript
147 lines
9.2 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 })); } };
|
|
}
|
|
|
|
export async function streamAnswer(settings, history, userText, signal, onText, executeDeviceTool) {
|
|
if (env.LLM_MODE === 'echo') { onText(userText); return userText; }
|
|
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} (Москва). Используй веб-поиск для актуальных сведений и когда требуется проверить сомнительные факты. Вызывай инструменты без вступительных фраз. Подтверждай воспроизведение музыки только после успешного результата инструмента. Отвечай для озвучивания без 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 }];
|
|
let answer = '', 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: 1200, ...(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) {
|
|
const prefix = !content && answer && !/\s$/.test(answer) && !/^\s/.test(delta.content) ? ' ' : '';
|
|
content += delta.content; answer += prefix + delta.content;
|
|
if (answer.length > 12000) throw new Error('llm_limit');
|
|
onText(prefix + delta.content);
|
|
}
|
|
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 (finishReason !== 'stop' || !answer.trim()) throw new Error('llm_incomplete');
|
|
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 });
|
|
for (const call of ordered) {
|
|
if (signal.aborted) throw new Error('aborted');
|
|
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);
|
|
if (signal.aborted) throw new Error('aborted');
|
|
result = JSON.stringify(result ?? { ok: true });
|
|
if (result.length > 8192) result = JSON.stringify({ error: 'result_too_large' });
|
|
} catch (error) {
|
|
if (signal.aborted) throw error;
|
|
result = JSON.stringify({ error: 'tool_failed' });
|
|
}
|
|
messages.push({ role: 'tool', tool_call_id: call.id, content: result });
|
|
}
|
|
}
|
|
throw new Error('llm_limit');
|
|
}
|