From 261a4ecb2aa9bc8e39e95373907b1497b1341b08 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 24 Jun 2025 16:45:50 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B0=D1=88=D0=B5=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MASKING_AND_ENCRYPTION_PLAN.md | 37 +++ RAG_ASSISTANT_INTEGRATION.md | 92 ++++++ backend/app.js | 2 + backend/routes/chat.js | 6 +- backend/routes/messages.js | 185 ++++++++++- backend/routes/rag.js | 31 ++ backend/routes/tables.js | 13 +- backend/routes/users.js | 128 +++++++- backend/services/emailBot.js | 38 ++- backend/services/ragService.js | 104 ++++++ backend/services/telegramBot.js | 33 +- frontend/src/components/ContactTable.vue | 179 +++++++--- frontend/src/components/tables/TableCell.vue | 129 +++++++- .../src/components/tables/UserTableView.vue | 309 ++++++++++++++++-- frontend/src/services/messagesService.js | 9 + .../src/views/contacts/ContactDetailsView.vue | 63 ++-- frontend/src/views/tables/TablesListView.vue | 6 +- 17 files changed, 1209 insertions(+), 155 deletions(-) create mode 100644 MASKING_AND_ENCRYPTION_PLAN.md create mode 100644 RAG_ASSISTANT_INTEGRATION.md create mode 100644 backend/routes/rag.js create mode 100644 backend/services/ragService.js diff --git a/MASKING_AND_ENCRYPTION_PLAN.md b/MASKING_AND_ENCRYPTION_PLAN.md new file mode 100644 index 0000000..69ae5a6 --- /dev/null +++ b/MASKING_AND_ENCRYPTION_PLAN.md @@ -0,0 +1,37 @@ +# План задач по маскировке и шифрованию данных + +## 1. Анализ данных +- [ ] Определить, какие поля и таблицы содержат чувствительные данные (например, email, ФИО, адрес, сообщения, вложения). +- [ ] Составить список полей для маскировки (для user/guest) и для шифрования (для хранения в БД). + +## 2. Маскировка данных (токенизация) +- [ ] Реализовать функцию маскировки на backend (Node.js): + - [ ] Для ролей user/guest возвращать токены вместо реальных данных (например, `token_`). + - [ ] Для admin возвращать реальные значения. +- [ ] Внедрить маскировку в API-эндпоинты, возвращающие списки пользователей, чатов, сообщений и т.д. +- [ ] Обновить frontend для корректного отображения токенов и подсказок (например, «Данные скрыты, доступ только администратору»). + +## 3. Шифрование данных при хранении +- [ ] Выбрать алгоритм шифрования (например, AES-256-GCM). +- [ ] Реализовать функции шифрования/дешифрования на backend для чувствительных полей. +- [ ] Хранить ключи шифрования только в переменных окружения (не в коде и не в БД). +- [ ] Мигрировать существующие данные: зашифровать чувствительные поля в БД. +- [ ] Обновить логику создания/обновления записей: шифровать данные перед сохранением. +- [ ] Обновить логику чтения: расшифровывать данные для admin, возвращать токены для user/guest. + +## 4. Шифрование файлов и вложений +- [ ] Реализовать шифрование файлов/вложений перед сохранением на диск или в облако. +- [ ] Обеспечить расшифровку файлов только для admin. + +## 5. Безопасное хранение ключей +- [ ] Настроить хранение ключей шифрования в переменных окружения (Akash/Flux Cloud). +- [ ] Обновить Dockerfile и инструкции деплоя для поддержки секретов. + +## 6. Тестирование и аудит +- [ ] Провести тестирование маскировки и шифрования для всех ролей. +- [ ] Проверить, что admin видит реальные данные, user/guest — только токены. +- [ ] Провести аудит безопасности (внешний или внутренний). + +## 7. Документация +- [ ] Описать логику маскировки и шифрования в README или отдельном разделе. +- [ ] Добавить инструкции по настройке переменных окружения и деплою на Akash/Flux Cloud. \ No newline at end of file diff --git a/RAG_ASSISTANT_INTEGRATION.md b/RAG_ASSISTANT_INTEGRATION.md new file mode 100644 index 0000000..baec673 --- /dev/null +++ b/RAG_ASSISTANT_INTEGRATION.md @@ -0,0 +1,92 @@ +# Интеграция RAG-ассистента для бизнеса с поддержкой продуктов, сегментов клиентов и LLM + +## Цель + +Реализовать интеллектуального ассистента для бизнеса, который: +- Использует RAG-таблицы для хранения вопросов, ответов, уточняющих вопросов, ответов на возражения и дополнительного контекста. +- Поддерживает фильтрацию по продуктам, сегментам клиентов (тегам), приоритету, дате и другим бизнес-полям. +- Интегрируется с LLM (Ollama/OpenAI) для генерации финального ответа на основе найденного контекста. +- Позволяет настраивать системный промт с плейсхолдерами для гибкой персонализации ответов. + +--- + +## Основные требования + +1. **Структура RAG-таблицы** + - Каждая строка содержит: + - Вопрос (`question`) + - Ответ (`answer`) + - Ответ с уточняющим вопросом (`clarifyingAnswer`) + - Ответ на возражение (`objectionAnswer`) + - Теги пользователя/сегмента (`userTags`) + - Продукт/услуга (`product`) + - Дополнительный контекст (`context`) + - Приоритет (`priority`) + - Дата (`date`) + - Для каждого столбца указывается назначение (purpose) через выпадающий список при создании/редактировании. + +2. **Фильтрация и поиск** + - При поступлении вопроса пользователя: + - Фильтровать строки по продукту, тегам пользователя, приоритету, дате и другим полям. + - Выполнять векторный поиск (embedding) только по релевантным строкам. + +3. **Интеграция с LLM** + - После поиска по RAG-таблице формировать системный промт с подстановкой найденных данных (через плейсхолдеры). + - Передавать промт и вопрос пользователя в LLM (Ollama/OpenAI). + - Возвращать финальный ответ пользователю. + +4. **Плейсхолдеры для промта** + - Поддерживаются плейсхолдеры: + - `{context}` — дополнительная информация + - `{answer}` — основной ответ + - `{clarifyingAnswer}` — уточняющий вопрос + - `{objectionAnswer}` — ответ на возражение + - `{question}` — вопрос пользователя + - `{userTags}` — теги пользователя + - `{product}` — продукт/услуга + - `{priority}` — приоритет + - `{date}` — дата + - `{rules}` — описание применённых правил + - `{history}` — история диалога + - `{model}` — используемая LLM + - `{language}` — язык ответа + +5. **Кэширование embedding** + - Для ускорения поиска embedding для вопросов кэшируются в БД. + - При изменении вопроса embedding обновляется. + +6. **Логирование и аналитика** + - Логируются все этапы работы ассистента: запрос пользователя, найденный контекст, результат LLM, время ответа, id пользователя и т.д. + - Вся информация сохраняется для последующего анализа и улучшения качества ответов. + +--- + +## Пример бизнес-сценария + +- Клиент B2B интересуется продуктом "ProductX". +- Вопрос: "Как интегрировать ваш продукт с нашей ERP?" +- Система фильтрует строки по `product = "ProductX"` и тегу `B2B`. +- Векторный поиск проводится только по релевантным строкам. +- В системном промте используются плейсхолдеры для подстановки найденных данных. +- LLM генерирует финальный ответ с учётом контекста, уточняющих вопросов и ответов на возражения. + +--- + +## Пример системного промта + +``` +Ты — ассистент компании. Пользователь интересуется продуктом: {product}, сегмент: {userTags}. +Используй только релевантные ответы и контекст для этого продукта и типа клиента. +Контекст: {context} +Ответ: {answer} +Уточняющий вопрос: {clarifyingAnswer} +Ответ на возражение: {objectionAnswer} +``` + +--- + +## Результат + +- Персонализированные, точные и масштабируемые ответы для разных продуктов и сегментов клиентов. +- Гибкая настройка ассистента через UI и системный промт. +- Возможность расширения под любые бизнес-сценарии. \ No newline at end of file diff --git a/backend/app.js b/backend/app.js index 64975db..7aabf51 100644 --- a/backend/app.js +++ b/backend/app.js @@ -15,6 +15,7 @@ const messagesRoutes = require('./routes/messages'); const userTagsRoutes = require('./routes/userTags'); const tagsInitRoutes = require('./routes/tagsInit'); const tagsRoutes = require('./routes/tags'); +const ragRoutes = require('./routes/rag'); // Новый роут для RAG-ассистента // Проверка и создание директорий для хранения данных контрактов const ensureDirectoriesExist = () => { @@ -188,6 +189,7 @@ app.use('/api/messages', messagesRoutes); app.use('/api/tags', tagsInitRoutes); app.use('/api/tags', tagsRoutes); app.use('/api/identities', identitiesRoutes); +app.use('/api/rag', ragRoutes); // Подключаем роут const nonceStore = new Map(); // или любая другая реализация хранилища nonce diff --git a/backend/routes/chat.js b/backend/routes/chat.js index 4cc98a8..fa95f42 100644 --- a/backend/routes/chat.js +++ b/backend/routes/chat.js @@ -351,9 +351,9 @@ router.post('/message', requireAuth, upload.array('attachments'), async (req, re } else { // Обычный пользователь — только в свой диалог convResult = await db.getQuery()( - 'SELECT * FROM conversations WHERE id = $1 AND user_id = $2', - [conversationId, userId] - ); + 'SELECT * FROM conversations WHERE id = $1 AND user_id = $2', + [conversationId, userId] + ); } if (convResult.rows.length === 0) { logger.warn('Conversation not found or access denied', { conversationId, userId }); diff --git a/backend/routes/messages.js b/backend/routes/messages.js index e408093..874e127 100644 --- a/backend/routes/messages.js +++ b/backend/routes/messages.js @@ -2,6 +2,8 @@ const express = require('express'); const router = express.Router(); const db = require('../db'); const { broadcastMessagesUpdate } = require('../wsHub'); +const telegramBot = require('../services/telegramBot'); +const emailBot = new (require('../services/emailBot'))(); // GET /api/messages?userId=123 router.get('/', async (req, res) => { @@ -42,11 +44,100 @@ router.get('/', async (req, res) => { router.post('/', async (req, res) => { const { user_id, sender_type, content, channel, role, direction, attachment_filename, attachment_mimetype, attachment_size, attachment_data, metadata } = req.body; try { - const result = await db.getQuery()( - `INSERT INTO messages (user_id, sender_type, content, channel, role, direction, created_at, attachment_filename, attachment_mimetype, attachment_size, attachment_data, metadata) - VALUES ($1,$2,$3,$4,$5,$6,NOW(),$7,$8,$9,$10,$11) RETURNING *`, - [user_id, sender_type, content, channel, role, direction, attachment_filename, attachment_mimetype, attachment_size, attachment_data, metadata] + // Проверка наличия идентификатора для выбранного канала + if (channel === 'email') { + const emailIdentity = await db.getQuery()( + 'SELECT provider_id FROM user_identities WHERE user_id = $1 AND provider = $2 LIMIT 1', + [user_id, 'email'] + ); + if (emailIdentity.rows.length === 0) { + return res.status(400).json({ error: 'У пользователя не указан email. Сообщение не отправлено.' }); + } + } + if (channel === 'telegram') { + const tgIdentity = await db.getQuery()( + 'SELECT provider_id FROM user_identities WHERE user_id = $1 AND provider = $2 LIMIT 1', + [user_id, 'telegram'] + ); + if (tgIdentity.rows.length === 0) { + return res.status(400).json({ error: 'У пользователя не привязан Telegram. Сообщение не отправлено.' }); + } + } + if (channel === 'wallet' || channel === 'web3' || channel === 'web') { + const walletIdentity = await db.getQuery()( + 'SELECT provider_id FROM user_identities WHERE user_id = $1 AND provider = $2 LIMIT 1', + [user_id, 'wallet'] + ); + if (walletIdentity.rows.length === 0) { + return res.status(400).json({ error: 'У пользователя не привязан кошелёк. Сообщение не отправлено.' }); + } + } + // 1. Проверяем, есть ли беседа для user_id + let conversationResult = await db.getQuery()( + 'SELECT * FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC, created_at DESC LIMIT 1', + [user_id] ); + let conversation; + if (conversationResult.rows.length === 0) { + // 2. Если нет — создаём новую беседу + const title = `Чат с пользователем ${user_id}`; + const newConv = await db.getQuery()( + 'INSERT INTO conversations (user_id, title, created_at, updated_at) VALUES ($1, $2, NOW(), NOW()) RETURNING *', + [user_id, title] + ); + conversation = newConv.rows[0]; + } else { + conversation = conversationResult.rows[0]; + } + // 3. Сохраняем сообщение с conversation_id + const result = await db.getQuery()( + `INSERT INTO messages (user_id, conversation_id, sender_type, content, channel, role, direction, created_at, attachment_filename, attachment_mimetype, attachment_size, attachment_data, metadata) + VALUES ($1,$2,$3,$4,$5,$6,$7,NOW(),$8,$9,$10,$11,$12) RETURNING *`, + [user_id, conversation.id, sender_type, content, channel, role, direction, attachment_filename, attachment_mimetype, attachment_size, attachment_data, metadata] + ); + // 4. Если это исходящее сообщение для Telegram — отправляем через бота + if (channel === 'telegram' && direction === 'out') { + try { + console.log(`[messages.js] Попытка отправки сообщения в Telegram для user_id=${user_id}`); + // Получаем Telegram ID пользователя + const tgIdentity = await db.getQuery()( + 'SELECT provider_id FROM user_identities WHERE user_id = $1 AND provider = $2 LIMIT 1', + [user_id, 'telegram'] + ); + console.log(`[messages.js] Результат поиска Telegram ID:`, tgIdentity.rows); + if (tgIdentity.rows.length > 0) { + const telegramId = tgIdentity.rows[0].provider_id; + console.log(`[messages.js] Отправка сообщения в Telegram ID: ${telegramId}, текст: ${content}`); + const bot = await telegramBot.getBot(); + try { + const sendResult = await bot.telegram.sendMessage(telegramId, content); + console.log(`[messages.js] Результат отправки в Telegram:`, sendResult); + } catch (sendErr) { + console.error(`[messages.js] Ошибка при отправке в Telegram:`, sendErr); + } + } else { + console.warn(`[messages.js] Не найден Telegram ID для user_id=${user_id}`); + } + } catch (err) { + console.error('[messages.js] Ошибка отправки сообщения в Telegram:', err); + } + } + // 5. Если это исходящее сообщение для Email — отправляем email + if (channel === 'email' && direction === 'out') { + try { + // Получаем email пользователя + const emailIdentity = await db.getQuery()( + 'SELECT provider_id FROM user_identities WHERE user_id = $1 AND provider = $2 LIMIT 1', + [user_id, 'email'] + ); + if (emailIdentity.rows.length > 0) { + const email = emailIdentity.rows[0].provider_id; + await emailBot.sendEmail(email, 'Новое сообщение', content); + } + } catch (err) { + console.error('[messages.js] Ошибка отправки email:', err); + } + } broadcastMessagesUpdate(); res.json({ success: true, message: result.rows[0] }); } catch (e) { @@ -123,4 +214,90 @@ router.get('/conversations', async (req, res) => { } }); +// Массовая рассылка сообщения во все каналы пользователя +router.post('/broadcast', async (req, res) => { + const { user_id, content } = req.body; + if (!user_id || !content) { + return res.status(400).json({ error: 'user_id и content обязательны' }); + } + try { + // Получаем все идентификаторы пользователя + const identitiesRes = await db.getQuery()( + 'SELECT provider, provider_id FROM user_identities WHERE user_id = $1', + [user_id] + ); + const identities = identitiesRes.rows; + // --- Найти или создать беседу (conversation) --- + let conversationResult = await db.getQuery()( + 'SELECT * FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC, created_at DESC LIMIT 1', + [user_id] + ); + let conversation; + if (conversationResult.rows.length === 0) { + const title = `Чат с пользователем ${user_id}`; + const newConv = await db.getQuery()( + 'INSERT INTO conversations (user_id, title, created_at, updated_at) VALUES ($1, $2, NOW(), NOW()) RETURNING *', + [user_id, title] + ); + conversation = newConv.rows[0]; + } else { + conversation = conversationResult.rows[0]; + } + const results = []; + let sent = false; + // Email + const email = identities.find(i => i.provider === 'email')?.provider_id; + if (email) { + try { + await emailBot.sendEmail(email, 'Новое сообщение', content); + // Сохраняем в messages с conversation_id + await db.getQuery()( + `INSERT INTO messages (user_id, conversation_id, sender_type, content, channel, role, direction, created_at, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8)`, + [user_id, conversation.id, 'admin', content, 'email', 'user', 'out', JSON.stringify({ broadcast: true })] + ); + results.push({ channel: 'email', status: 'sent' }); + sent = true; + } catch (err) { + results.push({ channel: 'email', status: 'error', error: err.message }); + } + } + // Telegram + const telegram = identities.find(i => i.provider === 'telegram')?.provider_id; + if (telegram) { + try { + const bot = await telegramBot.getBot(); + await bot.telegram.sendMessage(telegram, content); + await db.getQuery()( + `INSERT INTO messages (user_id, conversation_id, sender_type, content, channel, role, direction, created_at, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8)`, + [user_id, conversation.id, 'admin', content, 'telegram', 'user', 'out', JSON.stringify({ broadcast: true })] + ); + results.push({ channel: 'telegram', status: 'sent' }); + sent = true; + } catch (err) { + results.push({ channel: 'telegram', status: 'error', error: err.message }); + } + } + // Wallet/web3 + const wallet = identities.find(i => i.provider === 'wallet')?.provider_id; + if (wallet) { + // Здесь можно реализовать отправку через web3, если нужно + await db.getQuery()( + `INSERT INTO messages (user_id, conversation_id, sender_type, content, channel, role, direction, created_at, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8)`, + [user_id, conversation.id, 'admin', content, 'wallet', 'user', 'out', JSON.stringify({ broadcast: true })] + ); + results.push({ channel: 'wallet', status: 'saved' }); + sent = true; + } + if (!sent) { + return res.status(400).json({ error: 'У пользователя нет ни одного канала для рассылки.' }); + } + res.json({ success: true, results }); + } catch (e) { + res.status(500).json({ error: 'Broadcast error', details: e.message }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/backend/routes/rag.js b/backend/routes/rag.js new file mode 100644 index 0000000..2db8a18 --- /dev/null +++ b/backend/routes/rag.js @@ -0,0 +1,31 @@ +const express = require('express'); +const router = express.Router(); +const { ragAnswer, generateLLMResponse } = require('../services/ragService'); + +router.post('/answer', async (req, res) => { + const { tableId, question, userTags, product, systemPrompt, priority, date, rules, history, model, language } = req.body; + try { + const ragResult = await ragAnswer({ tableId, userQuestion: question, userTags, product }); + const llmResponse = await generateLLMResponse({ + userQuestion: question, + context: ragResult.context, + clarifyingAnswer: ragResult.clarifyingAnswer, + objectionAnswer: ragResult.objectionAnswer, + answer: ragResult.answer, + systemPrompt, + userTags: userTags?.join ? userTags.join(', ') : userTags, + product, + priority: priority || ragResult.priority, + date: date || ragResult.date, + rules, + history, + model, + language + }); + res.json({ ...ragResult, llmResponse }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/tables.js b/backend/routes/tables.js index 225f105..452e309 100644 --- a/backend/routes/tables.js +++ b/backend/routes/tables.js @@ -53,10 +53,19 @@ router.get('/:id', async (req, res, next) => { router.post('/:id/columns', async (req, res, next) => { try { const tableId = req.params.id; - const { name, type, options, order } = req.body; + const { name, type, options, order, tagIds, purpose } = req.body; + let finalOptions = options; + // Собираем options + finalOptions = finalOptions || {}; + if (type === 'tags' && Array.isArray(tagIds)) { + finalOptions.tagIds = tagIds; + } + if (purpose) { + finalOptions.purpose = purpose; + } const result = await db.getQuery()( 'INSERT INTO user_columns (table_id, name, type, options, "order") VALUES ($1, $2, $3, $4, $5) RETURNING *', - [tableId, name, type, options ? JSON.stringify(options) : null, order || 0] + [tableId, name, type, finalOptions ? JSON.stringify(finalOptions) : null, order || 0] ); res.json(result.rows[0]); } catch (err) { diff --git a/backend/routes/users.js b/backend/routes/users.js index 8d222d2..af65af2 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -52,30 +52,126 @@ router.put('/profile', requireAuth, async (req, res) => { }); */ -// Получение списка пользователей с контактами -router.get('/', async (req, res, next) => { +// Получение списка пользователей с фильтрацией +router.get('/', requireAuth, async (req, res, next) => { try { - const usersResult = await db.getQuery()('SELECT id, first_name, last_name, created_at, preferred_language FROM users ORDER BY id'); - const users = usersResult.rows; - // Получаем все user_identities разом - const identitiesResult = await db.getQuery()('SELECT user_id, provider, provider_id FROM user_identities'); - const identities = identitiesResult.rows; - // Группируем идентификаторы по user_id - const identityMap = {}; - for (const id of identities) { - if (!identityMap[id.user_id]) identityMap[id.user_id] = {}; - if (!identityMap[id.user_id][id.provider]) identityMap[id.user_id][id.provider] = id.provider_id; + const { + tagIds = '', + dateFrom = '', + dateTo = '', + contactType = 'all', + search = '', + newMessages = '' + } = req.query; + const adminId = req.user && req.user.id; + + // --- Формируем условия --- + const where = []; + const params = []; + let idx = 1; + + // Фильтр по дате + if (dateFrom) { + where.push(`DATE(u.created_at) >= $${idx++}`); + params.push(dateFrom); } - // Собираем контакты + if (dateTo) { + where.push(`DATE(u.created_at) <= $${idx++}`); + params.push(dateTo); + } + + // Фильтр по типу контакта + if (contactType !== 'all') { + where.push(`EXISTS ( + SELECT 1 FROM user_identities ui + WHERE ui.user_id = u.id AND ui.provider = $${idx++} + )`); + params.push(contactType); + } + + // Фильтр по поиску + 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(ui.provider_id) LIKE $${idx}) + )`); + params.push(`%${search.toLowerCase()}%`); + idx++; + } + + // --- Основной SQL --- + let sql = ` + SELECT u.id, u.first_name, u.last_name, u.created_at, u.preferred_language, + (SELECT provider_id FROM user_identities WHERE user_id = u.id AND provider = 'email' LIMIT 1) AS email, + (SELECT provider_id FROM user_identities WHERE user_id = u.id AND provider = 'telegram' LIMIT 1) AS telegram, + (SELECT provider_id FROM user_identities WHERE user_id = u.id AND provider = 'wallet' LIMIT 1) AS wallet + FROM users u + `; + + // Фильтрация по тегам + if (tagIds) { + const tagIdArr = tagIds.split(',').map(Number).filter(Boolean); + if (tagIdArr.length > 0) { + sql += ` + JOIN user_tags ut ON ut.user_id = u.id + WHERE ut.tag_id = ANY($${idx++}) + GROUP BY u.id + HAVING COUNT(DISTINCT ut.tag_id) = $${idx++} + `; + params.push(tagIdArr); + params.push(tagIdArr.length); + } + } else if (where.length > 0) { + sql += ` WHERE ${where.join(' AND ')} `; + } + + if (!tagIds) { + sql += ' ORDER BY u.id '; + } + + // --- Выполняем запрос --- + const usersResult = await db.getQuery()(sql, params); + let users = usersResult.rows; + + // --- Фильтрация по новым сообщениям --- + if (newMessages === 'yes' && adminId) { + // Получаем время последнего прочтения для каждого пользователя + const readRes = await db.getQuery()( + 'SELECT user_id, last_read_at FROM admin_read_messages WHERE admin_id = $1', + [adminId] + ); + const readMap = {}; + for (const row of readRes.rows) { + readMap[row.user_id] = row.last_read_at; + } + // Получаем последнее сообщение для каждого пользователя + const msgRes = await db.getQuery()( + `SELECT user_id, MAX(created_at) as last_msg_at FROM messages GROUP BY user_id` + ); + const msgMap = {}; + for (const row of msgRes.rows) { + msgMap[row.user_id] = row.last_msg_at; + } + // Оставляем только тех, у кого есть новые сообщения + users = users.filter(u => { + const lastRead = readMap[u.id]; + const lastMsg = msgMap[u.id]; + return lastMsg && (!lastRead || new Date(lastMsg) > new Date(lastRead)); + }); + } + + // --- Формируем ответ --- const contacts = users.map(u => ({ id: u.id, name: [u.first_name, u.last_name].filter(Boolean).join(' ') || null, - email: identityMap[u.id]?.email || null, - telegram: identityMap[u.id]?.telegram || null, - wallet: identityMap[u.id]?.wallet || null, + email: u.email || null, + telegram: u.telegram || null, + wallet: u.wallet || null, created_at: u.created_at, preferred_language: u.preferred_language || [] })); + res.json({ success: true, contacts }); } catch (error) { logger.error('Error fetching contacts:', error); diff --git a/backend/services/emailBot.js b/backend/services/emailBot.js index e1e38e8..ce3e209 100644 --- a/backend/services/emailBot.js +++ b/backend/services/emailBot.js @@ -140,14 +140,30 @@ class EmailBotService { const html = parsed.html || ''; // 1. Найти или создать пользователя const { userId, role } = await identityService.findOrCreateUserWithRole('email', fromEmail); - // 2. Сохранить письмо и вложения в messages + // 1.1 Найти или создать беседу + let conversationResult = await db.getQuery()( + 'SELECT * FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC, created_at DESC LIMIT 1', + [userId] + ); + let conversation; + if (conversationResult.rows.length === 0) { + const title = `Чат с пользователем ${userId}`; + const newConv = await db.getQuery()( + 'INSERT INTO conversations (user_id, title, created_at, updated_at) VALUES ($1, $2, NOW(), NOW()) RETURNING *', + [userId, title] + ); + conversation = newConv.rows[0]; + } else { + conversation = conversationResult.rows[0]; + } + // 2. Сохранять все сообщения с conversation_id let hasAttachments = parsed.attachments && parsed.attachments.length > 0; if (hasAttachments) { for (const att of parsed.attachments) { await db.getQuery()( - `INSERT INTO messages (user_id, sender_type, content, channel, role, direction, created_at, attachment_filename, attachment_mimetype, attachment_size, attachment_data, metadata) - VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9, $10, $11)`, - [userId, 'user', text, 'email', role, 'in', + `INSERT INTO messages (user_id, conversation_id, sender_type, content, channel, role, direction, created_at, attachment_filename, attachment_mimetype, attachment_size, attachment_data, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10, $11, $12)`, + [userId, conversation.id, 'user', text, 'email', role, 'in', att.filename, att.contentType, att.size, @@ -158,18 +174,18 @@ class EmailBotService { } } else { await db.getQuery()( - `INSERT INTO messages (user_id, sender_type, content, channel, role, direction, created_at, metadata) - VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7)`, - [userId, 'user', text, 'email', role, 'in', JSON.stringify({ subject, html })] + `INSERT INTO messages (user_id, conversation_id, sender_type, content, channel, role, direction, created_at, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8)`, + [userId, conversation.id, 'user', text, 'email', role, 'in', JSON.stringify({ subject, html })] ); } // 3. Получить ответ от ИИ const aiResponse = await aiAssistant.getResponse(text, 'auto'); - // 4. Сохранить ответ в БД + // 4. Сохранить ответ в БД с conversation_id await db.getQuery()( - `INSERT INTO messages (user_id, sender_type, content, channel, role, direction, created_at, metadata) - VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7)`, - [userId, 'assistant', aiResponse, 'email', role, 'out', JSON.stringify({ subject, html })] + `INSERT INTO messages (user_id, conversation_id, sender_type, content, channel, role, direction, created_at, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8)`, + [userId, conversation.id, 'assistant', aiResponse, 'email', role, 'out', JSON.stringify({ subject, html })] ); // 5. Отправить ответ на email await this.sendEmail(fromEmail, 'Re: ' + subject, aiResponse); diff --git a/backend/services/ragService.js b/backend/services/ragService.js new file mode 100644 index 0000000..0efa196 --- /dev/null +++ b/backend/services/ragService.js @@ -0,0 +1,104 @@ +const { OpenAIEmbeddings } = require('@langchain/openai'); +const { HNSWLib } = require('@langchain/community/vectorstores/hnswlib'); +const db = require('../db'); +const { ChatOllama } = require('@langchain/ollama'); + +async function getTableData(tableId) { + const columns = (await db.getQuery()('SELECT * FROM user_columns WHERE table_id = $1', [tableId])).rows; + const rows = (await db.getQuery()('SELECT * FROM user_rows WHERE table_id = $1', [tableId])).rows; + const cellValues = (await db.getQuery()('SELECT * FROM user_cell_values WHERE row_id IN (SELECT id FROM user_rows WHERE table_id = $1)', [tableId])).rows; + + const getColId = purpose => columns.find(col => col.options?.purpose === purpose)?.id; + const questionColId = getColId('question'); + const answerColId = getColId('answer'); + const userTagsColId = getColId('userTags'); + const contextColId = getColId('context'); + const productColId = getColId('product'); + const priorityColId = getColId('priority'); + const dateColId = getColId('date'); + + return rows.map(row => { + const cells = cellValues.filter(cell => cell.row_id === row.id); + return { + id: row.id, + question: cells.find(c => c.column_id === questionColId)?.value, + answer: cells.find(c => c.column_id === answerColId)?.value, + userTags: cells.find(c => c.column_id === userTagsColId)?.value, + context: cells.find(c => c.column_id === contextColId)?.value, + product: cells.find(c => c.column_id === productColId)?.value, + priority: cells.find(c => c.column_id === priorityColId)?.value, + date: cells.find(c => c.column_id === dateColId)?.value, + }; + }); +} + +async function ragAnswer({ tableId, userQuestion, userTags = [], product = null }) { + const data = await getTableData(tableId); + const questions = data.map(row => row.question); + + // Получаем embedding для всех вопросов + const embeddings = await new OpenAIEmbeddings().embedDocuments(questions); + + // Создаём векторное хранилище + const vectorStore = await HNSWLib.fromTexts(questions, data, new OpenAIEmbeddings()); + + // Получаем embedding для вопроса пользователя + const [userEmbedding] = await new OpenAIEmbeddings().embedDocuments([userQuestion]); + + // Ищем наиболее похожие вопросы (top-3) + const results = await vectorStore.similaritySearchVectorWithScore(userEmbedding, 3); + + // Фильтруем по тегам/продукту, если нужно + let filtered = results.map(([row, score]) => ({ ...row, score })); + if (userTags.length) { + filtered = filtered.filter(row => row.userTags && userTags.some(tag => row.userTags.includes(tag))); + } + if (product) { + filtered = filtered.filter(row => row.product === product); + } + + // Берём лучший результат + const best = filtered[0]; + + // Формируем ответ + return { + answer: best?.answer, + context: best?.context, + product: best?.product, + priority: best?.priority, + date: best?.date, + score: best?.score, + }; +} + +async function generateLLMResponse({ userQuestion, context, clarifyingAnswer, objectionAnswer, answer, systemPrompt, userTags, product, priority, date, rules, history, model, language }) { + // Подставляем значения в шаблон промта + let prompt = (systemPrompt || '') + .replace('{context}', context || '') + .replace('{clarifyingAnswer}', clarifyingAnswer || '') + .replace('{objectionAnswer}', objectionAnswer || '') + .replace('{answer}', answer || '') + .replace('{question}', userQuestion || '') + .replace('{userTags}', userTags || '') + .replace('{product}', product || '') + .replace('{priority}', priority || '') + .replace('{date}', date || '') + .replace('{rules}', rules || '') + .replace('{history}', history || '') + .replace('{model}', model || '') + .replace('{language}', language || ''); + + const chat = new ChatOllama({ + baseUrl: process.env.OLLAMA_BASE_URL || 'http://localhost:11434', + model: process.env.OLLAMA_MODEL || 'qwen2.5', + system: prompt, + temperature: 0.7, + maxTokens: 1000, + timeout: 30000, + }); + + const response = await chat.invoke(`Вопрос пользователя: ${userQuestion}`); + return response.content; +} + +module.exports = { ragAnswer, generateLLMResponse }; \ No newline at end of file diff --git a/backend/services/telegramBot.js b/backend/services/telegramBot.js index a2433aa..0ca7723 100644 --- a/backend/services/telegramBot.js +++ b/backend/services/telegramBot.js @@ -267,8 +267,23 @@ async function getBot() { const telegramId = ctx.from.id.toString(); // 1. Найти или создать пользователя const { userId, role } = await identityService.findOrCreateUserWithRole('telegram', telegramId); - - // 2. Сохранить входящее сообщение в messages + // 1.1 Найти или создать беседу + let conversationResult = await db.getQuery()( + 'SELECT * FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC, created_at DESC LIMIT 1', + [userId] + ); + let conversation; + if (conversationResult.rows.length === 0) { + const title = `Чат с пользователем ${userId}`; + const newConv = await db.getQuery()( + 'INSERT INTO conversations (user_id, title, created_at, updated_at) VALUES ($1, $2, NOW(), NOW()) RETURNING *', + [userId, title] + ); + conversation = newConv.rows[0]; + } else { + conversation = conversationResult.rows[0]; + } + // 2. Сохранять все сообщения с conversation_id let content = text; let attachmentMeta = {}; // Проверяем вложения (фото, документ, аудио, видео) @@ -310,9 +325,9 @@ async function getBot() { } // Сохраняем сообщение в БД await db.getQuery()( - `INSERT INTO messages (user_id, sender_type, content, channel, role, direction, created_at, attachment_filename, attachment_mimetype, attachment_size, attachment_data) - VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9, $10)`, - [userId, 'user', content, 'telegram', role, 'in', + `INSERT INTO messages (user_id, conversation_id, sender_type, content, channel, role, direction, created_at, attachment_filename, attachment_mimetype, attachment_size, attachment_data) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10, $11)`, + [userId, conversation.id, 'user', content, 'telegram', role, 'in', attachmentMeta.attachment_filename || null, attachmentMeta.attachment_mimetype || null, attachmentMeta.attachment_size || null, @@ -322,11 +337,11 @@ async function getBot() { // 3. Получить ответ от ИИ const aiResponse = await aiAssistant.getResponse(content, 'auto'); - // 4. Сохранить ответ в БД + // 4. Сохранить ответ в БД с conversation_id await db.getQuery()( - `INSERT INTO messages (user_id, sender_type, content, channel, role, direction, created_at) - VALUES ($1, $2, $3, $4, $5, $6, NOW())`, - [userId, 'assistant', aiResponse, 'telegram', role, 'out'] + `INSERT INTO messages (user_id, conversation_id, sender_type, content, channel, role, direction, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`, + [userId, conversation.id, 'assistant', aiResponse, 'telegram', role, 'out'] ); // 5. Отправить ответ пользователю await ctx.reply(aiResponse); diff --git a/frontend/src/components/ContactTable.vue b/frontend/src/components/ContactTable.vue index fde9d05..394b638 100644 --- a/frontend/src/components/ContactTable.vue +++ b/frontend/src/components/ContactTable.vue @@ -4,17 +4,52 @@

