feat: новая функция

This commit is contained in:
2025-10-09 20:49:51 +03:00
parent 13fb51e447
commit 34666b44d8
11 changed files with 553 additions and 53 deletions

View File

@@ -27,29 +27,62 @@ router.get('/', async (req, res) => {
const encryptionKey = encryptionUtils.getEncryptionKey(); const encryptionKey = encryptionUtils.getEncryptionKey();
try { try {
// Проверяем, это гостевой идентификатор (формат: channel:rawId) // Проверяем, это гостевой идентификатор (формат: guest_123)
if (userId && userId.includes(':')) { if (userId && userId.startsWith('guest_')) {
const guestId = parseInt(userId.replace('guest_', ''));
if (isNaN(guestId)) {
return res.status(400).json({ error: 'Invalid guest ID format' });
}
// Сначала получаем guest_identifier по guestId
const identifierResult = await db.getQuery()(
`WITH decrypted_guest AS (
SELECT
id,
decrypt_text(identifier_encrypted, $2) as guest_identifier,
channel
FROM unified_guest_messages
WHERE user_id IS NULL
)
SELECT guest_identifier, channel
FROM decrypted_guest
GROUP BY guest_identifier, channel
HAVING MIN(id) = $1
LIMIT 1`,
[guestId, encryptionKey]
);
if (identifierResult.rows.length === 0) {
return res.json([]);
}
const guestIdentifier = identifierResult.rows[0].guest_identifier;
const guestChannel = identifierResult.rows[0].channel;
// Теперь получаем все сообщения этого гостя (по идентификатору И каналу)
const guestResult = await db.getQuery()( const guestResult = await db.getQuery()(
`SELECT `SELECT
id, id,
decrypt_text(identifier_encrypted, $2) as user_id, decrypt_text(identifier_encrypted, $3) as user_id,
channel, channel,
decrypt_text(content_encrypted, $2) as content, decrypt_text(content_encrypted, $3) as content,
content_type, content_type,
attachments, attachments,
media_metadata, media_metadata,
is_ai, is_ai,
created_at created_at
FROM unified_guest_messages FROM unified_guest_messages
WHERE decrypt_text(identifier_encrypted, $2) = $1 WHERE decrypt_text(identifier_encrypted, $3) = $1
AND channel = $2
ORDER BY created_at ASC`, ORDER BY created_at ASC`,
[userId, encryptionKey] [guestIdentifier, guestChannel, encryptionKey]
); );
// Преобразуем формат для совместимости с фронтендом // Преобразуем формат для совместимости с фронтендом
const messages = guestResult.rows.map(msg => ({ const messages = guestResult.rows.map(msg => ({
id: msg.id, id: msg.id,
user_id: msg.user_id, user_id: `guest_${guestId}`,
sender_type: msg.is_ai ? 'bot' : 'user', sender_type: msg.is_ai ? 'bot' : 'user',
content: msg.content, content: msg.content,
channel: msg.channel, channel: msg.channel,

View File

@@ -25,11 +25,24 @@ router.use((req, res, next) => {
// PATCH /api/tags/user/:userId — установить теги пользователю // PATCH /api/tags/user/:userId — установить теги пользователю
router.patch('/user/:userId', async (req, res) => { router.patch('/user/:userId', async (req, res) => {
const userId = Number(req.params.userId); const userIdParam = req.params.userId;
const { tags } = req.body; // массив tagIds (id строк из таблицы тегов) const { tags } = req.body; // массив tagIds (id строк из таблицы тегов)
// Гостевые пользователи (guest_123) не могут иметь теги
if (userIdParam.startsWith('guest_')) {
return res.status(400).json({ error: 'Guests cannot have tags' });
}
const userId = Number(userIdParam);
if (isNaN(userId)) {
return res.status(400).json({ error: 'Invalid user ID' });
}
if (!Array.isArray(tags)) { if (!Array.isArray(tags)) {
return res.status(400).json({ error: 'tags должен быть массивом' }); return res.status(400).json({ error: 'tags должен быть массивом' });
} }
try { try {
// Удаляем старые связи // Удаляем старые связи
await db.getQuery()('DELETE FROM user_tag_links WHERE user_id = $1', [userId]); await db.getQuery()('DELETE FROM user_tag_links WHERE user_id = $1', [userId]);
@@ -52,7 +65,19 @@ router.patch('/user/:userId', async (req, res) => {
// GET /api/tags/user/:userId — получить все теги пользователя // GET /api/tags/user/:userId — получить все теги пользователя
router.get('/user/:userId', async (req, res) => { router.get('/user/:userId', async (req, res) => {
const userId = Number(req.params.userId); const userIdParam = req.params.userId;
// Гостевые пользователи (guest_123) не имеют тегов
if (userIdParam.startsWith('guest_')) {
return res.json({ tags: [] });
}
const userId = Number(userIdParam);
if (isNaN(userId)) {
return res.status(400).json({ error: 'Invalid user ID' });
}
try { try {
const result = await db.getQuery()( const result = await db.getQuery()(
'SELECT tag_id FROM user_tag_links WHERE user_id = $1', 'SELECT tag_id FROM user_tag_links WHERE user_id = $1',
@@ -66,8 +91,20 @@ router.get('/user/:userId', async (req, res) => {
// DELETE /api/tags/user/:userId/tag/:tagId — удалить тег у пользователя // DELETE /api/tags/user/:userId/tag/:tagId — удалить тег у пользователя
router.delete('/user/:userId/tag/:tagId', async (req, res) => { router.delete('/user/:userId/tag/:tagId', async (req, res) => {
const userId = Number(req.params.userId); const userIdParam = req.params.userId;
// Гостевые пользователи (guest_123) не могут иметь теги
if (userIdParam.startsWith('guest_')) {
return res.status(400).json({ error: 'Guests cannot have tags' });
}
const userId = Number(userIdParam);
const tagId = Number(req.params.tagId); const tagId = Number(req.params.tagId);
if (isNaN(userId) || isNaN(tagId)) {
return res.status(400).json({ error: 'Invalid user ID or tag ID' });
}
try { try {
await db.getQuery()( await db.getQuery()(
'DELETE FROM user_tag_links WHERE user_id = $1 AND tag_id = $2', 'DELETE FROM user_tag_links WHERE user_id = $1 AND tag_id = $2',

View File

@@ -226,26 +226,39 @@ router.get('/', requireAuth, async (req, res, next) => {
const guestContactsResult = await db.getQuery()( const guestContactsResult = await db.getQuery()(
`WITH decrypted_guests AS ( `WITH decrypted_guests AS (
SELECT SELECT
id,
decrypt_text(identifier_encrypted, $1) as guest_identifier, decrypt_text(identifier_encrypted, $1) as guest_identifier,
channel, channel,
created_at, created_at,
user_id user_id
FROM unified_guest_messages FROM unified_guest_messages
WHERE user_id IS NULL WHERE user_id IS NULL
),
guest_groups AS (
SELECT
MIN(id) as guest_id,
guest_identifier,
channel,
MIN(created_at) as created_at,
MAX(created_at) as last_message_at,
COUNT(*) as message_count
FROM decrypted_guests
GROUP BY guest_identifier, channel
) )
SELECT SELECT
ROW_NUMBER() OVER (ORDER BY guest_id ASC) as guest_number,
guest_id,
guest_identifier, guest_identifier,
channel, channel,
MIN(created_at) as created_at, created_at,
MAX(created_at) as last_message_at, last_message_at,
COUNT(*) as message_count message_count
FROM decrypted_guests FROM guest_groups
GROUP BY guest_identifier, channel ORDER BY guest_id ASC`,
ORDER BY MAX(created_at) DESC`,
[encryptionKey] [encryptionKey]
); );
const guestContacts = guestContactsResult.rows.map(g => { const guestContacts = guestContactsResult.rows.map((g) => {
const channelMap = { const channelMap = {
'web': '🌐', 'web': '🌐',
'telegram': '📱', 'telegram': '📱',
@@ -254,9 +267,21 @@ router.get('/', requireAuth, async (req, res, next) => {
const icon = channelMap[g.channel] || '👤'; const icon = channelMap[g.channel] || '👤';
const rawId = g.guest_identifier.replace(`${g.channel}:`, ''); const rawId = g.guest_identifier.replace(`${g.channel}:`, '');
// Формируем имя в зависимости от канала
let displayName;
if (g.channel === 'email') {
displayName = `${icon} ${rawId}`;
} else if (g.channel === 'telegram') {
displayName = `${icon} Telegram (${rawId})`;
} else {
displayName = `${icon} Гость ${g.guest_number}`;
}
return { return {
id: g.guest_identifier, // Используем unified identifier как ID id: `guest_${g.guest_id}`, // Используем внутренний ID для поиска
name: `${icon} ${g.channel === 'web' ? 'Гость' : g.channel} (${rawId.substring(0, 8)}...)`, guest_number: parseInt(g.guest_number), // Порядковый номер для отображения
guest_identifier: g.guest_identifier, // Сохраняем для запросов
name: displayName,
email: g.channel === 'email' ? rawId : null, email: g.channel === 'email' ? rawId : null,
telegram: g.channel === 'telegram' ? rawId : null, telegram: g.channel === 'telegram' ? rawId : null,
wallet: null, wallet: null,
@@ -322,13 +347,27 @@ router.post('/mark-contact-read', async (req, res) => {
try { try {
const adminId = req.user && req.user.id; const adminId = req.user && req.user.id;
const { contactId } = req.body; const { contactId } = req.body;
if (!adminId || !contactId) { if (!adminId || !contactId) {
return res.status(400).json({ error: 'adminId and contactId required' }); return res.status(400).json({ error: 'adminId and contactId required' });
} }
// Валидация contactId: может быть числом (user.id) или строкой (guest identifier)
// Приводим к строке для универсальности
const contactIdStr = String(contactId);
// Проверка на допустимые форматы:
// - Число (user.id): "123"
// - Гостевой идентификатор: "telegram:123", "email:user@example.com", "web:uuid"
if (!contactIdStr || contactIdStr.length > 255) {
return res.status(400).json({ error: 'Invalid contactId format' });
}
await db.query( await db.query(
'INSERT INTO admin_read_contacts (admin_id, contact_id, read_at) VALUES ($1, $2, NOW()) ON CONFLICT (admin_id, contact_id) DO UPDATE SET read_at = NOW()', 'INSERT INTO admin_read_contacts (admin_id, contact_id, read_at) VALUES ($1, $2, NOW()) ON CONFLICT (admin_id, contact_id) DO UPDATE SET read_at = NOW()',
[adminId, contactId] [adminId, contactIdStr]
); );
res.json({ success: true }); res.json({ success: true });
} catch (e) { } catch (e) {
console.error('[ERROR] /mark-contact-read:', e); console.error('[ERROR] /mark-contact-read:', e);
@@ -426,19 +465,75 @@ router.patch('/:id', requireAuth, async (req, res) => {
// DELETE /api/users/:id — удалить контакт и все связанные данные // DELETE /api/users/:id — удалить контакт и все связанные данные
router.delete('/:id', requireAuth, async (req, res) => { router.delete('/:id', requireAuth, async (req, res) => {
// console.log('[users.js] DELETE HANDLER', req.params.id); const userIdParam = req.params.id;
const userId = Number(req.params.id);
// console.log('[ROUTER] Перед вызовом deleteUserById для userId:', userId);
try { try {
// Обработка гостевых контактов (guest_123)
if (userIdParam.startsWith('guest_')) {
const guestId = parseInt(userIdParam.replace('guest_', ''));
if (isNaN(guestId)) {
return res.status(400).json({ error: 'Invalid guest ID format' });
}
// Получаем ключ шифрования
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
// Находим guest_identifier по guestId
const identifierResult = await db.getQuery()(
`WITH decrypted_guest AS (
SELECT
id,
decrypt_text(identifier_encrypted, $2) as guest_identifier,
channel
FROM unified_guest_messages
WHERE user_id IS NULL
)
SELECT guest_identifier, channel
FROM decrypted_guest
GROUP BY guest_identifier, channel
HAVING MIN(id) = $1
LIMIT 1`,
[guestId, encryptionKey]
);
if (identifierResult.rows.length === 0) {
return res.status(404).json({ error: 'Guest contact not found' });
}
const guestIdentifier = identifierResult.rows[0].guest_identifier;
const guestChannel = identifierResult.rows[0].channel;
// Удаляем все сообщения этого гостя
const deleteResult = await db.getQuery()(
`DELETE FROM unified_guest_messages
WHERE decrypt_text(identifier_encrypted, $2) = $1
AND channel = $3`,
[guestIdentifier, encryptionKey, guestChannel]
);
broadcastContactsUpdate();
return res.json({ success: true, deleted: deleteResult.rowCount });
}
// Обработка обычных пользователей
const userId = Number(userIdParam);
if (isNaN(userId)) {
return res.status(400).json({ error: 'Invalid user ID' });
}
const deletedCount = await deleteUserById(userId); const deletedCount = await deleteUserById(userId);
// console.log('[ROUTER] deleteUserById вернул:', deletedCount);
if (deletedCount === 0) { if (deletedCount === 0) {
return res.status(404).json({ success: false, deleted: 0, error: 'User not found' }); return res.status(404).json({ success: false, deleted: 0, error: 'User not found' });
} }
broadcastContactsUpdate(); broadcastContactsUpdate();
res.json({ success: true, deleted: deletedCount }); res.json({ success: true, deleted: deletedCount });
} catch (e) { } catch (e) {
// console.error('[DELETE] Ошибка при удалении пользователя:', e); console.error('[DELETE] Ошибка при удалении:', e);
res.status(500).json({ error: 'DB error', details: e.message }); res.status(500).json({ error: 'DB error', details: e.message });
} }
}); });
@@ -457,26 +552,36 @@ router.get('/:id', async (req, res, next) => {
try { try {
const query = db.getQuery(); const query = db.getQuery();
// Проверяем, это гостевой идентификатор (формат: channel:rawId) // Проверяем, это гостевой идентификатор (формат: guest_123)
if (userId.includes(':')) { if (userId.startsWith('guest_')) {
const guestId = parseInt(userId.replace('guest_', ''));
if (isNaN(guestId)) {
return res.status(400).json({ error: 'Invalid guest ID format' });
}
const guestResult = await query( const guestResult = await query(
`WITH decrypted_guest AS ( `WITH decrypted_guest AS (
SELECT SELECT
id,
decrypt_text(identifier_encrypted, $2) as guest_identifier, decrypt_text(identifier_encrypted, $2) as guest_identifier,
channel, channel,
created_at created_at,
user_id
FROM unified_guest_messages FROM unified_guest_messages
WHERE decrypt_text(identifier_encrypted, $2) = $1 WHERE user_id IS NULL
) )
SELECT SELECT
MIN(id) as guest_id,
guest_identifier, guest_identifier,
channel, channel,
MIN(created_at) as created_at, MIN(created_at) as created_at,
MAX(created_at) as last_message_at, MAX(created_at) as last_message_at,
COUNT(*) as message_count COUNT(*) as message_count
FROM decrypted_guest FROM decrypted_guest
GROUP BY guest_identifier, channel`, GROUP BY guest_identifier, channel
[userId, encryptionKey] HAVING MIN(id) = $1`,
[guestId, encryptionKey]
); );
if (guestResult.rows.length === 0) { if (guestResult.rows.length === 0) {
@@ -484,17 +589,28 @@ router.get('/:id', async (req, res, next) => {
} }
const guest = guestResult.rows[0]; const guest = guestResult.rows[0];
const rawId = userId.replace(`${guest.channel}:`, ''); const rawId = guest.guest_identifier.replace(`${guest.channel}:`, '');
const channelMap = { const channelMap = {
'web': '🌐', 'web': '🌐',
'telegram': '📱', 'telegram': '📱',
'email': '✉️' 'email': '✉️'
}; };
const icon = channelMap[guest.channel] || '👤'; const icon = channelMap[guest.channel] || '👤';
// Формируем имя в зависимости от канала
let displayName;
if (guest.channel === 'email') {
displayName = `${icon} ${rawId}`;
} else if (guest.channel === 'telegram') {
displayName = `${icon} Telegram (${rawId})`;
} else {
displayName = `${icon} Гость ${guestId}`;
}
return res.json({ return res.json({
id: userId, id: `guest_${guestId}`,
name: `${icon} ${guest.channel === 'web' ? 'Гость' : guest.channel} (${rawId.substring(0, 8)}...)`, guest_identifier: guest.guest_identifier,
name: displayName,
email: guest.channel === 'email' ? rawId : null, email: guest.channel === 'email' ? rawId : null,
telegram: guest.channel === 'telegram' ? rawId : null, telegram: guest.channel === 'telegram' ? rawId : null,
wallet: null, wallet: null,

View File

@@ -37,15 +37,33 @@ async function startServer() {
console.warn('[Server] Ollama недоступен, AI ассистент будет инициализирован позже:', error.message); console.warn('[Server] Ollama недоступен, AI ассистент будет инициализирован позже:', error.message);
}); });
// Инициализация ботов сразу при старте (не ждем Ollama) // НОВОЕ: Ожидание готовности Ollama перед запуском ботов
console.log('[Server] ▶️ Импортируем BotManager...'); const { waitForOllama } = require('./utils/waitForOllama');
const botManager = require('./services/botManager');
console.log('[Server] ▶️ Вызываем botManager.initialize()...'); // Запускаем ожидание Ollama в фоне (не блокируем старт сервера)
botManager.initialize() waitForOllama({
maxWaitTime: 4 * 60 * 1000, // 4 минуты
retryInterval: 5000, // 5 секунд между попытками
required: false // Не обязательно - запустим боты даже без Ollama
})
.then((ollamaReady) => {
if (ollamaReady) {
console.log('[Server] ✅ Ollama готов к работе');
} else {
console.warn('[Server] ⚠️ Ollama не готов, боты будут работать с ограниченным функционалом AI');
}
// Инициализация ботов ПОСЛЕ ожидания Ollama
console.log('[Server] ▶️ Импортируем BotManager...');
const botManager = require('./services/botManager');
console.log('[Server] ▶️ Вызываем botManager.initialize()...');
return botManager.initialize();
})
.then(() => { .then(() => {
console.log('[Server] ✅ botManager.initialize() завершен'); console.log('[Server] ✅ botManager.initialize() завершен');
// ✨ НОВОЕ: Запускаем AI Queue Worker после инициализации ботов // ✨ Запускаем AI Queue Worker после инициализации ботов
if (process.env.USE_AI_QUEUE !== 'false') { if (process.env.USE_AI_QUEUE !== 'false') {
const ragService = require('./services/ragService'); const ragService = require('./services/ragService');
ragService.startQueueWorker(); ragService.startQueueWorker();
@@ -53,7 +71,7 @@ async function startServer() {
} }
}) })
.catch(error => { .catch(error => {
console.error('[Server] ❌ Ошибка botManager.initialize():', error.message); console.error('[Server] ❌ Ошибка инициализации:', error.message);
logger.error('[Server] Ошибка инициализации ботов:', error); logger.error('[Server] Ошибка инициализации ботов:', error);
}); });

View File

@@ -12,6 +12,7 @@
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const ollamaConfig = require('./ollamaConfig'); const ollamaConfig = require('./ollamaConfig');
const { shouldProcessWithAI } = require('../utils/languageFilter');
/** /**
* AI Assistant - тонкая обёртка для работы с Ollama и RAG * AI Assistant - тонкая обёртка для работы с Ollama и RAG
@@ -70,6 +71,18 @@ class AIAssistant {
try { try {
logger.info(`[AIAssistant] Генерация ответа для канала ${channel}, пользователь ${userId}`); logger.info(`[AIAssistant] Генерация ответа для канала ${channel}, пользователь ${userId}`);
// 0. Проверяем язык сообщения (только русский)
const languageCheck = shouldProcessWithAI(userQuestion);
if (!languageCheck.shouldProcess) {
logger.info(`[AIAssistant] ⚠️ Пропуск обработки: ${languageCheck.reason} (user: ${userId}, channel: ${channel})`);
return {
success: false,
reason: languageCheck.reason,
skipped: true,
message: 'AI обрабатывает только сообщения на русском языке'
};
}
const messageDeduplicationService = require('./messageDeduplicationService'); const messageDeduplicationService = require('./messageDeduplicationService');
const aiAssistantSettingsService = require('./aiAssistantSettingsService'); const aiAssistantSettingsService = require('./aiAssistantSettingsService');
const aiAssistantRulesService = require('./aiAssistantRulesService'); const aiAssistantRulesService = require('./aiAssistantRulesService');

View File

@@ -56,12 +56,20 @@ class BotManager {
await telegramBot.initialize().catch(error => { await telegramBot.initialize().catch(error => {
logger.warn('[BotManager] Telegram Bot не инициализирован:', error.message); logger.warn('[BotManager] Telegram Bot не инициализирован:', error.message);
}); });
// Устанавливаем централизованный процессор сообщений для Telegram
telegramBot.setMessageProcessor(this.processMessage.bind(this));
logger.info('[BotManager] ✅ Telegram Bot подключен к unified processor');
// Инициализируем Email Bot // Инициализируем Email Bot
logger.info('[BotManager] Инициализация Email Bot...'); logger.info('[BotManager] Инициализация Email Bot...');
await emailBot.initialize().catch(error => { await emailBot.initialize().catch(error => {
logger.warn('[BotManager] Email Bot не инициализирован:', error.message); logger.warn('[BotManager] Email Bot не инициализирован:', error.message);
}); });
// Устанавливаем централизованный процессор сообщений для Email
emailBot.setMessageProcessor(this.processMessage.bind(this));
logger.info('[BotManager] ✅ Email Bot подключен к unified processor');
this.isInitialized = true; this.isInitialized = true;
logger.info('[BotManager] ✅ BotManager успешно инициализирован'); logger.info('[BotManager] ✅ BotManager успешно инициализирован');

View File

@@ -150,14 +150,14 @@ async function getEmbeddingModel() {
function getTimeouts() { function getTimeouts() {
return { return {
// Ollama API - таймауты запросов // Ollama API - таймауты запросов
ollamaChat: 120000, // 120 сек (2 мин) - генерация ответов LLM ollamaChat: 180000, // 180 сек (3 мин) - генерация ответов LLM (увеличено для сложных запросов)
ollamaEmbedding: 60000, // 60 сек (1 мин) - генерация embeddings ollamaEmbedding: 90000, // 90 сек (1.5 мин) - генерация embeddings (увеличено)
ollamaHealth: 5000, // 5 сек - health check ollamaHealth: 5000, // 5 сек - health check
ollamaTags: 10000, // 10 сек - список моделей ollamaTags: 10000, // 10 сек - список моделей
// Vector Search - таймауты запросов // Vector Search - таймауты запросов
vectorSearch: 30000, // 30 сек - поиск по векторам vectorSearch: 90000, // 90 сек - поиск по векторам (увеличено для больших баз)
vectorUpsert: 60000, // 60 сек - индексация данных vectorUpsert: 90000, // 90 сек - индексация данных (увеличено)
vectorHealth: 5000, // 5 сек - health check vectorHealth: 5000, // 5 сек - health check
// AI Cache - TTL (Time To Live) для кэширования // AI Cache - TTL (Time To Live) для кэширования
@@ -166,12 +166,12 @@ function getTimeouts() {
cacheMax: 1000, // Максимум записей в кэше cacheMax: 1000, // Максимум записей в кэше
// AI Queue - параметры очереди // AI Queue - параметры очереди
queueTimeout: 120000, // 120 сек - таймаут задачи в очереди queueTimeout: 180000, // 180 сек - таймаут задачи в очереди (увеличено)
queueMaxSize: 100, // Максимум задач в очереди queueMaxSize: 100, // Максимум задач в очереди
queueInterval: 100, // 100 мс - интервал проверки очереди queueInterval: 100, // 100 мс - интервал проверки очереди
// Default для совместимости // Default для совместимости
default: 120000 // 120 сек default: 180000 // 180 сек (увеличено с 120)
}; };
} }

View File

@@ -395,6 +395,15 @@ async function generateLLMResponse({
const ollamaUrl = ollamaConfig.getBaseUrl(); const ollamaUrl = ollamaConfig.getBaseUrl();
const timeouts = ollamaConfig.getTimeouts(); const timeouts = ollamaConfig.getTimeouts();
// Логируем размер промпта для отладки
const promptSize = JSON.stringify(messages).length;
console.log(`[RAG] Отправка запроса в Ollama. Размер промпта: ${promptSize} символов, таймаут: ${timeouts.ollamaChat/1000}с`);
// Проверяем размер промпта и предупреждаем, если он большой
if (promptSize > 10000) {
console.warn(`[RAG] ⚠️ Большой промпт (${promptSize} символов). Возможны проблемы с производительностью.`);
}
const response = await axios.post(`${ollamaUrl}/api/chat`, { const response = await axios.post(`${ollamaUrl}/api/chat`, {
model: model || ollamaConfig.getDefaultModel(), model: model || ollamaConfig.getDefaultModel(),
messages: messages, messages: messages,
@@ -406,7 +415,17 @@ async function generateLLMResponse({
llmResponse = response.data.message.content; llmResponse = response.data.message.content;
} catch (error) { } catch (error) {
console.error(`[RAG] Error in Ollama call:`, error.message); const isTimeout = error.message && (
error.message.includes('timeout') ||
error.message.includes('ETIMEDOUT') ||
error.message.includes('ECONNABORTED')
);
if (isTimeout) {
console.warn(`[RAG] Ollama timeout после ${timeouts.ollamaChat/1000}с. Возможно, модель перегружена или контекст слишком большой.`);
} else {
console.error(`[RAG] Error in Ollama call:`, error.message);
}
// Финальный fallback - возврат ответа из RAG // Финальный fallback - возврат ответа из RAG
if (answer) { if (answer) {
@@ -414,6 +433,11 @@ async function generateLLMResponse({
return answer; return answer;
} }
// Если был таймаут и нет ответа из RAG - возвращаем более информативное сообщение
if (isTimeout) {
return 'Извините, обработка запроса заняла слишком много времени. Пожалуйста, попробуйте упростить ваш вопрос или повторите попытку позже.';
}
return 'Извините, произошла ошибка при генерации ответа.'; return 'Извините, произошла ошибка при генерации ответа.';
} }

View File

@@ -0,0 +1,85 @@
/**
* Фильтр сообщений по языку
* AI ассистент работает только на русском языке
*/
/**
* Проверяет наличие кириллицы в тексте
*/
function hasCyrillic(text) {
if (!text || typeof text !== 'string') return false;
return /[а-яА-ЯЁё]/.test(text);
}
/**
* Определяет процент кириллицы в тексте
*/
function getCyrillicPercentage(text) {
if (!text) return 0;
const cyrillicChars = (text.match(/[а-яА-ЯЁё]/g) || []).length;
const totalChars = text.replace(/\s/g, '').length;
return totalChars > 0 ? (cyrillicChars / totalChars) * 100 : 0;
}
/**
* Проверяет, является ли сообщение на русском языке
* @param {string} message - текст сообщения
* @param {number} minCyrillicPercent - минимальный % кириллицы (по умолчанию 10%)
* @returns {boolean}
*/
function isRussianMessage(message, minCyrillicPercent = 10) {
if (!message || typeof message !== 'string') return false;
// Убираем пробелы и спецсимволы для точного подсчета
const cleanText = message.trim();
// Если сообщение очень короткое (например "Hi"), считаем русским
if (cleanText.length < 10) {
return hasCyrillic(cleanText);
}
// Для длинных сообщений проверяем процент кириллицы
const cyrillicPercent = getCyrillicPercentage(cleanText);
return cyrillicPercent >= minCyrillicPercent;
}
/**
* Определяет, нужно ли обрабатывать сообщение AI
* @param {string} message - текст сообщения
* @returns {Object} { shouldProcess: boolean, reason: string }
*/
function shouldProcessWithAI(message) {
if (!message || typeof message !== 'string') {
return { shouldProcess: false, reason: 'Empty message' };
}
const cleanMessage = message.trim();
// Проверка на русский язык
if (!isRussianMessage(cleanMessage)) {
return {
shouldProcess: false,
reason: 'Non-Russian message (AI works only with Russian)'
};
}
// Проверка на максимальный размер (опционально)
const MAX_LENGTH = 10000;
if (cleanMessage.length > MAX_LENGTH) {
return {
shouldProcess: false,
reason: `Message too long (${cleanMessage.length} > ${MAX_LENGTH} chars)`
};
}
return { shouldProcess: true, reason: 'OK' };
}
module.exports = {
hasCyrillic,
getCyrillicPercentage,
isRussianMessage,
shouldProcessWithAI
};

View File

@@ -0,0 +1,148 @@
/**
* 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 logger = require('./logger');
const ollamaConfig = require('../services/ollamaConfig');
/**
* Проверяет, загружена ли модель в память через /api/ps
* НЕ триггерит загрузку модели (в отличие от /api/generate)
* @param {string} modelName - название модели (например: qwen2.5:7b)
* @returns {Promise<boolean>} true если модель в памяти
*/
async function isModelLoaded(modelName) {
try {
const axios = require('axios');
const baseUrl = ollamaConfig.getBaseUrl();
// Используем /api/ps - показывает какие модели сейчас в памяти
// Этот endpoint НЕ триггерит загрузку модели!
const response = await axios.get(`${baseUrl}/api/ps`, {
timeout: 3000
});
// Проверяем, есть ли наша модель в списке загруженных
if (response.data && response.data.models) {
return response.data.models.some(m => {
// Сравниваем без тега (qwen2.5 == qwen2.5:7b)
const modelBaseName = modelName.split(':')[0];
const loadedBaseName = (m.name || m.model || '').split(':')[0];
return loadedBaseName === modelBaseName;
});
}
return false;
} catch (error) {
// API ps может не существовать в старых версиях Ollama
// Или модель не загружена
return false;
}
}
/**
* Ожидание готовности Ollama и загрузки модели в память
* Ollama может загружаться до 4 минут при старте Docker контейнера
* entrypoint.sh загружает модель qwen2.5:7b в память с keep_alive=24h
*
* @param {Object} options - Опции ожидания
* @param {number} options.maxWaitTime - Максимальное время ожидания в мс (по умолчанию 4 минуты)
* @param {number} options.retryInterval - Интервал между попытками в мс (по умолчанию 5 секунд)
* @param {boolean} options.required - Обязательно ли ждать Ollama (по умолчанию false)
* @returns {Promise<boolean>} true если Ollama готов, false если таймаут (и required=false)
*/
async function waitForOllama(options = {}) {
const {
maxWaitTime = parseInt(process.env.OLLAMA_WAIT_TIME) || 4 * 60 * 1000,
retryInterval = parseInt(process.env.OLLAMA_RETRY_INTERVAL) || 5000,
required = process.env.OLLAMA_REQUIRED === 'true' || false
} = options;
const startTime = Date.now();
let attempt = 0;
const maxAttempts = Math.ceil(maxWaitTime / retryInterval);
logger.info(`[waitForOllama] ⏳ Ожидание готовности Ollama и загрузки модели в память (макс. ${maxWaitTime/1000}с, интервал ${retryInterval/1000}с)...`);
while (Date.now() - startTime < maxWaitTime) {
attempt++;
try {
// Шаг 1: Проверяем доступность Ollama API
const healthStatus = await ollamaConfig.checkHealth();
if (healthStatus.status === 'ok') {
const model = healthStatus.model;
// Шаг 2: Проверяем, загружена ли модель в память
const modelReady = await isModelLoaded(model);
if (modelReady) {
const waitedTime = ((Date.now() - startTime) / 1000).toFixed(1);
logger.info(`[waitForOllama] ✅ Ollama готов! Модель ${model} загружена в память (ожидание ${waitedTime}с, попытка ${attempt}/${maxAttempts})`);
logger.info(`[waitForOllama] 📊 Ollama: ${healthStatus.baseUrl}, доступно моделей: ${healthStatus.availableModels}`);
return true;
} else {
logger.info(`[waitForOllama] ⏳ Ollama API готов, но модель ${model} ещё грузится в память... (попытка ${attempt}/${maxAttempts})`);
}
} else {
logger.warn(`[waitForOllama] ⚠️ Ollama API не готов (попытка ${attempt}/${maxAttempts}): ${healthStatus.error}`);
}
} catch (error) {
logger.warn(`[waitForOllama] ⚠️ Ошибка проверки Ollama (попытка ${attempt}/${maxAttempts}): ${error.message}`);
}
// Если не последняя попытка - ждем перед следующей
if (Date.now() - startTime < maxWaitTime) {
const remainingTime = Math.max(0, maxWaitTime - (Date.now() - startTime));
const nextRetry = Math.min(retryInterval, remainingTime);
if (nextRetry > 0) {
await new Promise(resolve => setTimeout(resolve, nextRetry));
}
}
}
// Таймаут истек
const totalWaitTime = ((Date.now() - startTime) / 1000).toFixed(1);
if (required) {
const error = `Ollama не готов после ${totalWaitTime}с ожидания (${attempt} попыток)`;
logger.error(`[waitForOllama] ❌ ${error}`);
throw new Error(error);
} else {
logger.warn(`[waitForOllama] ⚠️ Ollama не готов после ${totalWaitTime}с ожидания (${attempt} попыток). Продолжаем без AI.`);
return false;
}
}
/**
* Проверка готовности Ollama (одна попытка, без ожидания)
* @returns {Promise<boolean>} true если Ollama готов
*/
async function isOllamaReady() {
try {
const healthStatus = await ollamaConfig.checkHealth();
if (healthStatus.status !== 'ok') return false;
// Проверяем модель
return await isModelLoaded(healthStatus.model);
} catch (error) {
return false;
}
}
module.exports = {
waitForOllama,
isOllamaReady
};

View File

@@ -379,21 +379,39 @@ function formatDate(date) {
} }
async function loadMessages() { async function loadMessages() {
if (!contact.value || !contact.value.id) return; if (!contact.value || !contact.value.id) return;
console.log('[ContactDetailsView] 📥 loadMessages START for:', contact.value.id);
isLoadingMessages.value = true; isLoadingMessages.value = true;
try { try {
// Загружаем ВСЕ публичные сообщения этого пользователя (как на главной странице) // Загружаем ВСЕ публичные сообщения этого пользователя (как на главной странице)
messages.value = await messagesService.getMessagesByUserId(contact.value.id); const loadedMessages = await messagesService.getMessagesByUserId(contact.value.id);
console.log('[ContactDetailsView] 📩 Loaded messages:', loadedMessages.length, 'for', contact.value.id);
messages.value = loadedMessages;
if (messages.value.length > 0) { if (messages.value.length > 0) {
lastMessageDate.value = messages.value[messages.value.length - 1].created_at; lastMessageDate.value = messages.value[messages.value.length - 1].created_at;
} else { } else {
lastMessageDate.value = null; lastMessageDate.value = null;
} }
// Также получаем conversationId для отправки новых сообщений // Получаем conversationId только для зарегистрированных пользователей
const conv = await messagesService.getConversationByUserId(contact.value.id); // Гости не имеют conversations
conversationId.value = conv?.id || null; if (!contact.value.id.startsWith('guest_')) {
try {
const conv = await messagesService.getConversationByUserId(contact.value.id);
conversationId.value = conv?.id || null;
} catch (convError) {
console.warn('[ContactDetailsView] Не удалось загрузить conversationId:', convError.message);
conversationId.value = null;
}
} else {
conversationId.value = null; // Гости не имеют conversationId
}
console.log('[ContactDetailsView] ✅ loadMessages DONE, messages count:', messages.value.length);
} catch (e) { } catch (e) {
console.error('[ContactDetailsView] Ошибка загрузки сообщений:', e); console.error('[ContactDetailsView] Ошибка загрузки сообщений:', e);
messages.value = []; messages.value = [];
lastMessageDate.value = null; lastMessageDate.value = null;
conversationId.value = null; conversationId.value = null;