diff --git a/backend/app.js b/backend/app.js index e0e10e5..ca6fc87 100644 --- a/backend/app.js +++ b/backend/app.js @@ -21,6 +21,7 @@ const errorHandler = require('./middleware/errorHandler'); // const { version } = require('./package.json'); // Закомментировано, так как не используется const db = require('./db'); // Добавляем импорт db const aiAssistant = require('./services/ai-assistant'); // Добавляем импорт aiAssistant +const { warmupModel } = require('./scripts/warmup-model'); // Добавляем импорт разогрева модели const fs = require('fs'); const path = require('path'); const messagesRoutes = require('./routes/messages'); @@ -97,7 +98,9 @@ app.use( cors({ origin: [ 'http://localhost:5173', - 'http://127.0.0.1:5173', // Добавляем альтернативный origin + 'http://127.0.0.1:5173', + 'https://hb3-accelerator.com', + 'https://www.hb3-accelerator.com', ], credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], diff --git a/backend/routes/messages.js b/backend/routes/messages.js index b3b7d16..bc69b1d 100644 --- a/backend/routes/messages.js +++ b/backend/routes/messages.js @@ -262,6 +262,37 @@ router.get('/conversations', async (req, res) => { } }); +// POST /api/conversations - создать беседу для пользователя +router.post('/conversations', async (req, res) => { + const { userId, title } = req.body; + if (!userId) return res.status(400).json({ error: 'userId required' }); + + // Получаем ключ шифрования + const fs = require('fs'); + const path = require('path'); + let encryptionKey = 'default-key'; + + try { + const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key'); + if (fs.existsSync(keyPath)) { + encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); + } + } catch (keyError) { + console.error('Error reading encryption key:', keyError); + } + + try { + const conversationTitle = title || `Чат с пользователем ${userId}`; + const result = await db.getQuery()( + 'INSERT INTO conversations (user_id, title_encrypted, created_at, updated_at) VALUES ($1, encrypt_text($2, $3), NOW(), NOW()) RETURNING *', + [userId, conversationTitle, encryptionKey] + ); + res.json(result.rows[0]); + } catch (e) { + res.status(500).json({ error: 'DB error', details: e.message }); + } +}); + // Массовая рассылка сообщения во все каналы пользователя router.post('/broadcast', async (req, res) => { const { user_id, content } = req.body; diff --git a/backend/routes/monitoring.js b/backend/routes/monitoring.js index af840b9..5378cb6 100644 --- a/backend/routes/monitoring.js +++ b/backend/routes/monitoring.js @@ -14,6 +14,10 @@ const express = require('express'); const router = express.Router(); const axios = require('axios'); const db = require('../db'); +const aiAssistant = require('../services/ai-assistant'); +const aiCache = require('../services/ai-cache'); +const aiQueue = require('../services/ai-queue'); +const logger = require('../utils/logger'); router.get('/', async (req, res) => { const results = {}; @@ -50,4 +54,71 @@ router.get('/', async (req, res) => { res.json({ status: 'ok', services: results, timestamp: new Date().toISOString() }); }); +// GET /api/monitoring/ai-stats - статистика AI +router.get('/ai-stats', async (req, res) => { + try { + const aiHealth = await aiAssistant.checkHealth(); + const cacheStats = aiCache.getStats(); + const queueStats = aiQueue.getStats(); + + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + ai: { + health: aiHealth, + model: process.env.OLLAMA_MODEL || 'qwen2.5:7b', + baseUrl: process.env.OLLAMA_BASE_URL || 'http://localhost:11434' + }, + cache: { + ...cacheStats, + hitRate: `${(cacheStats.hitRate * 100).toFixed(1)}%` + }, + queue: { + ...queueStats, + avgResponseTime: `${queueStats.avgResponseTime.toFixed(0)}ms` + } + }); + } catch (error) { + logger.error('Error getting AI stats:', error); + res.status(500).json({ + status: 'error', + error: error.message + }); + } +}); + +// POST /api/monitoring/ai-cache/clear - очистка кэша +router.post('/ai-cache/clear', async (req, res) => { + try { + aiCache.clear(); + res.json({ + status: 'ok', + message: 'AI cache cleared successfully' + }); + } catch (error) { + logger.error('Error clearing AI cache:', error); + res.status(500).json({ + status: 'error', + error: error.message + }); + } +}); + +// POST /api/monitoring/ai-queue/clear - очистка очереди +router.post('/ai-queue/clear', async (req, res) => { + try { + aiQueue.clear(); + res.json({ + status: 'ok', + message: 'AI queue cleared successfully' + }); + } catch (error) { + logger.error('Error clearing AI queue:', error); + res.status(500).json({ + status: 'error', + error: error.message + }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/backend/routes/tables.js b/backend/routes/tables.js index 212e062..90f0e92 100644 --- a/backend/routes/tables.js +++ b/backend/routes/tables.js @@ -338,53 +338,7 @@ router.get('/:id/rows', async (req, res, next) => { } }); -// Изменить значение ячейки (доступно всем) -router.patch('/cell/:cellId', async (req, res, next) => { - try { - const cellId = req.params.cellId; - const { value } = req.body; - // Получаем ключ шифрования - const fs = require('fs'); - const path = require('path'); - let encryptionKey = 'default-key'; - - try { - const keyPath = '/app/ssl/keys/full_db_encryption.key'; - if (fs.existsSync(keyPath)) { - encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); - } - } catch (keyError) { - console.error('Error reading encryption key:', keyError); - } - - const result = await db.getQuery()( - 'UPDATE user_cell_values SET value_encrypted = encrypt_text($1, $3), updated_at = NOW() WHERE id = $2 RETURNING *', - [value, cellId, encryptionKey] - ); - // Получаем row_id и table_id - const row = (await db.getQuery()('SELECT row_id FROM user_cell_values WHERE id = $1', [cellId])).rows[0]; - if (row) { - const rowId = row.row_id; - const table = (await db.getQuery()('SELECT table_id FROM user_rows WHERE id = $1', [rowId])).rows[0]; - if (table) { - const tableId = table.table_id; - // Получаем всю строку для upsert - const rowData = (await db.getQuery()('SELECT r.id as row_id, decrypt_text(c.value_encrypted, $2) as text, decrypt_text(c2.value_encrypted, $2) as answer FROM user_rows r LEFT JOIN user_cell_values c ON c.row_id = r.id AND c.column_id = 1 LEFT JOIN user_cell_values c2 ON c2.row_id = r.id AND c2.column_id = 2 WHERE r.id = $1', [rowId, encryptionKey])).rows[0]; - if (rowData) { - const upsertRows = [{ row_id: rowData.row_id, text: rowData.text, metadata: { answer: rowData.answer } }].filter(r => r.row_id && r.text); - console.log('[DEBUG][upsertRows]', upsertRows); - if (upsertRows.length > 0) { - await vectorSearchClient.upsert(tableId, upsertRows); - } - } - } - } - res.json(result.rows[0]); - broadcastTableUpdate(tableId); - } catch (err) { - next(err); - } -}); + // Создать/обновить значение ячейки (upsert) (доступно всем) router.post('/cell', async (req, res, next) => { @@ -416,13 +370,15 @@ router.post('/cell', async (req, res, next) => { if (table) { const tableId = table.table_id; - // Проверяем, является ли это таблицей "Теги клиентов" - ОТКЛЮЧАЕМ WebSocket - // const tableName = (await db.getQuery()('SELECT decrypt_text(name_encrypted, $2) as name FROM user_tables WHERE id = $1', [tableId, encryptionKey])).rows[0]; - // if (tableName && tableName.name === 'Теги клиентов') { - // // Отправляем WebSocket уведомление об обновлении тегов - // const { broadcastTagsUpdate } = require('../wsHub'); - // broadcastTagsUpdate(); - // } + // Проверяем, является ли это таблицей "Теги клиентов" + const tableName = (await db.getQuery()('SELECT decrypt_text(name_encrypted, $2) as name FROM user_tables WHERE id = $1', [tableId, encryptionKey])).rows[0]; + console.log('🔄 [Tables] Проверяем таблицу:', { tableId, tableName: tableName?.name }); + if (tableName && tableName.name === 'Теги клиентов') { + // Отправляем WebSocket уведомление об обновлении тегов + console.log('🔄 [Tables] Обновление ячейки в таблице тегов, отправляем уведомление'); + const { broadcastTagsUpdate } = require('../wsHub'); + broadcastTagsUpdate(); + } // Получаем всю строку для upsert const rowData = (await db.getQuery()('SELECT r.id as row_id, decrypt_text(c.value_encrypted, $2) as text, decrypt_text(c2.value_encrypted, $2) as answer FROM user_rows r LEFT JOIN user_cell_values c ON c.row_id = r.id AND c.column_id = 1 LEFT JOIN user_cell_values c2 ON c2.row_id = r.id AND c2.column_id = 2 WHERE r.id = $1', [row_id, encryptionKey])).rows[0]; @@ -499,11 +455,12 @@ router.delete('/row/:rowId', async (req, res, next) => { } } - // Отправляем WebSocket уведомление, если это была таблица тегов - ОТКЛЮЧАЕМ - // if (isTagsTable) { - // const { broadcastTagsUpdate } = require('../wsHub'); - // broadcastTagsUpdate(); - // } + // Отправляем WebSocket уведомление, если это была таблица тегов + if (isTagsTable) { + console.log('🔄 [Tables] Обновление строки в таблице тегов, отправляем уведомление'); + const { broadcastTagsUpdate } = require('../wsHub'); + broadcastTagsUpdate(); + } // Отправляем WebSocket уведомление об обновлении таблицы const { broadcastTableUpdate } = require('../wsHub'); @@ -790,6 +747,31 @@ router.post('/:tableId/row/:rowId/multirelations', async (req, res, next) => { const { tableId, rowId } = req.params; const { column_id, to_table_id, to_row_ids } = req.body; // to_row_ids: массив id if (!Array.isArray(to_row_ids)) return res.status(400).json({ error: 'to_row_ids должен быть массивом' }); + + // Получаем ключ шифрования + const fs = require('fs'); + const path = require('path'); + let encryptionKey = 'default-key'; + + try { + const keyPath = '/app/ssl/keys/full_db_encryption.key'; + if (fs.existsSync(keyPath)) { + encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); + } + } catch (keyError) { + console.error('Error reading encryption key:', keyError); + } + + // Проверяем, является ли это обновлением тегов (проверяем связанную таблицу) + const relatedTableName = (await db.getQuery()('SELECT decrypt_text(name_encrypted, $2) as name FROM user_tables WHERE id = $1', [to_table_id, encryptionKey])).rows[0]; + console.log('🔄 [Tables] Multirelations: проверяем связанную таблицу:', { to_table_id, tableName: relatedTableName?.name }); + + if (relatedTableName && relatedTableName.name === 'Теги клиентов') { + console.log('🔄 [Tables] Multirelations: обновление тегов, отправляем уведомление'); + const { broadcastTagsUpdate } = require('../wsHub'); + broadcastTagsUpdate(); + } + // Удаляем старые связи для этой строки/столбца await db.getQuery()('DELETE FROM user_table_relations WHERE from_row_id = $1 AND column_id = $2', [rowId, column_id]); // Добавляем новые связи diff --git a/backend/routes/users.js b/backend/routes/users.js index 4f988d1..78ed511 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -84,7 +84,7 @@ router.get('/', requireAuth, async (req, res, next) => { let encryptionKey = 'default-key'; try { - const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key'); + const keyPath = '/app/ssl/keys/full_db_encryption.key'; if (fs.existsSync(keyPath)) { encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); } @@ -120,13 +120,16 @@ router.get('/', requireAuth, async (req, res, next) => { // Фильтр по поиску if (search) { where.push(`( - LOWER(u.first_name) LIKE $${idx} OR - LOWER(u.last_name) LIKE $${idx} OR - EXISTS (SELECT 1 FROM user_identities ui WHERE ui.user_id = u.id AND LOWER(decrypt_text(ui.provider_id_encrypted, $${idx + 1})) LIKE $${idx}) + LOWER(decrypt_text(u.first_name_encrypted, $${idx++})) LIKE $${idx++} OR + LOWER(decrypt_text(u.last_name_encrypted, $${idx++})) LIKE $${idx++} OR + EXISTS (SELECT 1 FROM user_identities ui WHERE ui.user_id = u.id AND LOWER(decrypt_text(ui.provider_id_encrypted, $${idx++})) LIKE $${idx++}) )`); + params.push(encryptionKey); // Для first_name_encrypted + params.push(`%${search.toLowerCase()}%`); + params.push(encryptionKey); // Для last_name_encrypted + params.push(`%${search.toLowerCase()}%`); + params.push(encryptionKey); // Для provider_id_encrypted params.push(`%${search.toLowerCase()}%`); - params.push(encryptionKey); - idx += 2; } // Фильтр по блокировке @@ -138,13 +141,22 @@ router.get('/', requireAuth, async (req, res, next) => { // --- Основной SQL --- let sql = ` - SELECT u.id, u.first_name, u.last_name, u.created_at, u.preferred_language, u.is_blocked, + SELECT u.id, + CASE + WHEN u.first_name_encrypted IS NULL OR u.first_name_encrypted = '' THEN NULL + ELSE decrypt_text(u.first_name_encrypted, $${idx++}) + END as first_name, + CASE + WHEN u.last_name_encrypted IS NULL OR u.last_name_encrypted = '' THEN NULL + ELSE decrypt_text(u.last_name_encrypted, $${idx++}) + END as last_name, + u.created_at, u.preferred_language, u.is_blocked, (SELECT decrypt_text(provider_id_encrypted, $${idx++}) FROM user_identities WHERE user_id = u.id AND provider_encrypted = encrypt_text('email', $${idx++}) LIMIT 1) AS email, (SELECT decrypt_text(provider_id_encrypted, $${idx++}) FROM user_identities WHERE user_id = u.id AND provider_encrypted = encrypt_text('telegram', $${idx++}) LIMIT 1) AS telegram, (SELECT decrypt_text(provider_id_encrypted, $${idx++}) FROM user_identities WHERE user_id = u.id AND provider_encrypted = encrypt_text('wallet', $${idx++}) LIMIT 1) AS wallet FROM users u `; - params.push(encryptionKey, encryptionKey, encryptionKey, encryptionKey, encryptionKey, encryptionKey); + params.push(encryptionKey, encryptionKey, encryptionKey, encryptionKey, encryptionKey, encryptionKey, encryptionKey, encryptionKey); // Фильтрация по тегам if (tagIds) { @@ -298,13 +310,55 @@ router.patch('/:id/unblock', requireAuth, async (req, res) => { router.patch('/:id', requireAuth, async (req, res) => { try { const userId = req.params.id; - const { first_name, last_name, preferred_language, is_blocked } = req.body; + const { first_name, last_name, name, preferred_language, language, is_blocked } = req.body; const fields = []; const values = []; let idx = 1; - if (first_name !== undefined) { fields.push(`first_name = $${idx++}`); values.push(first_name); } - if (last_name !== undefined) { fields.push(`last_name = $${idx++}`); values.push(last_name); } - if (preferred_language !== undefined) { fields.push(`preferred_language = $${idx++}`); values.push(JSON.stringify(preferred_language)); } + + // Получаем ключ шифрования один раз + const fs = require('fs'); + const path = require('path'); + let encryptionKey = 'default-key'; + try { + const keyPath = '/app/ssl/keys/full_db_encryption.key'; + if (fs.existsSync(keyPath)) { + encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); + console.log('Encryption key loaded:', encryptionKey.length, 'characters'); + } + } catch (keyError) { + console.error('Error reading encryption key:', keyError); + } + + // Обработка поля name - разбиваем на first_name и last_name + if (name !== undefined) { + const nameParts = name.trim().split(' '); + const firstName = nameParts[0] || ''; + const lastName = nameParts.slice(1).join(' ') || ''; + fields.push(`first_name_encrypted = encrypt_text($${idx++}, $${idx++})`); + values.push(firstName); + values.push(encryptionKey); + fields.push(`last_name_encrypted = encrypt_text($${idx++}, $${idx++})`); + values.push(lastName); + values.push(encryptionKey); + } else { + if (first_name !== undefined) { + fields.push(`first_name_encrypted = encrypt_text($${idx++}, $${idx++})`); + values.push(first_name); + values.push(encryptionKey); + } + if (last_name !== undefined) { + fields.push(`last_name_encrypted = encrypt_text($${idx++}, $${idx++})`); + values.push(last_name); + values.push(encryptionKey); + } + } + + // Обработка поля language (alias для preferred_language) + const languageToUpdate = language !== undefined ? language : preferred_language; + if (languageToUpdate !== undefined) { + fields.push(`preferred_language = $${idx++}`); + values.push(JSON.stringify(languageToUpdate)); + } if (is_blocked !== undefined) { fields.push(`is_blocked = $${idx++}`); values.push(is_blocked); @@ -318,6 +372,7 @@ router.patch('/:id', requireAuth, async (req, res) => { const sql = `UPDATE users SET ${fields.join(', ')} WHERE id = $${idx}`; values.push(userId); await db.query(sql, values); + broadcastContactsUpdate(); res.json({ success: true, message: 'Пользователь обновлен' }); } catch (e) { logger.error('Ошибка обновления пользователя:', e); @@ -326,7 +381,7 @@ router.patch('/:id', requireAuth, async (req, res) => { }); // DELETE /api/users/:id — удалить контакт и все связанные данные -router.delete('/:id', async (req, res) => { +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); @@ -354,7 +409,7 @@ router.get('/:id', async (req, res, next) => { let encryptionKey = 'default-key'; try { - const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key'); + const keyPath = '/app/ssl/keys/full_db_encryption.key'; if (fs.existsSync(keyPath)) { encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); } @@ -401,7 +456,7 @@ router.post('/', async (req, res) => { let encryptionKey = 'default-key'; try { - const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key'); + const keyPath = '/app/ssl/keys/full_db_encryption.key'; if (fs.existsSync(keyPath)) { encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); } @@ -430,7 +485,7 @@ router.post('/import', requireAuth, async (req, res) => { let encryptionKey = 'default-key'; try { - const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key'); + const keyPath = '/app/ssl/keys/full_db_encryption.key'; if (fs.existsSync(keyPath)) { encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); } @@ -525,9 +580,9 @@ router.patch('/:id/tags', async (req, res) => { ); } - // Отправляем WebSocket уведомление об обновлении тегов - ОТКЛЮЧАЕМ - // const { broadcastTagsUpdate } = require('../wsHub'); - // broadcastTagsUpdate(); + // Отправляем WebSocket уведомление об обновлении тегов + const { broadcastTagsUpdate } = require('../wsHub'); + broadcastTagsUpdate(); res.json({ success: true }); } catch (e) { @@ -559,9 +614,9 @@ router.delete('/:id/tags/:tagId', async (req, res) => { [userId, tagId] ); - // Отправляем WebSocket уведомление об обновлении тегов - ОТКЛЮЧАЕМ - // const { broadcastTagsUpdate } = require('../wsHub'); - // broadcastTagsUpdate(); + // Отправляем WebSocket уведомление об обновлении тегов + const { broadcastTagsUpdate } = require('../wsHub'); + broadcastTagsUpdate(); res.json({ success: true }); } catch (e) { diff --git a/backend/scripts/warmup-model.js b/backend/scripts/warmup-model.js new file mode 100644 index 0000000..20c90ec --- /dev/null +++ b/backend/scripts/warmup-model.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +/** + * Скрипт для разогрева модели Ollama + * Запускается при старте backend для ускорения первых запросов + */ + +const fetch = require('node-fetch'); + +const OLLAMA_URL = process.env.OLLAMA_URL || 'http://ollama:11434'; +const MODEL_NAME = process.env.OLLAMA_MODEL || 'qwen2.5:7b'; + +async function warmupModel() { + console.log('🔥 Разогрев модели Ollama...'); + + try { + // Проверяем доступность Ollama + const healthResponse = await fetch(`${OLLAMA_URL}/api/tags`); + if (!healthResponse.ok) { + throw new Error(`Ollama недоступен: ${healthResponse.status}`); + } + + console.log('✅ Ollama доступен'); + + // Отправляем простой запрос для разогрева + const warmupResponse = await fetch(`${OLLAMA_URL}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: MODEL_NAME, + messages: [ + { role: 'system', content: 'Ты полезный ассистент.' }, + { role: 'user', content: 'Привет! Как дела?' } + ], + stream: false, + options: { + temperature: 0.3, + num_predict: 50, + num_ctx: 512, + num_thread: 8, + num_gpu: 1, + num_gqa: 8, + rope_freq_base: 1000000, + rope_freq_scale: 0.5, + repeat_penalty: 1.1, + top_k: 40, + top_p: 0.9, + }, + }), + }); + + if (!warmupResponse.ok) { + throw new Error(`Ошибка разогрева: ${warmupResponse.status}`); + } + + const data = await warmupResponse.json(); + console.log('✅ Модель разогрета успешно'); + console.log(`📝 Ответ модели: ${data.choices?.[0]?.message?.content?.substring(0, 100)}...`); + + } catch (error) { + console.error('❌ Ошибка разогрева модели:', error.message); + // Не прерываем запуск приложения + } +} + +// Запускаем разогрев с задержкой +setTimeout(warmupModel, 5000); + +module.exports = { warmupModel }; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 0de4871..57c39f6 100644 --- a/backend/server.js +++ b/backend/server.js @@ -18,6 +18,7 @@ const logger = require('./utils/logger'); const { getBot } = require('./services/telegramBot'); const EmailBotService = require('./services/emailBot'); const { initDbPool, seedAIAssistantSettings } = require('./db'); +const { warmupModel } = require('./scripts/warmup-model'); // Добавляем импорт разогрева модели const PORT = process.env.PORT || 8000; @@ -64,6 +65,15 @@ initWSS(server); async function startServer() { await initDbPool(); // Дождаться пересоздания пула! await seedAIAssistantSettings(); // Инициализация ассистента после загрузки модели Ollama + + // Разогрев модели Ollama + console.log('🔥 Запуск разогрева модели...'); + setTimeout(() => { + warmupModel().catch(err => { + console.error('❌ Ошибка разогрева модели:', err.message); + }); + }, 10000); // Задержка 10 секунд для полной инициализации + await initServices(); // Только теперь запускать сервисы console.log(`Server is running on port ${PORT}`); } diff --git a/backend/services/ai-assistant.js b/backend/services/ai-assistant.js index d3d4431..a0cec11 100644 --- a/backend/services/ai-assistant.js +++ b/backend/services/ai-assistant.js @@ -17,6 +17,8 @@ const { HNSWLib } = require('@langchain/community/vectorstores/hnswlib'); const { OpenAIEmbeddings } = require('@langchain/openai'); const logger = require('../utils/logger'); const fetch = require('node-fetch'); +const aiCache = require('./ai-cache'); +const aiQueue = require('./ai-queue'); // Простой кэш для ответов const responseCache = new Map(); @@ -105,6 +107,38 @@ class AIAssistant { return cyrillicPattern.test(message) ? 'ru' : 'en'; } + // Определение приоритета запроса + getRequestPriority(message, history, rules) { + let priority = 0; + + // Высокий приоритет для коротких запросов + if (message.length < 50) { + priority += 10; + } + + // Приоритет по типу запроса + const urgentKeywords = ['срочно', 'urgent', 'важно', 'important', 'помоги', 'help']; + if (urgentKeywords.some(keyword => message.toLowerCase().includes(keyword))) { + priority += 20; + } + + // Приоритет для администраторов + if (rules && rules.isAdmin) { + priority += 15; + } + + // Приоритет по времени ожидания (если есть история) + if (history && history.length > 0) { + const lastMessage = history[history.length - 1]; + const timeDiff = Date.now() - (lastMessage.timestamp || Date.now()); + if (timeDiff > 30000) { // Более 30 секунд ожидания + priority += 5; + } + } + + return priority; + } + // Основной метод для получения ответа async getResponse(message, language = 'auto', history = null, systemPrompt = '', rules = null) { try { @@ -120,14 +154,57 @@ class AIAssistant { return 'Извините, модель временно недоступна. Пожалуйста, попробуйте позже.'; } - // Создаем ключ кэша - const cacheKey = JSON.stringify({ message, language, systemPrompt, rules }); - const cached = responseCache.get(cacheKey); - if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) { + // Проверяем кэш + const cacheKey = aiCache.generateKey([{ role: 'user', content: message }], { + temperature: 0.3, + maxTokens: 150 + }); + const cachedResponse = aiCache.get(cacheKey); + if (cachedResponse) { console.log('Returning cached response'); - return cached.response; + return cachedResponse; } + // Определяем приоритет запроса + const priority = this.getRequestPriority(message, history, rules); + + // Добавляем запрос в очередь + const requestId = await aiQueue.addRequest({ + message, + language, + history, + systemPrompt, + rules + }, priority); + + // Ждем результат из очереди + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Request timeout - очередь перегружена')); + }, 60000); // 60 секунд таймаут для очереди + + const onCompleted = (item) => { + if (item.id === requestId) { + clearTimeout(timeout); + aiQueue.off('completed', onCompleted); + aiQueue.off('failed', onFailed); + resolve(item.result); + } + }; + + const onFailed = (item) => { + if (item.id === requestId) { + clearTimeout(timeout); + aiQueue.off('completed', onCompleted); + aiQueue.off('failed', onFailed); + reject(new Error(item.error)); + } + }; + + aiQueue.on('completed', onCompleted); + aiQueue.on('failed', onFailed); + }); + // Определяем язык, если не указан явно const detectedLanguage = language === 'auto' ? this.detectLanguage(message) : language; console.log('Detected language:', detectedLanguage); @@ -179,10 +256,7 @@ class AIAssistant { // Кэшируем ответ if (response) { - responseCache.set(cacheKey, { - response, - timestamp: Date.now() - }); + aiCache.set(cacheKey, response); } return response; @@ -200,7 +274,7 @@ class AIAssistant { // Создаем AbortController для таймаута const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 60000); // Увеличиваем до 60 секунд + const timeoutId = setTimeout(() => controller.abort(), 120000); // Увеличиваем до 120 секунд const response = await fetch(`${this.baseUrl}/v1/chat/completions`, { method: 'POST', @@ -211,16 +285,19 @@ class AIAssistant { stream: false, options: { temperature: 0.3, - num_predict: 200, // Уменьшаем максимальную длину ответа - num_ctx: 1024, // Уменьшаем контекст для экономии памяти - num_thread: 8, // Увеличиваем количество потоков + num_predict: 150, // Уменьшаем максимальную длину ответа для ускорения + num_ctx: 512, // Уменьшаем контекст для экономии памяти и ускорения + num_thread: 12, // Увеличиваем количество потоков для ускорения num_gpu: 1, // Используем GPU если доступен num_gqa: 8, // Оптимизация для qwen2.5 rope_freq_base: 1000000, // Оптимизация для qwen2.5 rope_freq_scale: 0.5, // Оптимизация для qwen2.5 repeat_penalty: 1.1, // Добавляем штраф за повторения - top_k: 40, // Ограничиваем выбор токенов - top_p: 0.9, // Используем nucleus sampling + top_k: 20, // Уменьшаем выбор токенов для ускорения + top_p: 0.8, // Уменьшаем nucleus sampling для ускорения + mirostat: 2, // Используем mirostat для стабильности + mirostat_tau: 5.0, // Настройка mirostat + mirostat_eta: 0.1, // Настройка mirostat }, }), signal: controller.signal, @@ -240,7 +317,7 @@ class AIAssistant { } catch (error) { console.error('Error in fallbackRequestOpenAI:', error); if (error.name === 'AbortError') { - throw new Error('Request timeout - модель не ответила в течение 60 секунд'); + throw new Error('Request timeout - модель не ответила в течение 120 секунд'); } throw error; } diff --git a/backend/services/ai-cache.js b/backend/services/ai-cache.js new file mode 100644 index 0000000..3e9352c --- /dev/null +++ b/backend/services/ai-cache.js @@ -0,0 +1,77 @@ +/** + * Кэширование AI ответов для ускорения работы + */ + +const crypto = require('crypto'); +const logger = require('../utils/logger'); + +class AICache { + constructor() { + this.cache = new Map(); + this.maxSize = 1000; // Максимальное количество кэшированных ответов + this.ttl = 24 * 60 * 60 * 1000; // 24 часа в миллисекундах + } + + // Генерация ключа кэша на основе запроса + generateKey(messages, options = {}) { + const content = JSON.stringify({ + messages: messages.map(m => ({ role: m.role, content: m.content })), + temperature: options.temperature || 0.3, + maxTokens: options.num_predict || 150 + }); + return crypto.createHash('md5').update(content).digest('hex'); + } + + // Получение ответа из кэша + get(key) { + const cached = this.cache.get(key); + if (!cached) return null; + + // Проверяем TTL + if (Date.now() - cached.timestamp > this.ttl) { + this.cache.delete(key); + return null; + } + + logger.info(`[AICache] Cache hit for key: ${key.substring(0, 8)}...`); + return cached.response; + } + + // Сохранение ответа в кэш + set(key, response) { + // Очищаем старые записи если кэш переполнен + if (this.cache.size >= this.maxSize) { + const oldestKey = this.cache.keys().next().value; + this.cache.delete(oldestKey); + } + + this.cache.set(key, { + response, + timestamp: Date.now() + }); + + logger.info(`[AICache] Cached response for key: ${key.substring(0, 8)}...`); + } + + // Очистка кэша + clear() { + this.cache.clear(); + logger.info('[AICache] Cache cleared'); + } + + // Статистика кэша + getStats() { + return { + size: this.cache.size, + maxSize: this.maxSize, + hitRate: this.calculateHitRate() + }; + } + + calculateHitRate() { + // Простая реализация - в реальности нужно отслеживать hits/misses + return this.cache.size / this.maxSize; + } +} + +module.exports = new AICache(); \ No newline at end of file diff --git a/backend/services/ai-queue.js b/backend/services/ai-queue.js index 3697ac4..4dfe0ff 100644 --- a/backend/services/ai-queue.js +++ b/backend/services/ai-queue.js @@ -1,377 +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 + * Очередь для AI запросов с приоритизацией */ -const Queue = require('better-queue'); +const EventEmitter = require('events'); const logger = require('../utils/logger'); -class AIQueueService { +class AIQueue extends EventEmitter { constructor() { - this.queue = null; - this.isInitialized = false; - this.userRequestTimes = new Map(); // Добавляем Map для отслеживания запросов пользователей + super(); + this.queue = []; + this.processing = false; + this.maxConcurrent = 2; // Максимум 2 запроса одновременно + this.activeRequests = 0; this.stats = { - totalProcessed: 0, - totalFailed: 0, - averageProcessingTime: 0, - currentQueueSize: 0, - lastProcessedAt: null + total: 0, + completed: 0, + failed: 0, + avgResponseTime: 0 }; - - this.initQueue(); } - initQueue() { - try { - this.queue = new Queue(this.processTask.bind(this), { - // Ограничиваем количество одновременных запросов к Ollama - concurrent: 2, - - // Максимальное время выполнения задачи - maxTimeout: 180000, // 3 минуты - - // Задержка между задачами для предотвращения перегрузки - afterProcessDelay: 1000, // 1 секунда - - // Максимальное количество повторных попыток - maxRetries: 2, - - // Задержка между повторными попытками - retryDelay: 5000, // 5 секунд - - // Функция определения приоритета - priority: this.getTaskPriority.bind(this), - - // Функция фильтрации задач - filter: this.filterTask.bind(this), - - // Функция слияния одинаковых задач - merge: this.mergeTasks.bind(this), - - // ID задачи для предотвращения дублирования - id: 'requestId' - }); - - this.setupEventListeners(); - this.isInitialized = true; - - logger.info('[AIQueue] Queue initialized successfully'); - } catch (error) { - logger.error('[AIQueue] Failed to initialize queue:', error); - this.isInitialized = false; - } - } - - // Определение приоритета задачи - getTaskPriority(task, cb) { - try { - let priority = 1; // Базовый приоритет - - // Высокий приоритет для администраторов - if (task.userRole === 'admin') { - priority += 10; - } - - // Приоритет по типу запроса - switch (task.type) { - case 'urgent': - priority += 20; - break; - case 'chat': - priority += 5; - break; - case 'analysis': - priority += 3; - break; - case 'generation': - priority += 1; - break; - } - - // Приоритет по размеру запроса (короткие запросы имеют больший приоритет) - if (task.message && task.message.length < 100) { - priority += 2; - } - - // Приоритет по времени ожидания - const waitTime = Date.now() - task.timestamp; - if (waitTime > 30000) { // Более 30 секунд ожидания - priority += 5; - } - - cb(null, priority); - } catch (error) { - cb(error, 1); - } - } - - // Фильтрация задач - filterTask(task, cb) { - try { - // Проверяем обязательные поля - if (!task.message || typeof task.message !== 'string') { - return cb('Invalid message format'); - } - - if (!task.requestId) { - return cb('Missing request ID'); - } - - // Проверяем размер сообщения - if (task.message.length > 10000) { - return cb('Message too long (max 10000 characters)'); - } - - // Проверяем частоту запросов от пользователя - if (this.isUserRateLimited(task.userId)) { - return cb('User rate limit exceeded'); - } - - cb(null, task); - } catch (error) { - cb(error); - } - } - - // Слияние одинаковых задач - mergeTasks(oldTask, newTask, cb) { - try { - // Если это тот же запрос от того же пользователя, обновляем метаданные - if (oldTask.message === newTask.message && oldTask.userId === newTask.userId) { - oldTask.timestamp = newTask.timestamp; - oldTask.retryCount = (oldTask.retryCount || 0) + 1; - cb(null, oldTask); - } else { - cb(null, newTask); - } - } catch (error) { - cb(error); - } - } - - // Обработка задачи - async processTask(task, cb) { - const startTime = Date.now(); - const taskId = task.requestId; - - try { - logger.info(`[AIQueue] Processing task ${taskId} for user ${task.userId}`); - - // Импортируем AI сервис - const aiAssistant = require('./ai-assistant'); - const encryptedDb = require('./encryptedDatabaseService'); - - // Выполняем AI запрос - const result = await aiAssistant.getResponse( - task.message, - task.language || 'auto', - task.history || null, - task.systemPrompt || '', - task.rules || null - ); - - const processingTime = Date.now() - startTime; - - // Сохраняем AI ответ в базу данных - if (task.conversationId && result) { - try { - const aiMessage = await encryptedDb.saveData('messages', { - conversation_id: task.conversationId, - user_id: task.userId, - content: result, - sender_type: 'assistant', - role: 'assistant', - channel: 'web' - }); - - // Получаем расшифрованные данные для WebSocket - const decryptedAiMessage = await encryptedDb.getData('messages', { id: aiMessage.id }, 1); - if (decryptedAiMessage && decryptedAiMessage[0]) { - // Отправляем сообщение через WebSocket - const { broadcastChatMessage } = require('../wsHub'); - broadcastChatMessage(decryptedAiMessage[0], task.userId); - } - - logger.info(`[AIQueue] AI response saved for conversation ${task.conversationId}`); - } catch (dbError) { - logger.error(`[AIQueue] Error saving AI response:`, dbError); - } - } - - // Обновляем статистику - this.updateStats(true, processingTime); - - logger.info(`[AIQueue] Task ${taskId} completed in ${processingTime}ms`); - - cb(null, { - success: true, - result, - processingTime, - taskId - }); - - } catch (error) { - const processingTime = Date.now() - startTime; - - // Обновляем статистику - this.updateStats(false, processingTime); - - logger.error(`[AIQueue] Task ${taskId} failed:`, error); - - cb(null, { - success: false, - error: error.message, - processingTime, - taskId - }); - } - } - - // Добавление задачи в очередь - addTask(taskData) { - if (!this.isInitialized || !this.queue) { - throw new Error('Queue is not initialized'); - } - - const task = { - ...taskData, + // Добавление запроса в очередь + async addRequest(request, priority = 0) { + const queueItem = { + id: Date.now() + Math.random(), + request, + priority, timestamp: Date.now(), - requestId: taskData.requestId || `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + status: 'pending' }; - return new Promise((resolve, reject) => { - const ticket = this.queue.push(task, (error, result) => { - if (error) { - reject(error); - } else { - resolve(result); + this.queue.push(queueItem); + this.queue.sort((a, b) => b.priority - a.priority); // Сортировка по приоритету + + this.stats.total++; + logger.info(`[AIQueue] Added request ${queueItem.id} with priority ${priority}`); + + // Запускаем обработку если не запущена + if (!this.processing) { + this.processQueue(); + } + + return queueItem.id; + } + + // Обработка очереди + async processQueue() { + if (this.processing || this.activeRequests >= this.maxConcurrent) { + return; + } + + this.processing = true; + + while (this.queue.length > 0 && this.activeRequests < this.maxConcurrent) { + const item = this.queue.shift(); + if (!item) continue; + + this.activeRequests++; + item.status = 'processing'; + + try { + const startTime = Date.now(); + const result = await this.processRequest(item.request); + const responseTime = Date.now() - startTime; + + item.status = 'completed'; + item.result = result; + item.responseTime = responseTime; + + this.stats.completed++; + this.updateAvgResponseTime(responseTime); + + logger.info(`[AIQueue] Request ${item.id} completed in ${responseTime}ms`); + + // Эмитим событие о завершении + this.emit('completed', item); + + } catch (error) { + item.status = 'failed'; + item.error = error.message; + + this.stats.failed++; + logger.error(`[AIQueue] Request ${item.id} failed:`, error.message); + + // Эмитим событие об ошибке + this.emit('failed', item); + } finally { + this.activeRequests--; + } + } + + this.processing = false; + + // Если в очереди еще есть запросы, продолжаем обработку + if (this.queue.length > 0) { + setTimeout(() => this.processQueue(), 100); + } + } + + // Обработка одного запроса + async processRequest(request) { + // Прямой вызов AI без очереди + const aiAssistant = require('./ai-assistant'); + + // Используем прямой метод без очереди + const messages = []; + if (request.systemPrompt) { + messages.push({ role: 'system', content: request.systemPrompt }); + } + if (request.history && Array.isArray(request.history)) { + for (const msg of request.history) { + if (msg.role && msg.content) { + messages.push({ role: msg.role, content: msg.content }); } - }); - - // Добавляем обработчики событий для билета - ticket.on('failed', (error) => { - logger.error(`[AIQueue] Task ${task.requestId} failed:`, error); - reject(error); - }); - - ticket.on('finish', (result) => { - logger.info(`[AIQueue] Task ${task.requestId} finished`); - resolve(result); - }); - }); - } - - // Настройка обработчиков событий очереди - setupEventListeners() { - this.queue.on('task_queued', (taskId) => { - logger.info(`[AIQueue] Task ${taskId} queued`); - this.stats.currentQueueSize = this.queue.length; - }); - - this.queue.on('task_started', (taskId) => { - logger.info(`[AIQueue] Task ${taskId} started`); - }); - - this.queue.on('task_finish', (taskId, result) => { - logger.info(`[AIQueue] Task ${taskId} finished successfully`); - this.stats.lastProcessedAt = new Date(); - this.stats.currentQueueSize = this.queue.length; - }); - - this.queue.on('task_failed', (taskId, error) => { - logger.error(`[AIQueue] Task ${taskId} failed:`, error); - this.stats.currentQueueSize = this.queue.length; - }); - - this.queue.on('empty', () => { - logger.info('[AIQueue] Queue is empty'); - this.stats.currentQueueSize = 0; - }); - - this.queue.on('drain', () => { - logger.info('[AIQueue] Queue drained'); - this.stats.currentQueueSize = 0; - }); - } - - // Обновление статистики - updateStats(success, processingTime) { - this.stats.totalProcessed++; - if (!success) { - this.stats.totalFailed++; + } } - - // Обновляем среднее время обработки - const totalTime = this.stats.averageProcessingTime * (this.stats.totalProcessed - 1) + processingTime; - this.stats.averageProcessingTime = totalTime / this.stats.totalProcessed; + messages.push({ role: 'user', content: request.message }); + + // Прямой вызов API без очереди + return await aiAssistant.fallbackRequestOpenAI(messages, request.language, request.systemPrompt); } - // Проверка ограничения частоты запросов пользователя - isUserRateLimited(userId) { - // Простая реализация - можно улучшить с использованием Redis - const now = Date.now(); - const userRequests = this.userRequestTimes.get(userId) || []; - - // Удаляем старые запросы (старше 1 минуты) - const recentRequests = userRequests.filter(time => now - time < 60000); - - // Ограничиваем до 10 запросов в минуту - if (recentRequests.length >= 10) { - return true; - } - - // Добавляем текущий запрос - recentRequests.push(now); - this.userRequestTimes.set(userId, recentRequests); - - return false; + // Обновление средней скорости ответа + updateAvgResponseTime(responseTime) { + const total = this.stats.completed; + this.stats.avgResponseTime = + (this.stats.avgResponseTime * (total - 1) + responseTime) / total; } - // Получение статистики очереди + // Получение статистики getStats() { - const queueStats = this.queue ? this.queue.getStats() : {}; - return { ...this.stats, - queueStats, - isInitialized: this.isInitialized, - currentQueueSize: this.queue ? this.queue.length : 0, - runningTasks: this.queue ? this.queue.running : 0 + queueLength: this.queue.length, + activeRequests: this.activeRequests, + processing: this.processing }; } // Очистка очереди clear() { - if (this.queue) { - this.queue.destroy(); - this.initQueue(); - } - } - - // Пауза/возобновление очереди - pause() { - if (this.queue) { - this.queue.pause(); - logger.info('[AIQueue] Queue paused'); - } - } - - resume() { - if (this.queue) { - this.queue.resume(); - logger.info('[AIQueue] Queue resumed'); - } + this.queue = []; + logger.info('[AIQueue] Queue cleared'); } } -// Создаем и экспортируем единственный экземпляр -const aiQueueService = new AIQueueService(); -module.exports = aiQueueService; \ No newline at end of file +module.exports = new AIQueue(); \ No newline at end of file diff --git a/backend/services/encryptedDatabaseService.js b/backend/services/encryptedDatabaseService.js index f3e6ca4..def2c96 100644 --- a/backend/services/encryptedDatabaseService.js +++ b/backend/services/encryptedDatabaseService.js @@ -349,8 +349,8 @@ class EncryptedDataService { params.push(...Object.values(conditions)); } - const { rows } = await db.getQuery()(query, params); - return rows; + const result = await db.getQuery()(query, params); + return result.rows; } catch (error) { console.error(`❌ Ошибка удаления данных из ${tableName}:`, error); throw error; diff --git a/backend/services/telegramBot.js b/backend/services/telegramBot.js index e2370d8..b8dfd79 100644 --- a/backend/services/telegramBot.js +++ b/backend/services/telegramBot.js @@ -454,7 +454,7 @@ async function getBot() { const aiResponse = await Promise.race([ aiResponsePromise, new Promise((_, reject) => - setTimeout(() => reject(new Error('AI response timeout')), 60000) + setTimeout(() => reject(new Error('AI response timeout')), 120000) ) ]); @@ -494,7 +494,7 @@ async function getBot() { // Запускаем бота с таймаутом const launchPromise = botInstance.launch(); const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Telegram bot launch timeout')), 10000); // 10 секунд таймаут + setTimeout(() => reject(new Error('Telegram bot launch timeout')), 30000); // 30 секунд таймаут }); await Promise.race([launchPromise, timeoutPromise]); diff --git a/backend/wsHub.js b/backend/wsHub.js index 6d74667..dd6965b 100644 --- a/backend/wsHub.js +++ b/backend/wsHub.js @@ -25,6 +25,9 @@ function initWSS(server) { wss.on('connection', (ws, req) => { console.log('🔌 [WebSocket] Новое подключение'); + console.log('🔌 [WebSocket] IP клиента:', req.socket.remoteAddress); + console.log('🔌 [WebSocket] User-Agent:', req.headers['user-agent']); + console.log('🔌 [WebSocket] Origin:', req.headers.origin); // Добавляем клиента в общий список if (!wsClients.has('anonymous')) { @@ -42,13 +45,21 @@ function initWSS(server) { // Аутентификация пользователя authenticateUser(ws, data.userId); } + + if (data.type === 'ping') { + // Отправляем pong ответ + ws.send(JSON.stringify({ + type: 'pong', + timestamp: data.timestamp + })); + } } catch (error) { console.error('❌ [WebSocket] Ошибка парсинга сообщения:', error); } }); - ws.on('close', () => { - console.log('🔌 [WebSocket] Соединение закрыто'); + ws.on('close', (code, reason) => { + console.log('🔌 [WebSocket] Соединение закрыто', { code, reason: reason.toString() }); // Удаляем клиента из всех списков for (const [userId, clients] of wsClients.entries()) { clients.delete(ws); @@ -59,7 +70,7 @@ function initWSS(server) { }); ws.on('error', (error) => { - console.error('❌ [WebSocket] Ошибка соединения:', error); + console.error('❌ [WebSocket] Ошибка соединения:', error.message); }); }); @@ -224,46 +235,19 @@ function broadcastTableRelationsUpdate(tableId, rowId, targetUserId = null) { } function broadcastTagsUpdate(targetUserId = null) { - const now = Date.now(); - const cacheKey = targetUserId || 'global'; - - // Проверяем, не отправляли ли мы недавно уведомление - const lastUpdate = tagsChangeCache.get(cacheKey); - if (lastUpdate && (now - lastUpdate) < TAGS_CACHE_TTL) { - console.log(`🏷️ [WebSocket] Пропускаем отправку уведомления о тегах (слишком часто)`, { targetUserId }); - return; - } - - // Обновляем кэш - tagsChangeCache.set(cacheKey, now); - - console.log(`🏷️ [WebSocket] Отправка обновления тегов`, { targetUserId }); - - const payload = { + console.log('🔔 [WebSocket] Отправляем уведомление об обновлении тегов'); + const message = JSON.stringify({ type: 'tags-updated', - timestamp: now - }; + timestamp: Date.now() + }); - if (targetUserId) { - // Отправляем конкретному пользователю - const userClients = wsClients.get(targetUserId.toString()); - if (userClients) { - for (const ws of userClients) { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(payload)); - } - } + // Отправляем всем подключенным клиентам + wss.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + console.log('🔔 [WebSocket] Отправляем tags-updated клиенту'); + client.send(message); } - } else { - // Отправляем всем - for (const [userId, clients] of wsClients.entries()) { - for (const ws of clients) { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(payload)); - } - } - } - } + }); } function getConnectedUsers() { @@ -298,6 +282,21 @@ function getStats() { }; } +// Функция для отправки уведомлений о статусе AI +function broadcastAIStatus(status) { + console.log('📢 [WebSocket] Отправка статуса AI всем клиентам'); + for (const [userId, clients] of wsClients.entries()) { + for (const ws of clients) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'ai-status', + status + })); + } + } + } +} + module.exports = { initWSS, broadcastContactsUpdate, @@ -307,6 +306,7 @@ module.exports = { broadcastTableUpdate, broadcastTableRelationsUpdate, broadcastTagsUpdate, + broadcastAIStatus, getConnectedUsers, getStats }; \ No newline at end of file diff --git a/docker-compose.local.yml b/docker-compose.local.yml deleted file mode 100644 index 1220944..0000000 --- a/docker-compose.local.yml +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright (c) 2024-2025 Тарабанов Александр Викторович -# All rights reserved. -# This software is proprietary and confidential. -# For licensing inquiries: info@hb3-accelerator.com - -volumes: - postgres_data: - external: true - name: dapp-for-business_postgres_data - ollama_data: - external: true - name: dapp-for-business_ollama_data - vector_search_data: - external: true - name: dapp-for-business_vector_search_data - frontend_node_modules: - external: true - name: dapp-for-business_frontend_node_modules \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 993125d..63b1834 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,7 +42,6 @@ services: - ollama_data:/root/.ollama # ports: # - '11434:11434' # Закрываем - используется только backend'ом - command: serve deploy: resources: limits: @@ -58,8 +57,8 @@ services: test: ["CMD", "ollama", "list"] interval: 30s timeout: 10s - retries: 3 - start_period: 60s + retries: 5 + start_period: 120s vector-search: build: context: ./vector-search @@ -143,29 +142,6 @@ services: ports: - '5173:5173' # Закрываем - используем nginx command: yarn run dev -- --host 0.0.0.0 - ollama-setup: - image: curlimages/curl:latest - container_name: dapp-ollama-setup - logging: - driver: "json-file" - options: - max-size: "5m" - max-file: "2" - depends_on: - - ollama - restart: on-failure - command: | - sh -c " - echo 'Waiting for Ollama to be ready...' - until curl -s http://ollama:11434/api/tags >/dev/null; do - sleep 5 - done - echo 'Ollama is ready, pulling qwen2.5-7b model...' - curl -X POST http://ollama:11434/api/pull -d '{\"name\":\"${OLLAMA_MODEL:-qwen2.5:7b}\"}' -H 'Content-Type: application/json' - echo 'Pulling embeddings model...' - curl -X POST http://ollama:11434/api/pull -d '{\"name\":\"${OLLAMA_EMBEDDINGS_MODEL:-mxbai-embed-large:latest}\"}' -H 'Content-Type: application/json' - echo 'Done!' - " ssh-tunnel-frontend: image: alpine:latest container_name: ssh-tunnel-frontend diff --git a/frontend/src/composables/useTablesWebSocket.js b/frontend/src/composables/useTablesWebSocket.js index 165150c..9cf57fb 100644 --- a/frontend/src/composables/useTablesWebSocket.js +++ b/frontend/src/composables/useTablesWebSocket.js @@ -15,20 +15,61 @@ import { ref, onMounted, onUnmounted } from 'vue'; export function useTablesWebSocket() { const ws = ref(null); const isConnected = ref(false); + const isConnecting = ref(false); // Добавляем флаг для предотвращения множественных подключений const tableUpdateCallbacks = ref(new Map()); // tableId -> callback const tableRelationsUpdateCallbacks = ref(new Map()); // `${tableId}-${rowId}` -> callback + const pingInterval = ref(null); // Интервал для ping сообщений function connect() { if (ws.value && ws.value.readyState === WebSocket.OPEN) { + console.log('[TablesWebSocket] Уже подключены, пропускаем'); return; // Уже подключены } - const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; - ws.value = new WebSocket(`${wsProtocol}://${window.location.host}/ws`); + if (isConnecting.value) { + console.log('[TablesWebSocket] Уже пытаемся подключиться, пропускаем'); + return; // Уже пытаемся подключиться + } + + isConnecting.value = true; + + // Определяем правильный URL для WebSocket + let wsUrl; + if (import.meta.env.DEV) { + // В режиме разработки используем прокси через Vite + wsUrl = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ws`; + } else { + // В продакшене используем тот же хост + wsUrl = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ws`; + } + + console.log('[TablesWebSocket] Подключение к:', wsUrl); + console.log('[TablesWebSocket] Текущий хост:', window.location.host); + console.log('[TablesWebSocket] Протокол:', window.location.protocol); + + try { + ws.value = new WebSocket(wsUrl); + } catch (error) { + console.error('[TablesWebSocket] Ошибка создания WebSocket:', error); + isConnecting.value = false; + return; + } ws.value.onopen = () => { console.log('[TablesWebSocket] Соединение установлено'); isConnected.value = true; + isConnecting.value = false; + + // Запускаем ping каждые 30 секунд + pingInterval.value = setInterval(() => { + if (ws.value && ws.value.readyState === WebSocket.OPEN) { + try { + ws.value.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); + } catch (error) { + console.error('[TablesWebSocket] Ошибка отправки ping:', error); + } + } + }, 30000); }; ws.value.onmessage = (event) => { @@ -36,6 +77,12 @@ export function useTablesWebSocket() { const data = JSON.parse(event.data); console.log('[TablesWebSocket] Получено сообщение:', data); + // Обрабатываем pong ответ + if (data.type === 'pong') { + console.log('[TablesWebSocket] Получен pong ответ'); + return; + } + if (data.type === 'table-updated') { const callbacks = tableUpdateCallbacks.value.get(data.tableId); if (callbacks) { @@ -55,20 +102,37 @@ export function useTablesWebSocket() { } }; - ws.value.onclose = () => { - console.log('[TablesWebSocket] Соединение закрыто'); + ws.value.onclose = (event) => { + console.log('[TablesWebSocket] Соединение закрыто', { + code: event.code, + reason: event.reason, + wasClean: event.wasClean + }); isConnected.value = false; - // Переподключение через 3 секунды - setTimeout(() => { - if (!isConnected.value) { - connect(); - } - }, 3000); + isConnecting.value = false; + + // Останавливаем ping интервал + if (pingInterval.value) { + clearInterval(pingInterval.value); + pingInterval.value = null; + } + + // Переподключение только если это не было намеренное закрытие + if (event.code !== 1000) { + setTimeout(() => { + if (!isConnected.value && !isConnecting.value) { + console.log('[TablesWebSocket] Попытка переподключения...'); + connect(); + } + }, 3000); + } }; ws.value.onerror = (error) => { console.error('[TablesWebSocket] Ошибка соединения:', error); + console.error('[TablesWebSocket] WebSocket readyState:', ws.value?.readyState); isConnected.value = false; + isConnecting.value = false; }; } @@ -117,10 +181,20 @@ export function useTablesWebSocket() { function disconnect() { if (ws.value) { - ws.value.close(); + // Останавливаем ping интервал + if (pingInterval.value) { + clearInterval(pingInterval.value); + pingInterval.value = null; + } + + // Корректно закрываем соединение + if (ws.value.readyState === WebSocket.OPEN) { + ws.value.close(1000, 'Manual disconnect'); + } ws.value = null; } isConnected.value = false; + isConnecting.value = false; } onMounted(() => { diff --git a/frontend/src/composables/useTagsWebSocket.js b/frontend/src/composables/useTagsWebSocket.js index a4c96e4..913aaa7 100644 --- a/frontend/src/composables/useTagsWebSocket.js +++ b/frontend/src/composables/useTagsWebSocket.js @@ -33,24 +33,16 @@ export function useTagsWebSocket() { } function handleTagsUpdate(data) { - console.log('🏷️ [useTagsWebSocket] Получено обновление тегов:', data); + console.log('🏷️ [useTagsWebSocket] Получено уведомление об обновлении тегов:', data); - // Очищаем предыдущий таймер - if (debounceTimer) { - clearTimeout(debounceTimer); - } - - // Устанавливаем новый таймер для дебаунсинга - debounceTimer = setTimeout(() => { - console.log('🏷️ [useTagsWebSocket] Выполняем обновление тегов после дебаунсинга'); - tagsUpdateCallbacks.value.forEach(callback => { - try { - callback(data); - } catch (error) { - console.error('Ошибка в callback обновления тегов:', error); - } - }); - }, DEBOUNCE_DELAY); + // Вызываем все зарегистрированные колбэки + tagsUpdateCallbacks.value.forEach(callback => { + try { + callback(data); + } catch (error) { + console.error('🏷️ [useTagsWebSocket] Ошибка в колбэке:', error); + } + }); } onMounted(() => { diff --git a/frontend/src/services/contactsService.js b/frontend/src/services/contactsService.js index 1cd4a5b..c5d8ca3 100644 --- a/frontend/src/services/contactsService.js +++ b/frontend/src/services/contactsService.js @@ -31,6 +31,12 @@ export default { return res.data; } catch (err) { console.error('Ошибка при удалении контакта:', err.response?.status, err.response?.data, err); + + // Если пользователь уже удален (404), считаем это успехом + if (err.response?.status === 404) { + return { success: true, deleted: 0, message: 'Пользователь уже удален' }; + } + throw err; } }, diff --git a/frontend/src/services/dleV2Service.js b/frontend/src/services/dleV2Service.js index d99b970..ca60eb8 100644 --- a/frontend/src/services/dleV2Service.js +++ b/frontend/src/services/dleV2Service.js @@ -10,7 +10,7 @@ * GitHub: https://github.com/HB3-ACCELERATOR */ -import axios from 'axios'; +import api from '@/api/axios'; /** * Сервис для работы с DLE v2 (Digital Legal Entity) @@ -24,7 +24,7 @@ class DLEV2Service { */ async createDLE(dleParams) { try { - const response = await axios.post('/api/dle-v2', dleParams); + const response = await api.post('/dle-v2', dleParams); return response.data; } catch (error) { console.error('Ошибка при создании DLE v2:', error); @@ -38,7 +38,7 @@ class DLEV2Service { */ async getAllDLEs() { try { - const response = await axios.get('/api/dle-v2'); + const response = await api.get('/dle-v2'); return response.data.data || []; } catch (error) { console.error('Ошибка при получении списка DLE v2:', error); @@ -52,7 +52,7 @@ class DLEV2Service { */ async getDefaults() { try { - const response = await axios.get('/api/dle-v2/defaults'); + const response = await api.get('/dle-v2/defaults'); return response.data.data; } catch (error) { console.error('Ошибка при получении настроек по умолчанию DLE v2:', error); @@ -73,7 +73,7 @@ class DLEV2Service { */ async deleteDLE(dleAddress) { try { - const response = await axios.delete(`/api/dle-v2/${dleAddress}`); + const response = await api.delete(`/dle-v2/${dleAddress}`); return response.data; } catch (error) { console.error('Ошибка при удалении DLE v2:', error); diff --git a/frontend/src/services/messagesService.js b/frontend/src/services/messagesService.js index 156b90f..73acc7e 100644 --- a/frontend/src/services/messagesService.js +++ b/frontend/src/services/messagesService.js @@ -10,12 +10,12 @@ * GitHub: https://github.com/HB3-ACCELERATOR */ -import axios from 'axios'; +import api from '@/api/axios'; export default { async getMessagesByUserId(userId) { if (!userId) return []; - const { data } = await axios.get(`/messages?userId=${userId}`); + const { data } = await api.get(`/messages?userId=${userId}`); return data; }, async sendMessage({ conversationId, message, attachments = [], toUserId }) { @@ -26,7 +26,7 @@ export default { attachments.forEach(file => { formData.append('attachments', file); }); - const { data } = await axios.post('/chat/message', formData, { + const { data } = await api.post('/chat/message', formData, { headers: { 'Content-Type': 'multipart/form-data' }, withCredentials: true }); @@ -34,20 +34,20 @@ export default { }, async getMessagesByConversationId(conversationId) { if (!conversationId) return []; - const { data } = await axios.get(`/messages?conversationId=${conversationId}`); + const { data } = await api.get(`/messages?conversationId=${conversationId}`); return data; }, async getConversationByUserId(userId) { if (!userId) return null; - const { data } = await axios.get(`/messages/conversations?userId=${userId}`); + const { data } = await api.get(`/messages/conversations?userId=${userId}`); return data; }, async generateAiDraft(conversationId, messages, language = 'auto') { - const { data } = await axios.post('/chat/ai-draft', { conversationId, messages, language }); + const { data } = await api.post('/chat/ai-draft', { conversationId, messages, language }); return data; }, async broadcastMessage({ userId, message }) { - const { data } = await axios.post('/messages/broadcast', { + const { data } = await api.post('/messages/broadcast', { user_id: userId, content: message }, { @@ -56,7 +56,7 @@ export default { return data; }, async deleteMessagesHistory(userId) { - const { data } = await axios.delete(`/messages/history/${userId}`, { + const { data } = await api.delete(`/messages/history/${userId}`, { withCredentials: true }); return data; @@ -64,6 +64,6 @@ export default { }; export async function getAllMessages() { - const { data } = await axios.get('/messages'); + const { data } = await api.get('/messages'); return data; } \ No newline at end of file diff --git a/frontend/src/services/websocketService.js b/frontend/src/services/websocketService.js index 3ed915a..8d0597a 100644 --- a/frontend/src/services/websocketService.js +++ b/frontend/src/services/websocketService.js @@ -139,7 +139,7 @@ class WebSocketService { break; case 'tags-updated': - console.log('🏷️ [WebSocket] Обновление тегов клиентов'); + console.log('🔔 [websocketService] Получено сообщение tags-updated'); this.emit('tags-updated'); break; diff --git a/frontend/src/views/contacts/ContactDeleteConfirm.vue b/frontend/src/views/contacts/ContactDeleteConfirm.vue index dd51b94..271e202 100644 --- a/frontend/src/views/contacts/ContactDeleteConfirm.vue +++ b/frontend/src/views/contacts/ContactDeleteConfirm.vue @@ -54,8 +54,13 @@ async function loadContact() { isLoading.value = true; try { contact.value = await contactsService.getContactById(route.params.id); + if (!contact.value) { + error.value = 'Контакт не найден'; + } } catch (e) { + console.error('Ошибка загрузки контакта:', e); contact.value = null; + error.value = 'Контакт не найден'; } finally { isLoading.value = false; } @@ -66,9 +71,17 @@ async function deleteContact() { isDeleting.value = true; error.value = ''; try { - await contactsService.deleteContact(contact.value.id); - router.push({ name: 'crm' }); + const result = await contactsService.deleteContact(contact.value.id); + console.log('Результат удаления:', result); + + // Если удаление успешно или пользователь уже удален + if (result.success || result.message === 'Пользователь уже удален') { + router.push({ name: 'contacts-list' }); + } else { + error.value = 'Ошибка при удалении контакта'; + } } catch (e) { + console.error('Ошибка при удалении:', e); error.value = 'Ошибка при удалении контакта'; } finally { isDeleting.value = false; @@ -76,7 +89,7 @@ async function deleteContact() { } function cancelDelete() { - router.push({ name: 'contact-details', params: { id: route.params.id } }); + router.push({ name: 'contacts-list' }); } onMounted(loadContact);