feat: новая функция
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 успешно инициализирован');
|
||||
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 'Извините, произошла ошибка при генерации ответа.';
|
||||
}
|
||||
|
||||
|
||||
85
backend/utils/languageFilter.js
Normal file
85
backend/utils/languageFilter.js
Normal 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
|
||||
};
|
||||
|
||||
148
backend/utils/waitForOllama.js
Normal file
148
backend/utils/waitForOllama.js
Normal 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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user