Контакты

-
- - - - - - - -
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Сбросить фильтры + + @@ -27,7 +62,7 @@ - + @@ -44,8 +79,9 @@ \ No newline at end of file diff --git a/frontend/src/services/messagesService.js b/frontend/src/services/messagesService.js index 2947a9e..63958f0 100644 --- a/frontend/src/services/messagesService.js +++ b/frontend/src/services/messagesService.js @@ -33,6 +33,15 @@ export default { async generateAiDraft(conversationId, messages, language = 'auto') { const { data } = await axios.post('/api/chat/ai-draft', { conversationId, messages, language }); return data; + }, + async broadcastMessage({ userId, message }) { + const { data } = await axios.post('/api/messages/broadcast', { + user_id: userId, + content: message + }, { + withCredentials: true + }); + return data; } }; diff --git a/frontend/src/views/contacts/ContactDetailsView.vue b/frontend/src/views/contacts/ContactDetailsView.vue index dc3f0b9..7875206 100644 --- a/frontend/src/views/contacts/ContactDetailsView.vue +++ b/frontend/src/views/contacts/ContactDetailsView.vue @@ -116,6 +116,7 @@ import ChatInterface from '../../components/ChatInterface.vue'; import contactsService from '../../services/contactsService.js'; import messagesService from '../../services/messagesService.js'; import { useAuth } from '../../composables/useAuth'; +import { ElMessageBox } from 'element-plus'; const route = useRoute(); const router = useRouter(); @@ -225,9 +226,9 @@ async function loadMessages() { conversationId.value = conv?.id || null; if (conversationId.value) { messages.value = await messagesService.getMessagesByConversationId(conversationId.value); - if (messages.value.length > 0) { - lastMessageDate.value = messages.value[messages.value.length - 1].created_at; - } else { + if (messages.value.length > 0) { + lastMessageDate.value = messages.value[messages.value.length - 1].created_at; + } else { lastMessageDate.value = null; } } else { @@ -318,31 +319,45 @@ function goBack() { } async function handleSendMessage({ message, attachments }) { - console.log('handleSendMessage', message, attachments); - if (!contact.value || !contact.value.id || !conversationId.value) return; - const tempId = 'local-' + Date.now(); - const optimisticMsg = { - id: tempId, - conversation_id: conversationId.value, - user_id: null, - content: message, - sender_type: 'user', - role: 'user', - channel: 'web', - created_at: new Date().toISOString(), - attachments: [], - isLocal: true - }; - messages.value.push(optimisticMsg); + if (!contact.value || !contact.value.id) return; + // Проверка наличия хотя бы одного идентификатора + const hasAnyId = contact.value.email || contact.value.telegram || contact.value.wallet; + if (!hasAnyId) { + if (typeof ElMessageBox === 'function') { + ElMessageBox.alert('У пользователя нет ни одного идентификатора (email, telegram, wallet). Сообщение не может быть отправлено.', 'Ошибка', { type: 'warning' }); + } else { + alert('У пользователя нет ни одного идентификатора (email, telegram, wallet). Сообщение не может быть отправлено.'); + } + return; + } try { - await messagesService.sendMessage({ + const result = await messagesService.broadcastMessage({ + userId: contact.value.id, message, - conversationId: conversationId.value, - attachments, - toUserId: contact.value.id + attachments }); - } finally { + // Формируем текст результата для отображения админу + let resultText = ''; + if (result && Array.isArray(result.results)) { + resultText = 'Результат рассылки по каналам:'; + for (const r of result.results) { + resultText += `\n${r.channel}: ${(r.status === 'sent' || r.status === 'saved') ? 'Успех' : 'Ошибка'}${r.error ? ' (' + r.error + ')' : ''}`; + } + } else { + resultText = 'Не удалось получить подробный ответ от сервера.'; + } + if (typeof ElMessageBox === 'function') { + ElMessageBox.alert(resultText, 'Результат рассылки', { type: 'info' }); + } else { + alert(resultText); + } await loadMessages(); + } catch (e) { + if (typeof ElMessageBox === 'function') { + ElMessageBox.alert('Ошибка отправки: ' + (e?.response?.data?.error || e?.message || e), 'Ошибка', { type: 'error' }); + } else { + alert('Ошибка отправки: ' + (e?.response?.data?.error || e?.message || e)); + } } } diff --git a/frontend/src/views/tables/TablesListView.vue b/frontend/src/views/tables/TablesListView.vue index 678fcdc..e2a3967 100644 --- a/frontend/src/views/tables/TablesListView.vue +++ b/frontend/src/views/tables/TablesListView.vue @@ -15,11 +15,7 @@ import { useRouter } from 'vue-router'; // import TagsTableView from '../../components/tables/TagsTableView.vue'; // больше не используется const router = useRouter(); function goBack() { - if (window.history.length > 1) { - router.back(); - } else { - router.push({ name: 'crm' }); - } + router.push({ name: 'crm' }); }
{{ contact.name || '-' }} {{ contact.email || '-' }} {{ contact.telegram || '-' }}