Files
alice-backend/server/providers.mjs
T

95 lines
5.6 KiB
JavaScript

import WebSocket from 'ws';
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) {
if (env.LLM_MODE === 'echo') { onText(userText); return userText; }
if (!env.OPENROUTER_API_KEY) throw new Error('llm_config');
const messages = [{ role: 'system', content: settings.systemPrompt }, ...history.slice(-10).flatMap(t => [{ role: 'user', content: t.user }, { role: 'assistant', content: t.assistant }]), { role: 'user', content: userText }];
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, stream: true, max_tokens: 512 }) });
if (!response.ok || !response.body) throw new Error('llm_error');
let buffer = '', answer = '', finished = false;
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]') { finished = true; return; }
const message = JSON.parse(payload);
if (message.error) throw new Error('llm_error');
const content = message.choices?.[0]?.delta?.content;
if (typeof content === 'string' && content) { answer += content; if (answer.length > 12000) throw new Error('llm_limit'); onText(content); }
};
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 (!finished || !answer.trim()) throw new Error('llm_incomplete');
return answer;
}