346 lines
12 KiB
JavaScript
346 lines
12 KiB
JavaScript
/**
|
||
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||
* All rights reserved.
|
||
*
|
||
* This software is proprietary and confidential.
|
||
* Unauthorized copying, modification, or distribution is prohibited.
|
||
*
|
||
* For licensing inquiries: info@hb3-accelerator.com
|
||
* Website: https://hb3-accelerator.com
|
||
* GitHub: https://github.com/HB3-ACCELERATOR
|
||
*/
|
||
|
||
const { ChatOllama } = require('@langchain/ollama');
|
||
const aiCache = require('./ai-cache');
|
||
const aiQueue = require('./ai-queue');
|
||
const logger = require('../utils/logger');
|
||
|
||
// Константы для AI параметров
|
||
const AI_CONFIG = {
|
||
temperature: 0.3,
|
||
maxTokens: 512,
|
||
timeout: 180000,
|
||
numCtx: 2048,
|
||
numGpu: 1,
|
||
numThread: 4,
|
||
repeatPenalty: 1.1,
|
||
topK: 40,
|
||
topP: 0.9,
|
||
// tfsZ не поддерживается в текущем Ollama — удаляем
|
||
mirostat: 2,
|
||
mirostatTau: 5,
|
||
mirostatEta: 0.1,
|
||
seed: -1,
|
||
// Ограничим количество генерируемых токенов для CPU, чтобы избежать таймаутов
|
||
numPredict: 256,
|
||
stop: []
|
||
};
|
||
|
||
class AIAssistant {
|
||
constructor() {
|
||
this.baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
|
||
this.defaultModel = process.env.OLLAMA_MODEL || 'qwen2.5:7b';
|
||
this.lastHealthCheck = 0;
|
||
this.healthCheckInterval = 30000; // 30 секунд
|
||
}
|
||
|
||
// Проверка здоровья модели
|
||
async checkModelHealth() {
|
||
const now = Date.now();
|
||
if (now - this.lastHealthCheck < this.healthCheckInterval) {
|
||
return true; // Используем кэшированный результат
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${this.baseUrl}/api/tags`);
|
||
if (!response.ok) {
|
||
throw new Error(`Ollama API returned ${response.status}`);
|
||
}
|
||
const data = await response.json();
|
||
const modelExists = data.models?.some(model => model.name === this.defaultModel);
|
||
|
||
this.lastHealthCheck = now;
|
||
return modelExists;
|
||
} catch (error) {
|
||
logger.error('Model health check failed:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Очистка старого кэша
|
||
cleanupCache() {
|
||
const now = Date.now();
|
||
const maxAge = 3600000; // 1 час
|
||
aiCache.cleanup(maxAge);
|
||
}
|
||
|
||
// Создание чата с кастомным системным промптом
|
||
createChat(customSystemPrompt = '') {
|
||
let systemPrompt = customSystemPrompt;
|
||
if (!systemPrompt) {
|
||
systemPrompt = 'Вы - полезный ассистент. Отвечайте на русском языке кратко и по делу.';
|
||
}
|
||
|
||
return new ChatOllama({
|
||
baseUrl: this.baseUrl,
|
||
model: this.defaultModel,
|
||
system: systemPrompt,
|
||
...AI_CONFIG,
|
||
options: AI_CONFIG
|
||
});
|
||
}
|
||
|
||
// Определение приоритета запроса
|
||
getRequestPriority(message, history, rules) {
|
||
let priority = 0;
|
||
|
||
// Высокий приоритет для коротких запросов
|
||
if (message.length < 50) {
|
||
priority += 10;
|
||
}
|
||
|
||
// Приоритет по типу запроса
|
||
const urgentKeywords = ['срочно', 'важно', 'помоги'];
|
||
if (urgentKeywords.some(keyword => message.toLowerCase().includes(keyword))) {
|
||
priority += 20;
|
||
}
|
||
|
||
// Приоритет для администраторов
|
||
if (rules && rules.isAdmin) {
|
||
priority += 15;
|
||
}
|
||
|
||
// Приоритет по времени ожидания (если есть история)
|
||
if (history && history.length > 0) {
|
||
const lastMessage = history[history.length - 1];
|
||
const timeDiff = Date.now() - (lastMessage.timestamp || Date.now());
|
||
if (timeDiff > 30000) { // Более 30 секунд ожидания
|
||
priority += 5;
|
||
}
|
||
}
|
||
|
||
return priority;
|
||
}
|
||
|
||
// Основной метод для получения ответа
|
||
async getResponse(message, history = null, systemPrompt = '', rules = null) {
|
||
try {
|
||
// Очищаем старый кэш
|
||
this.cleanupCache();
|
||
|
||
// Проверяем здоровье модели
|
||
const isHealthy = await this.checkModelHealth();
|
||
if (!isHealthy) {
|
||
return 'Извините, модель временно недоступна. Пожалуйста, попробуйте позже.';
|
||
}
|
||
|
||
// Проверяем кэш
|
||
const cacheKey = aiCache.generateKey([{ role: 'user', content: message }], {
|
||
temperature: 0.3,
|
||
maxTokens: 150
|
||
});
|
||
const cachedResponse = aiCache.get(cacheKey);
|
||
if (cachedResponse) {
|
||
return cachedResponse;
|
||
}
|
||
|
||
// Определяем приоритет запроса
|
||
const priority = this.getRequestPriority(message, history, rules);
|
||
|
||
// Добавляем запрос в очередь
|
||
const requestId = await aiQueue.addRequest({
|
||
message,
|
||
history,
|
||
systemPrompt,
|
||
rules
|
||
}, priority);
|
||
|
||
// Ждем результат из очереди
|
||
return new Promise((resolve, reject) => {
|
||
const timeout = setTimeout(() => {
|
||
reject(new Error('Request timeout - очередь перегружена'));
|
||
}, 180000); // 180 секунд таймаут для очереди
|
||
|
||
const onCompleted = (item) => {
|
||
if (item.id === requestId) {
|
||
clearTimeout(timeout);
|
||
aiQueue.off('completed', onCompleted);
|
||
aiQueue.off('failed', onFailed);
|
||
try {
|
||
aiCache.set(cacheKey, item.result);
|
||
} catch {}
|
||
resolve(item.result);
|
||
}
|
||
};
|
||
|
||
const onFailed = (item) => {
|
||
if (item.id === requestId) {
|
||
clearTimeout(timeout);
|
||
aiQueue.off('completed', onCompleted);
|
||
aiQueue.off('failed', onFailed);
|
||
reject(new Error(item.error));
|
||
}
|
||
};
|
||
|
||
aiQueue.on('completed', onCompleted);
|
||
aiQueue.on('failed', onFailed);
|
||
});
|
||
} catch (error) {
|
||
logger.error('Error in getResponse:', error);
|
||
return 'Извините, я не смог обработать ваш запрос. Пожалуйста, попробуйте позже.';
|
||
}
|
||
}
|
||
|
||
// Алиас для getResponse (для совместимости)
|
||
async processMessage(message, history = null, systemPrompt = '', rules = null) {
|
||
return this.getResponse(message, history, systemPrompt, rules);
|
||
}
|
||
|
||
// Прямой запрос к API (для очереди)
|
||
async directRequest(messages, systemPrompt = '', optionsOverride = {}) {
|
||
try {
|
||
const model = this.defaultModel;
|
||
|
||
// Создаем AbortController для таймаута
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), AI_CONFIG.timeout);
|
||
|
||
// Маппинг camelCase → snake_case для опций Ollama
|
||
const mapOptionsToOllama = (opts) => ({
|
||
temperature: opts.temperature,
|
||
// Используем только num_predict; не мапим maxTokens, чтобы не завышать лимит генерации
|
||
num_predict: typeof opts.numPredict === 'number' && opts.numPredict > 0 ? opts.numPredict : undefined,
|
||
num_ctx: opts.numCtx,
|
||
num_gpu: opts.numGpu,
|
||
num_thread: opts.numThread,
|
||
repeat_penalty: opts.repeatPenalty,
|
||
top_k: opts.topK,
|
||
top_p: opts.topP,
|
||
tfs_z: opts.tfsZ,
|
||
mirostat: opts.mirostat,
|
||
mirostat_tau: opts.mirostatTau,
|
||
mirostat_eta: opts.mirostatEta,
|
||
seed: opts.seed,
|
||
stop: Array.isArray(opts.stop) ? opts.stop : []
|
||
});
|
||
|
||
const mergedConfig = { ...AI_CONFIG, ...optionsOverride };
|
||
const ollamaOptions = mapOptionsToOllama(mergedConfig);
|
||
|
||
// Вставляем системный промпт в начало, если задан
|
||
const finalMessages = Array.isArray(messages) ? [...messages] : [];
|
||
// Нормализация: только 'user' | 'assistant' | 'system'
|
||
for (const m of finalMessages) {
|
||
if (m && m.role) {
|
||
if (m.role !== 'assistant' && m.role !== 'system') m.role = 'user';
|
||
}
|
||
}
|
||
if (systemPrompt && !finalMessages.find(m => m.role === 'system')) {
|
||
finalMessages.unshift({ role: 'system', content: systemPrompt });
|
||
}
|
||
|
||
let response;
|
||
try {
|
||
response = await fetch(`${this.baseUrl}/api/chat`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
signal: controller.signal,
|
||
body: JSON.stringify({
|
||
model,
|
||
messages: finalMessages,
|
||
stream: false,
|
||
options: ollamaOptions,
|
||
keep_alive: '3m'
|
||
})
|
||
});
|
||
} finally {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
// Ollama /api/chat возвращает ответ в data.message.content
|
||
if (data.message && typeof data.message.content === 'string') {
|
||
const content = data.message.content;
|
||
try {
|
||
const cacheKey = aiCache.generateKey(messages, { num_predict: ollamaOptions.num_predict, temperature: ollamaOptions.temperature });
|
||
aiCache.set(cacheKey, content);
|
||
} catch {}
|
||
return content;
|
||
}
|
||
// OpenAI-совместимый /v1/chat/completions
|
||
if (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) {
|
||
const content = data.choices[0].message.content;
|
||
try {
|
||
const cacheKey = aiCache.generateKey(messages, { num_predict: ollamaOptions.num_predict, temperature: ollamaOptions.temperature });
|
||
aiCache.set(cacheKey, content);
|
||
} catch {}
|
||
return content;
|
||
}
|
||
|
||
const content = data.response || '';
|
||
try {
|
||
const cacheKey = aiCache.generateKey(messages, { num_predict: ollamaOptions.num_predict, temperature: ollamaOptions.temperature });
|
||
aiCache.set(cacheKey, content);
|
||
} catch {}
|
||
return content;
|
||
} catch (error) {
|
||
logger.error('Error in directRequest:', error);
|
||
if (error.name === 'AbortError') {
|
||
throw new Error('Request timeout - модель не ответила в течение 120 секунд');
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Получение списка доступных моделей
|
||
async getAvailableModels() {
|
||
try {
|
||
const response = await fetch(`${this.baseUrl}/api/tags`);
|
||
const data = await response.json();
|
||
return data.models || [];
|
||
} catch (error) {
|
||
logger.error('Error getting available models:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// Проверка здоровья AI сервиса
|
||
async checkHealth() {
|
||
try {
|
||
const response = await fetch(`${this.baseUrl}/api/tags`);
|
||
if (!response.ok) {
|
||
throw new Error(`Ollama API returned ${response.status}`);
|
||
}
|
||
const data = await response.json();
|
||
return {
|
||
status: 'ok',
|
||
models: data.models?.length || 0,
|
||
baseUrl: this.baseUrl
|
||
};
|
||
} catch (error) {
|
||
logger.error('AI health check failed:', error);
|
||
return {
|
||
status: 'error',
|
||
error: error.message,
|
||
baseUrl: this.baseUrl
|
||
};
|
||
}
|
||
}
|
||
|
||
// Добавляем методы из vectorStore.js
|
||
async initVectorStore() {
|
||
// ... код инициализации ...
|
||
}
|
||
|
||
async findSimilarDocuments(query, k = 3) {
|
||
// ... код поиска документов ...
|
||
}
|
||
}
|
||
|
||
module.exports = new AIAssistant();
|