297 lines
12 KiB
JavaScript
297 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
|
||
*/
|
||
|
||
console.log('[ai-assistant] loaded');
|
||
|
||
const { ChatOllama } = require('@langchain/ollama');
|
||
const { HNSWLib } = require('@langchain/community/vectorstores/hnswlib');
|
||
const { OpenAIEmbeddings } = require('@langchain/openai');
|
||
const logger = require('../utils/logger');
|
||
const fetch = require('node-fetch');
|
||
|
||
// Простой кэш для ответов
|
||
const responseCache = new Map();
|
||
const CACHE_TTL = 5 * 60 * 1000; // 5 минут
|
||
|
||
class AIAssistant {
|
||
constructor() {
|
||
this.baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
|
||
this.defaultModel = process.env.OLLAMA_MODEL || 'qwen2.5:7b';
|
||
this.isModelLoaded = false;
|
||
this.lastHealthCheck = 0;
|
||
this.healthCheckInterval = 30000; // 30 секунд
|
||
}
|
||
|
||
// Проверка здоровья модели
|
||
async checkModelHealth() {
|
||
const now = Date.now();
|
||
if (now - this.lastHealthCheck < this.healthCheckInterval) {
|
||
return this.isModelLoaded;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${this.baseUrl}/api/tags`, {
|
||
timeout: 5000
|
||
});
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
this.isModelLoaded = data.models?.some(m => m.name === this.defaultModel) || false;
|
||
} else {
|
||
this.isModelLoaded = false;
|
||
}
|
||
} catch (error) {
|
||
console.error('Model health check failed:', error);
|
||
this.isModelLoaded = false;
|
||
}
|
||
|
||
this.lastHealthCheck = now;
|
||
return this.isModelLoaded;
|
||
}
|
||
|
||
// Очистка старых записей кэша
|
||
cleanupCache() {
|
||
const now = Date.now();
|
||
for (const [key, value] of responseCache.entries()) {
|
||
if (now - value.timestamp > CACHE_TTL) {
|
||
responseCache.delete(key);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Создание экземпляра ChatOllama с нужными параметрами
|
||
createChat(language = 'ru', customSystemPrompt = '') {
|
||
// Используем кастомный системный промпт, если он передан, иначе используем дефолтный
|
||
let systemPrompt = customSystemPrompt;
|
||
if (!systemPrompt) {
|
||
systemPrompt = language === 'ru'
|
||
? 'Вы - полезный ассистент. Отвечайте на русском языке кратко и по делу.'
|
||
: 'You are a helpful assistant. Respond in English briefly and to the point.';
|
||
}
|
||
|
||
return new ChatOllama({
|
||
baseUrl: this.baseUrl,
|
||
model: this.defaultModel,
|
||
system: systemPrompt,
|
||
temperature: 0.3, // Уменьшаем для более предсказуемых ответов
|
||
maxTokens: 100, // Еще больше уменьшаем для быстрого ответа
|
||
timeout: 60000, // Увеличиваем таймаут до 60 секунд
|
||
options: {
|
||
num_ctx: 512, // Еще больше уменьшаем контекст для экономии памяти
|
||
num_thread: 12, // Увеличиваем количество потоков еще больше
|
||
num_gpu: 1,
|
||
num_gqa: 8,
|
||
rope_freq_base: 1000000,
|
||
rope_freq_scale: 0.5,
|
||
repeat_penalty: 1.1, // Добавляем штраф за повторения
|
||
top_k: 20, // Еще больше ограничиваем выбор токенов
|
||
top_p: 0.8, // Уменьшаем nucleus sampling
|
||
temperature: 0.1, // Еще больше уменьшаем для более предсказуемых ответов
|
||
}
|
||
});
|
||
}
|
||
|
||
// Определение языка сообщения
|
||
detectLanguage(message) {
|
||
const cyrillicPattern = /[а-яА-ЯёЁ]/;
|
||
return cyrillicPattern.test(message) ? 'ru' : 'en';
|
||
}
|
||
|
||
// Основной метод для получения ответа
|
||
async getResponse(message, language = 'auto', history = null, systemPrompt = '', rules = null) {
|
||
try {
|
||
console.log('getResponse called with:', { message, language, history, systemPrompt, rules });
|
||
|
||
// Очищаем старый кэш
|
||
this.cleanupCache();
|
||
|
||
// Проверяем здоровье модели
|
||
const isHealthy = await this.checkModelHealth();
|
||
if (!isHealthy) {
|
||
console.warn('Model is not healthy, returning fallback response');
|
||
return 'Извините, модель временно недоступна. Пожалуйста, попробуйте позже.';
|
||
}
|
||
|
||
// Создаем ключ кэша
|
||
const cacheKey = JSON.stringify({ message, language, systemPrompt, rules });
|
||
const cached = responseCache.get(cacheKey);
|
||
if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) {
|
||
console.log('Returning cached response');
|
||
return cached.response;
|
||
}
|
||
|
||
// Определяем язык, если не указан явно
|
||
const detectedLanguage = language === 'auto' ? this.detectLanguage(message) : language;
|
||
console.log('Detected language:', detectedLanguage);
|
||
|
||
// Формируем system prompt с учётом правил
|
||
let fullSystemPrompt = systemPrompt || '';
|
||
if (rules && typeof rules === 'object') {
|
||
fullSystemPrompt += '\n' + JSON.stringify(rules, null, 2);
|
||
}
|
||
|
||
// Формируем массив сообщений для Qwen2.5/OpenAI API
|
||
const messages = [];
|
||
if (fullSystemPrompt) {
|
||
messages.push({ role: 'system', content: fullSystemPrompt });
|
||
}
|
||
if (Array.isArray(history) && history.length > 0) {
|
||
for (const msg of history) {
|
||
if (msg.role && msg.content) {
|
||
messages.push({ role: msg.role, content: msg.content });
|
||
}
|
||
}
|
||
}
|
||
// Добавляем текущее сообщение пользователя
|
||
messages.push({ role: 'user', content: message });
|
||
|
||
let response = null;
|
||
|
||
// Пробуем прямой API запрос (OpenAI-совместимый endpoint)
|
||
try {
|
||
console.log('Trying direct API request...');
|
||
response = await this.fallbackRequestOpenAI(messages, detectedLanguage, fullSystemPrompt);
|
||
console.log('Direct API response received:', response);
|
||
} catch (error) {
|
||
console.error('Error in direct API request:', error);
|
||
|
||
// Если прямой запрос не удался, пробуем через ChatOllama (склеиваем сообщения в текст)
|
||
const chat = this.createChat(detectedLanguage, fullSystemPrompt);
|
||
try {
|
||
const prompt = messages.map(m => `${m.role === 'user' ? 'Пользователь' : m.role === 'assistant' ? 'Ассистент' : 'Система'}: ${m.content}`).join('\n');
|
||
console.log('Sending request to ChatOllama...');
|
||
const chatResponse = await chat.invoke(prompt);
|
||
console.log('ChatOllama response:', chatResponse);
|
||
response = chatResponse.content;
|
||
} catch (chatError) {
|
||
console.error('Error using ChatOllama:', chatError);
|
||
throw chatError;
|
||
}
|
||
}
|
||
|
||
// Кэшируем ответ
|
||
if (response) {
|
||
responseCache.set(cacheKey, {
|
||
response,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
|
||
return response;
|
||
} catch (error) {
|
||
console.error('Error in getResponse:', error);
|
||
return 'Извините, я не смог обработать ваш запрос. Пожалуйста, попробуйте позже.';
|
||
}
|
||
}
|
||
|
||
// Новый метод для OpenAI/Qwen2.5 совместимого endpoint
|
||
async fallbackRequestOpenAI(messages, language, systemPrompt = '') {
|
||
try {
|
||
console.log('Using fallbackRequestOpenAI with:', { messages, language, systemPrompt });
|
||
const model = this.defaultModel;
|
||
|
||
// Создаем AbortController для таймаута
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 60000); // Увеличиваем до 60 секунд
|
||
|
||
const response = await fetch(`${this.baseUrl}/v1/chat/completions`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
model,
|
||
messages,
|
||
stream: false,
|
||
options: {
|
||
temperature: 0.3,
|
||
num_predict: 200, // Уменьшаем максимальную длину ответа
|
||
num_ctx: 1024, // Уменьшаем контекст для экономии памяти
|
||
num_thread: 8, // Увеличиваем количество потоков
|
||
num_gpu: 1, // Используем GPU если доступен
|
||
num_gqa: 8, // Оптимизация для qwen2.5
|
||
rope_freq_base: 1000000, // Оптимизация для qwen2.5
|
||
rope_freq_scale: 0.5, // Оптимизация для qwen2.5
|
||
repeat_penalty: 1.1, // Добавляем штраф за повторения
|
||
top_k: 40, // Ограничиваем выбор токенов
|
||
top_p: 0.9, // Используем nucleus sampling
|
||
},
|
||
}),
|
||
signal: controller.signal,
|
||
});
|
||
|
||
clearTimeout(timeoutId);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
const data = await response.json();
|
||
// Qwen2.5/OpenAI API возвращает ответ в data.choices[0].message.content
|
||
if (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) {
|
||
return data.choices[0].message.content;
|
||
}
|
||
return data.response || '';
|
||
} catch (error) {
|
||
console.error('Error in fallbackRequestOpenAI:', error);
|
||
if (error.name === 'AbortError') {
|
||
throw new Error('Request timeout - модель не ответила в течение 60 секунд');
|
||
}
|
||
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) {
|
||
// ... код поиска документов ...
|
||
}
|
||
}
|
||
|
||
// Создаем и экспортируем единственный экземпляр
|
||
const aiAssistant = new AIAssistant();
|
||
module.exports = aiAssistant;
|