import WebSocket from 'ws'; import { tools, validateTool } from './tools.mjs'; const env = process.env; 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)) url.searchParams.set(key, value); 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' }), 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'); }