diff --git a/backend/routes/messages.js b/backend/routes/messages.js index 1af0a62..f851906 100644 --- a/backend/routes/messages.js +++ b/backend/routes/messages.js @@ -27,29 +27,62 @@ router.get('/', async (req, res) => { const encryptionKey = encryptionUtils.getEncryptionKey(); try { - // Проверяем, это гостевой идентификатор (формат: channel:rawId) - if (userId && userId.includes(':')) { + // Проверяем, это гостевой идентификатор (формат: guest_123) + 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()( `SELECT id, - decrypt_text(identifier_encrypted, $2) as user_id, + decrypt_text(identifier_encrypted, $3) as user_id, channel, - decrypt_text(content_encrypted, $2) as content, + decrypt_text(content_encrypted, $3) as content, content_type, attachments, media_metadata, is_ai, created_at 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`, - [userId, encryptionKey] + [guestIdentifier, guestChannel, encryptionKey] ); // Преобразуем формат для совместимости с фронтендом const messages = guestResult.rows.map(msg => ({ id: msg.id, - user_id: msg.user_id, + user_id: `guest_${guestId}`, sender_type: msg.is_ai ? 'bot' : 'user', content: msg.content, channel: msg.channel, diff --git a/backend/routes/tags.js b/backend/routes/tags.js index c926043..f6c770b 100644 --- a/backend/routes/tags.js +++ b/backend/routes/tags.js @@ -25,11 +25,24 @@ router.use((req, res, next) => { // PATCH /api/tags/user/:userId — установить теги пользователю router.patch('/user/:userId', async (req, res) => { - const userId = Number(req.params.userId); + const userIdParam = req.params.userId; 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)) { return res.status(400).json({ error: 'tags должен быть массивом' }); } + try { // Удаляем старые связи 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 — получить все теги пользователя 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 { const result = await db.getQuery()( '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 — удалить тег у пользователя 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); + + if (isNaN(userId) || isNaN(tagId)) { + return res.status(400).json({ error: 'Invalid user ID or tag ID' }); + } + try { await db.getQuery()( 'DELETE FROM user_tag_links WHERE user_id = $1 AND tag_id = $2', diff --git a/backend/routes/users.js b/backend/routes/users.js index 1ddfcec..70869fc 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -226,26 +226,39 @@ router.get('/', requireAuth, async (req, res, next) => { const guestContactsResult = await db.getQuery()( `WITH decrypted_guests AS ( SELECT + id, decrypt_text(identifier_encrypted, $1) as guest_identifier, channel, created_at, user_id FROM unified_guest_messages 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 + ROW_NUMBER() OVER (ORDER BY guest_id ASC) as guest_number, + 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 - ORDER BY MAX(created_at) DESC`, + created_at, + last_message_at, + message_count + FROM guest_groups + ORDER BY guest_id ASC`, [encryptionKey] ); - const guestContacts = guestContactsResult.rows.map(g => { + const guestContacts = guestContactsResult.rows.map((g) => { const channelMap = { 'web': '🌐', 'telegram': '📱', @@ -254,9 +267,21 @@ router.get('/', requireAuth, async (req, res, next) => { const icon = channelMap[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 { - id: g.guest_identifier, // Используем unified identifier как ID - name: `${icon} ${g.channel === 'web' ? 'Гость' : g.channel} (${rawId.substring(0, 8)}...)`, + id: `guest_${g.guest_id}`, // Используем внутренний ID для поиска + guest_number: parseInt(g.guest_number), // Порядковый номер для отображения + guest_identifier: g.guest_identifier, // Сохраняем для запросов + name: displayName, email: g.channel === 'email' ? rawId : null, telegram: g.channel === 'telegram' ? rawId : null, wallet: null, @@ -322,13 +347,27 @@ router.post('/mark-contact-read', async (req, res) => { try { const adminId = req.user && req.user.id; const { contactId } = req.body; + if (!adminId || !contactId) { 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( '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 }); } catch (e) { console.error('[ERROR] /mark-contact-read:', e); @@ -426,19 +465,75 @@ router.patch('/:id', requireAuth, async (req, res) => { // DELETE /api/users/:id — удалить контакт и все связанные данные router.delete('/:id', requireAuth, async (req, res) => { - // console.log('[users.js] DELETE HANDLER', req.params.id); - const userId = Number(req.params.id); - // console.log('[ROUTER] Перед вызовом deleteUserById для userId:', userId); + const userIdParam = req.params.id; + 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); - // console.log('[ROUTER] deleteUserById вернул:', deletedCount); + if (deletedCount === 0) { return res.status(404).json({ success: false, deleted: 0, error: 'User not found' }); } + broadcastContactsUpdate(); res.json({ success: true, deleted: deletedCount }); } catch (e) { - // console.error('[DELETE] Ошибка при удалении пользователя:', e); + console.error('[DELETE] Ошибка при удалении:', e); res.status(500).json({ error: 'DB error', details: e.message }); } }); @@ -457,26 +552,36 @@ router.get('/:id', async (req, res, next) => { try { const query = db.getQuery(); - // Проверяем, это гостевой идентификатор (формат: channel:rawId) - if (userId.includes(':')) { + // Проверяем, это гостевой идентификатор (формат: guest_123) + 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( `WITH decrypted_guest AS ( SELECT + id, decrypt_text(identifier_encrypted, $2) as guest_identifier, channel, - created_at + created_at, + user_id FROM unified_guest_messages - WHERE decrypt_text(identifier_encrypted, $2) = $1 + WHERE user_id IS NULL ) 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_guest - GROUP BY guest_identifier, channel`, - [userId, encryptionKey] + GROUP BY guest_identifier, channel + HAVING MIN(id) = $1`, + [guestId, encryptionKey] ); if (guestResult.rows.length === 0) { @@ -484,17 +589,28 @@ router.get('/:id', async (req, res, next) => { } const guest = guestResult.rows[0]; - const rawId = userId.replace(`${guest.channel}:`, ''); + const rawId = guest.guest_identifier.replace(`${guest.channel}:`, ''); const channelMap = { 'web': '🌐', 'telegram': '📱', 'email': '✉️' }; 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({ - id: userId, - name: `${icon} ${guest.channel === 'web' ? 'Гость' : guest.channel} (${rawId.substring(0, 8)}...)`, + id: `guest_${guestId}`, + guest_identifier: guest.guest_identifier, + name: displayName, email: guest.channel === 'email' ? rawId : null, telegram: guest.channel === 'telegram' ? rawId : null, wallet: null, diff --git a/backend/server.js b/backend/server.js index 602da0e..0f17c83 100644 --- a/backend/server.js +++ b/backend/server.js @@ -37,15 +37,33 @@ async function startServer() { console.warn('[Server] Ollama недоступен, AI ассистент будет инициализирован позже:', error.message); }); - // Инициализация ботов сразу при старте (не ждем Ollama) - console.log('[Server] ▶️ Импортируем BotManager...'); - const botManager = require('./services/botManager'); - console.log('[Server] ▶️ Вызываем botManager.initialize()...'); - botManager.initialize() + // ⏳ НОВОЕ: Ожидание готовности Ollama перед запуском ботов + const { waitForOllama } = require('./utils/waitForOllama'); + + // Запускаем ожидание Ollama в фоне (не блокируем старт сервера) + 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(() => { console.log('[Server] ✅ botManager.initialize() завершен'); - // ✨ НОВОЕ: Запускаем AI Queue Worker после инициализации ботов + // ✨ Запускаем AI Queue Worker после инициализации ботов if (process.env.USE_AI_QUEUE !== 'false') { const ragService = require('./services/ragService'); ragService.startQueueWorker(); @@ -53,7 +71,7 @@ async function startServer() { } }) .catch(error => { - console.error('[Server] ❌ Ошибка botManager.initialize():', error.message); + console.error('[Server] ❌ Ошибка инициализации:', error.message); logger.error('[Server] Ошибка инициализации ботов:', error); }); diff --git a/backend/services/ai-assistant.js b/backend/services/ai-assistant.js index cac78a1..9737fdb 100644 --- a/backend/services/ai-assistant.js +++ b/backend/services/ai-assistant.js @@ -12,6 +12,7 @@ const logger = require('../utils/logger'); const ollamaConfig = require('./ollamaConfig'); +const { shouldProcessWithAI } = require('../utils/languageFilter'); /** * AI Assistant - тонкая обёртка для работы с Ollama и RAG @@ -70,6 +71,18 @@ class AIAssistant { try { 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 aiAssistantSettingsService = require('./aiAssistantSettingsService'); const aiAssistantRulesService = require('./aiAssistantRulesService'); diff --git a/backend/services/botManager.js b/backend/services/botManager.js index 75f35e9..8807001 100644 --- a/backend/services/botManager.js +++ b/backend/services/botManager.js @@ -56,12 +56,20 @@ class BotManager { await telegramBot.initialize().catch(error => { logger.warn('[BotManager] Telegram Bot не инициализирован:', error.message); }); + + // Устанавливаем централизованный процессор сообщений для Telegram + telegramBot.setMessageProcessor(this.processMessage.bind(this)); + logger.info('[BotManager] ✅ Telegram Bot подключен к unified processor'); // Инициализируем Email Bot logger.info('[BotManager] Инициализация Email Bot...'); await emailBot.initialize().catch(error => { logger.warn('[BotManager] Email Bot не инициализирован:', error.message); }); + + // Устанавливаем централизованный процессор сообщений для Email + emailBot.setMessageProcessor(this.processMessage.bind(this)); + logger.info('[BotManager] ✅ Email Bot подключен к unified processor'); this.isInitialized = true; logger.info('[BotManager] ✅ BotManager успешно инициализирован'); diff --git a/backend/services/ollamaConfig.js b/backend/services/ollamaConfig.js index 6b85dd6..f283f22 100644 --- a/backend/services/ollamaConfig.js +++ b/backend/services/ollamaConfig.js @@ -150,14 +150,14 @@ async function getEmbeddingModel() { function getTimeouts() { return { // Ollama API - таймауты запросов - ollamaChat: 120000, // 120 сек (2 мин) - генерация ответов LLM - ollamaEmbedding: 60000, // 60 сек (1 мин) - генерация embeddings + ollamaChat: 180000, // 180 сек (3 мин) - генерация ответов LLM (увеличено для сложных запросов) + ollamaEmbedding: 90000, // 90 сек (1.5 мин) - генерация embeddings (увеличено) ollamaHealth: 5000, // 5 сек - health check ollamaTags: 10000, // 10 сек - список моделей // Vector Search - таймауты запросов - vectorSearch: 30000, // 30 сек - поиск по векторам - vectorUpsert: 60000, // 60 сек - индексация данных + vectorSearch: 90000, // 90 сек - поиск по векторам (увеличено для больших баз) + vectorUpsert: 90000, // 90 сек - индексация данных (увеличено) vectorHealth: 5000, // 5 сек - health check // AI Cache - TTL (Time To Live) для кэширования @@ -166,12 +166,12 @@ function getTimeouts() { cacheMax: 1000, // Максимум записей в кэше // AI Queue - параметры очереди - queueTimeout: 120000, // 120 сек - таймаут задачи в очереди + queueTimeout: 180000, // 180 сек - таймаут задачи в очереди (увеличено) queueMaxSize: 100, // Максимум задач в очереди queueInterval: 100, // 100 мс - интервал проверки очереди // Default для совместимости - default: 120000 // 120 сек + default: 180000 // 180 сек (увеличено с 120) }; } diff --git a/backend/services/ragService.js b/backend/services/ragService.js index f33a504..6cc4d2f 100644 --- a/backend/services/ragService.js +++ b/backend/services/ragService.js @@ -395,6 +395,15 @@ async function generateLLMResponse({ const ollamaUrl = ollamaConfig.getBaseUrl(); 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`, { model: model || ollamaConfig.getDefaultModel(), messages: messages, @@ -406,7 +415,17 @@ async function generateLLMResponse({ llmResponse = response.data.message.content; } 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 if (answer) { @@ -414,6 +433,11 @@ async function generateLLMResponse({ return answer; } + // Если был таймаут и нет ответа из RAG - возвращаем более информативное сообщение + if (isTimeout) { + return 'Извините, обработка запроса заняла слишком много времени. Пожалуйста, попробуйте упростить ваш вопрос или повторите попытку позже.'; + } + return 'Извините, произошла ошибка при генерации ответа.'; } diff --git a/backend/utils/languageFilter.js b/backend/utils/languageFilter.js new file mode 100644 index 0000000..fc58bac --- /dev/null +++ b/backend/utils/languageFilter.js @@ -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 +}; + diff --git a/backend/utils/waitForOllama.js b/backend/utils/waitForOllama.js new file mode 100644 index 0000000..771e20b --- /dev/null +++ b/backend/utils/waitForOllama.js @@ -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} 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} 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} 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 +}; + diff --git a/frontend/src/views/contacts/ContactDetailsView.vue b/frontend/src/views/contacts/ContactDetailsView.vue index fc504a3..998e937 100644 --- a/frontend/src/views/contacts/ContactDetailsView.vue +++ b/frontend/src/views/contacts/ContactDetailsView.vue @@ -379,21 +379,39 @@ function formatDate(date) { } async function loadMessages() { if (!contact.value || !contact.value.id) return; + + console.log('[ContactDetailsView] 📥 loadMessages START for:', contact.value.id); isLoadingMessages.value = true; 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) { lastMessageDate.value = messages.value[messages.value.length - 1].created_at; } else { lastMessageDate.value = null; } - // Также получаем conversationId для отправки новых сообщений - const conv = await messagesService.getConversationByUserId(contact.value.id); - conversationId.value = conv?.id || null; + // Получаем conversationId только для зарегистрированных пользователей + // Гости не имеют conversations + 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) { - console.error('[ContactDetailsView] Ошибка загрузки сообщений:', e); + console.error('[ContactDetailsView] ❌ Ошибка загрузки сообщений:', e); messages.value = []; lastMessageDate.value = null; conversationId.value = null;