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();
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,

View File

@@ -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',

View File

@@ -226,14 +226,17 @@ 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,
@@ -241,11 +244,21 @@ router.get('/', requireAuth, async (req, res, next) => {
COUNT(*) as message_count
FROM decrypted_guests
GROUP BY guest_identifier, channel
ORDER BY MAX(created_at) DESC`,
)
SELECT
ROW_NUMBER() OVER (ORDER BY guest_id ASC) as guest_number,
guest_id,
guest_identifier,
channel,
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,7 +589,7 @@ 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': '📱',
@@ -492,9 +597,20 @@ router.get('/:id', async (req, res, next) => {
};
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,

View File

@@ -37,15 +37,33 @@ async function startServer() {
console.warn('[Server] Ollama недоступен, AI ассистент будет инициализирован позже:', error.message);
});
// Инициализация ботов сразу при старте (не ждем Ollama)
// НОВОЕ: Ожидание готовности 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()...');
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);
});

View File

@@ -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');

View File

@@ -57,12 +57,20 @@ class BotManager {
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 успешно инициализирован');

View File

@@ -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)
};
}

View File

@@ -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) {
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 'Извините, произошла ошибка при генерации ответа.';
}

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() {
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 для отправки новых сообщений
// Получаем 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;