78 lines
3.5 KiB
JavaScript
78 lines
3.5 KiB
JavaScript
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
export const defaults = Object.freeze({
|
|
llmModel: 'anthropic/claude-haiku-5.5',
|
|
sttModel: 'scribe_v3_live',
|
|
followUpMs: 3000,
|
|
activationSound: false,
|
|
ttsModel: 'eleven_v4_turbo',
|
|
voiceId: '21m00Tcm4TlvDq8ikWAM',
|
|
systemPrompt: 'Ты голосовой помощник Алиса. Отвечай по-русски, коротко и по делу. Пиши так, чтобы ответ хорошо звучал вслух: обычный текст, без Markdown. Если не знаешь ответа, скажи об этом прямо.',
|
|
});
|
|
|
|
const sttModels = [defaults.sttModel, 'scribe_v2_realtime'];
|
|
export const ttsModels = ['eleven_v4_turbo', 'eleven_v3_conversational', 'eleven_flash_v2_5'];
|
|
|
|
export function validateSettings(input) {
|
|
if (typeof input !== 'object' || input === null) return null;
|
|
const { llmModel, sttModel, ttsModel, voiceId, systemPrompt, followUpMs = defaults.followUpMs, activationSound = defaults.activationSound } = input;
|
|
if (typeof llmModel !== 'string' || !/^[\w.~:-]+\/[\w.:~-]+$/.test(llmModel) || llmModel.length > 120) return null;
|
|
if (!sttModels.includes(sttModel) || !ttsModels.includes(ttsModel)) return null;
|
|
if (typeof voiceId !== 'string' || !/^[a-zA-Z0-9]{20}$/.test(voiceId)) return null;
|
|
if (typeof systemPrompt !== 'string' || !systemPrompt.trim() || systemPrompt.length > 4000) return null;
|
|
if (!Number.isInteger(followUpMs) || followUpMs < 0 || followUpMs > 10000 || typeof activationSound !== 'boolean') return null;
|
|
return { llmModel, sttModel, ttsModel, voiceId, systemPrompt: systemPrompt.trim(), followUpMs, activationSound };
|
|
}
|
|
|
|
export function openStore(directory = process.env.DATA_DIR ?? './data') {
|
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
const path = join(directory, 'assistant.json');
|
|
let state = { settings: { ...defaults }, history: [] };
|
|
try {
|
|
const saved = JSON.parse(readFileSync(path, 'utf8'));
|
|
const settings = validateSettings({ ...defaults, ...saved.settings });
|
|
if (!settings || !Array.isArray(saved.history)) throw new Error('Invalid assistant state');
|
|
state = { settings, history: saved.history, device: saved.device };
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') throw error;
|
|
}
|
|
|
|
function commit(next) {
|
|
const temporary = path + '.tmp';
|
|
writeFileSync(temporary, JSON.stringify(next) + '\n', { mode: 0o600 });
|
|
renameSync(temporary, path);
|
|
state = next;
|
|
}
|
|
|
|
return {
|
|
getDevice: () => {
|
|
if (!state.device) return null;
|
|
return { ...state.device, online: Date.now() - state.device.updatedAt < 45000 };
|
|
},
|
|
recordDevice(device) {
|
|
const updatedAt = Date.now();
|
|
const lastPlayback = device.item?.uri && device.active
|
|
? { item: device.item, context: device.context, position: device.position, status: device.status, updatedAt }
|
|
: state.device?.lastPlayback;
|
|
commit({ ...state, device: { ...device, updatedAt, lastPlayback } });
|
|
},
|
|
getSettings: () => ({ ...state.settings }),
|
|
getHistory: () => state.history.map(turn => ({ ...turn })),
|
|
updateSettings(settings) {
|
|
commit({ ...state, settings });
|
|
},
|
|
recordTurn(user, assistant) {
|
|
const turn = {
|
|
user: user.slice(0, 6000),
|
|
assistant: assistant.slice(0, 6000),
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
commit({ ...state, history: [...state.history.slice(-49), turn] });
|
|
},
|
|
clearHistory() {
|
|
commit({ ...state, history: [] });
|
|
},
|
|
};
|
|
}
|