ваше сообщение коммита

This commit is contained in:
2025-06-24 16:45:50 +03:00
parent 5111b584e5
commit 5dcb8dd500
19 changed files with 11802 additions and 15078 deletions

View File

@@ -0,0 +1,37 @@
# План задач по маскировке и шифрованию данных
## 1. Анализ данных
- [ ] Определить, какие поля и таблицы содержат чувствительные данные (например, email, ФИО, адрес, сообщения, вложения).
- [ ] Составить список полей для маскировки (для user/guest) и для шифрования (для хранения в БД).
## 2. Маскировка данных (токенизация)
- [ ] Реализовать функцию маскировки на backend (Node.js):
- [ ] Для ролей user/guest возвращать токены вместо реальных данных (например, `token_<id>`).
- [ ] Для 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.

View File

@@ -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 и системный промт.
- Возможность расширения под любые бизнес-сценарии.

View File

@@ -15,6 +15,7 @@ const messagesRoutes = require('./routes/messages');
const userTagsRoutes = require('./routes/userTags'); const userTagsRoutes = require('./routes/userTags');
const tagsInitRoutes = require('./routes/tagsInit'); const tagsInitRoutes = require('./routes/tagsInit');
const tagsRoutes = require('./routes/tags'); const tagsRoutes = require('./routes/tags');
const ragRoutes = require('./routes/rag'); // Новый роут для RAG-ассистента
// Проверка и создание директорий для хранения данных контрактов // Проверка и создание директорий для хранения данных контрактов
const ensureDirectoriesExist = () => { const ensureDirectoriesExist = () => {
@@ -188,6 +189,7 @@ app.use('/api/messages', messagesRoutes);
app.use('/api/tags', tagsInitRoutes); app.use('/api/tags', tagsInitRoutes);
app.use('/api/tags', tagsRoutes); app.use('/api/tags', tagsRoutes);
app.use('/api/identities', identitiesRoutes); app.use('/api/identities', identitiesRoutes);
app.use('/api/rag', ragRoutes); // Подключаем роут
const nonceStore = new Map(); // или любая другая реализация хранилища nonce const nonceStore = new Map(); // или любая другая реализация хранилища nonce

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const db = require('../db'); const db = require('../db');
const { broadcastMessagesUpdate } = require('../wsHub'); const { broadcastMessagesUpdate } = require('../wsHub');
const telegramBot = require('../services/telegramBot');
const emailBot = new (require('../services/emailBot'))();
// GET /api/messages?userId=123 // GET /api/messages?userId=123
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
@@ -42,11 +44,100 @@ router.get('/', async (req, res) => {
router.post('/', 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; const { user_id, sender_type, content, channel, role, direction, attachment_filename, attachment_mimetype, attachment_size, attachment_data, metadata } = req.body;
try { 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) if (channel === 'email') {
VALUES ($1,$2,$3,$4,$5,$6,NOW(),$7,$8,$9,$10,$11) RETURNING *`, const emailIdentity = await db.getQuery()(
[user_id, sender_type, content, channel, role, direction, attachment_filename, attachment_mimetype, attachment_size, attachment_data, metadata] '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(); broadcastMessagesUpdate();
res.json({ success: true, message: result.rows[0] }); res.json({ success: true, message: result.rows[0] });
} catch (e) { } 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; module.exports = router;

31
backend/routes/rag.js Normal file
View File

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

View File

@@ -53,10 +53,19 @@ router.get('/:id', async (req, res, next) => {
router.post('/:id/columns', async (req, res, next) => { router.post('/:id/columns', async (req, res, next) => {
try { try {
const tableId = req.params.id; 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()( const result = await db.getQuery()(
'INSERT INTO user_columns (table_id, name, type, options, "order") VALUES ($1, $2, $3, $4, $5) RETURNING *', '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]); res.json(result.rows[0]);
} catch (err) { } catch (err) {

View File

@@ -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 { try {
const usersResult = await db.getQuery()('SELECT id, first_name, last_name, created_at, preferred_language FROM users ORDER BY id'); const {
const users = usersResult.rows; tagIds = '',
// Получаем все user_identities разом dateFrom = '',
const identitiesResult = await db.getQuery()('SELECT user_id, provider, provider_id FROM user_identities'); dateTo = '',
const identities = identitiesResult.rows; contactType = 'all',
// Группируем идентификаторы по user_id search = '',
const identityMap = {}; newMessages = ''
for (const id of identities) { } = req.query;
if (!identityMap[id.user_id]) identityMap[id.user_id] = {}; const adminId = req.user && req.user.id;
if (!identityMap[id.user_id][id.provider]) identityMap[id.user_id][id.provider] = id.provider_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 => ({ const contacts = users.map(u => ({
id: u.id, id: u.id,
name: [u.first_name, u.last_name].filter(Boolean).join(' ') || null, name: [u.first_name, u.last_name].filter(Boolean).join(' ') || null,
email: identityMap[u.id]?.email || null, email: u.email || null,
telegram: identityMap[u.id]?.telegram || null, telegram: u.telegram || null,
wallet: identityMap[u.id]?.wallet || null, wallet: u.wallet || null,
created_at: u.created_at, created_at: u.created_at,
preferred_language: u.preferred_language || [] preferred_language: u.preferred_language || []
})); }));
res.json({ success: true, contacts }); res.json({ success: true, contacts });
} catch (error) { } catch (error) {
logger.error('Error fetching contacts:', error); logger.error('Error fetching contacts:', error);

View File

@@ -140,14 +140,30 @@ class EmailBotService {
const html = parsed.html || ''; const html = parsed.html || '';
// 1. Найти или создать пользователя // 1. Найти или создать пользователя
const { userId, role } = await identityService.findOrCreateUserWithRole('email', fromEmail); 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; let hasAttachments = parsed.attachments && parsed.attachments.length > 0;
if (hasAttachments) { if (hasAttachments) {
for (const att of parsed.attachments) { for (const att of parsed.attachments) {
await db.getQuery()( 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) `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, NOW(), $7, $8, $9, $10, $11)`, VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10, $11, $12)`,
[userId, 'user', text, 'email', role, 'in', [userId, conversation.id, 'user', text, 'email', role, 'in',
att.filename, att.filename,
att.contentType, att.contentType,
att.size, att.size,
@@ -158,18 +174,18 @@ class EmailBotService {
} }
} else { } else {
await db.getQuery()( await db.getQuery()(
`INSERT INTO messages (user_id, sender_type, content, channel, role, direction, created_at, metadata) `INSERT INTO messages (user_id, conversation_id, sender_type, content, channel, role, direction, created_at, metadata)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7)`, VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8)`,
[userId, 'user', text, 'email', role, 'in', JSON.stringify({ subject, html })] [userId, conversation.id, 'user', text, 'email', role, 'in', JSON.stringify({ subject, html })]
); );
} }
// 3. Получить ответ от ИИ // 3. Получить ответ от ИИ
const aiResponse = await aiAssistant.getResponse(text, 'auto'); const aiResponse = await aiAssistant.getResponse(text, 'auto');
// 4. Сохранить ответ в БД // 4. Сохранить ответ в БД с conversation_id
await db.getQuery()( await db.getQuery()(
`INSERT INTO messages (user_id, sender_type, content, channel, role, direction, created_at, metadata) `INSERT INTO messages (user_id, conversation_id, sender_type, content, channel, role, direction, created_at, metadata)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7)`, VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8)`,
[userId, 'assistant', aiResponse, 'email', role, 'out', JSON.stringify({ subject, html })] [userId, conversation.id, 'assistant', aiResponse, 'email', role, 'out', JSON.stringify({ subject, html })]
); );
// 5. Отправить ответ на email // 5. Отправить ответ на email
await this.sendEmail(fromEmail, 'Re: ' + subject, aiResponse); await this.sendEmail(fromEmail, 'Re: ' + subject, aiResponse);

View File

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

View File

@@ -267,8 +267,23 @@ async function getBot() {
const telegramId = ctx.from.id.toString(); const telegramId = ctx.from.id.toString();
// 1. Найти или создать пользователя // 1. Найти или создать пользователя
const { userId, role } = await identityService.findOrCreateUserWithRole('telegram', telegramId); const { userId, role } = await identityService.findOrCreateUserWithRole('telegram', telegramId);
// 1.1 Найти или создать беседу
// 2. Сохранить входящее сообщение в messages 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 content = text;
let attachmentMeta = {}; let attachmentMeta = {};
// Проверяем вложения (фото, документ, аудио, видео) // Проверяем вложения (фото, документ, аудио, видео)
@@ -310,9 +325,9 @@ async function getBot() {
} }
// Сохраняем сообщение в БД // Сохраняем сообщение в БД
await db.getQuery()( await db.getQuery()(
`INSERT INTO messages (user_id, sender_type, content, channel, role, direction, created_at, attachment_filename, attachment_mimetype, attachment_size, attachment_data) `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, NOW(), $7, $8, $9, $10)`, VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10, $11)`,
[userId, 'user', content, 'telegram', role, 'in', [userId, conversation.id, 'user', content, 'telegram', role, 'in',
attachmentMeta.attachment_filename || null, attachmentMeta.attachment_filename || null,
attachmentMeta.attachment_mimetype || null, attachmentMeta.attachment_mimetype || null,
attachmentMeta.attachment_size || null, attachmentMeta.attachment_size || null,
@@ -322,11 +337,11 @@ async function getBot() {
// 3. Получить ответ от ИИ // 3. Получить ответ от ИИ
const aiResponse = await aiAssistant.getResponse(content, 'auto'); const aiResponse = await aiAssistant.getResponse(content, 'auto');
// 4. Сохранить ответ в БД // 4. Сохранить ответ в БД с conversation_id
await db.getQuery()( await db.getQuery()(
`INSERT INTO messages (user_id, sender_type, content, channel, role, direction, created_at) `INSERT INTO messages (user_id, conversation_id, sender_type, content, channel, role, direction, created_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())`, VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
[userId, 'assistant', aiResponse, 'telegram', role, 'out'] [userId, conversation.id, 'assistant', aiResponse, 'telegram', role, 'out']
); );
// 5. Отправить ответ пользователю // 5. Отправить ответ пользователю
await ctx.reply(aiResponse); await ctx.reply(aiResponse);

View File

@@ -4,17 +4,52 @@
<h2>Контакты</h2> <h2>Контакты</h2>
<button class="close-btn" @click="$emit('close')">×</button> <button class="close-btn" @click="$emit('close')">×</button>
</div> </div>
<div class="filters-panel"> <el-form :inline="true" class="filters-form" label-position="top">
<input v-model="filterName" placeholder="Имя" /> <el-form-item label="Поиск">
<input v-model="filterEmail" placeholder="Email" /> <el-input v-model="filterSearch" placeholder="Поиск по имени, email, telegram, кошельку" clearable @input="onAnyFilterChange" />
<input v-model="filterTelegram" placeholder="Telegram" /> </el-form-item>
<input v-model="filterWallet" placeholder="Кошелек" /> <el-form-item label="Тип контакта">
<input v-model="filterDateFrom" type="date" placeholder="Дата от" /> <el-select v-model="filterContactType" placeholder="Все" style="min-width:120px;" @change="onAnyFilterChange">
<input v-model="filterDateTo" type="date" placeholder="Дата до" /> <el-option label="Все" value="all" />
<label class="checkbox-label"> <el-option label="Email" value="email" />
<input type="checkbox" v-model="filterOnlyNewMessages" /> Только с новыми сообщениями <el-option label="Telegram" value="telegram" />
</label> <el-option label="Кошелек" value="wallet" />
</div> </el-select>
</el-form-item>
<el-form-item label="Дата от">
<el-date-picker v-model="filterDateFrom" type="date" placeholder="Дата от" clearable style="width: 100%;" @change="onAnyFilterChange" />
</el-form-item>
<el-form-item label="Дата до">
<el-date-picker v-model="filterDateTo" type="date" placeholder="Дата до" clearable style="width: 100%;" @change="onAnyFilterChange" />
</el-form-item>
<el-form-item label="Только с новыми сообщениями">
<el-select v-model="filterNewMessages" placeholder="Нет" style="min-width:110px;" @change="onAnyFilterChange">
<el-option label="Нет" :value="''" />
<el-option label="Да" value="yes" />
</el-select>
</el-form-item>
<el-form-item label="Теги">
<el-select
v-model="selectedTagIds"
multiple
filterable
collapse-tags
placeholder="Выберите теги"
style="min-width:180px;"
@change="onAnyFilterChange"
>
<el-option
v-for="tag in allTags"
:key="tag.id"
:label="tag.name"
:value="tag.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="resetFilters" type="default">Сбросить фильтры</el-button>
</el-form-item>
</el-form>
<table class="contact-table"> <table class="contact-table">
<thead> <thead>
<tr> <tr>
@@ -27,7 +62,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="contact in filteredContactsArray" :key="contact.id" :class="{ 'new-contact-row': newIds.includes(contact.id) }"> <tr v-for="contact in contactsArray" :key="contact.id" :class="{ 'new-contact-row': newIds.includes(contact.id) }">
<td>{{ contact.name || '-' }}</td> <td>{{ contact.name || '-' }}</td>
<td>{{ contact.email || '-' }}</td> <td>{{ contact.email || '-' }}</td>
<td>{{ contact.telegram || '-' }}</td> <td>{{ contact.telegram || '-' }}</td>
@@ -44,8 +79,9 @@
</template> </template>
<script setup> <script setup>
import { defineProps, computed, ref } from 'vue'; import { defineProps, computed, ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { ElSelect, ElOption, ElForm, ElFormItem, ElInput, ElDatePicker, ElCheckbox, ElButton } from 'element-plus';
const props = defineProps({ const props = defineProps({
contacts: { type: Array, default: () => [] }, contacts: { type: Array, default: () => [] },
newContacts: { type: Array, default: () => [] }, newContacts: { type: Array, default: () => [] },
@@ -53,38 +89,79 @@ const props = defineProps({
markMessagesAsReadForUser: { type: Function, default: null }, markMessagesAsReadForUser: { type: Function, default: null },
markContactAsRead: { type: Function, default: null } markContactAsRead: { type: Function, default: null }
}); });
const contactsArray = computed(() => Array.from(props.contacts || [])); const contactsArray = ref([]); // теперь управляем вручную
const newIds = computed(() => props.newContacts.map(c => c.id)); const newIds = computed(() => props.newContacts.map(c => c.id));
const newMsgUserIds = computed(() => props.newMessages.map(m => String(m.user_id))); const newMsgUserIds = computed(() => props.newMessages.map(m => String(m.user_id)));
const router = useRouter(); const router = useRouter();
// Фильтры // Фильтры
const filterName = ref(''); const filterSearch = ref('');
const filterEmail = ref(''); const filterContactType = ref('all');
const filterTelegram = ref('');
const filterWallet = ref('');
const filterDateFrom = ref(''); const filterDateFrom = ref('');
const filterDateTo = ref(''); const filterDateTo = ref('');
const filterOnlyNewMessages = ref(false); const filterNewMessages = ref('');
const filteredContactsArray = computed(() => { // Теги
return contactsArray.value.filter(contact => { const allTags = ref([]);
const nameMatch = !filterName.value || (contact.name || '').toLowerCase().includes(filterName.value.toLowerCase()); const selectedTagIds = ref([]);
const emailMatch = !filterEmail.value || (contact.email || '').toLowerCase().includes(filterEmail.value.toLowerCase());
const telegramMatch = !filterTelegram.value || (contact.telegram || '').toLowerCase().includes(filterTelegram.value.toLowerCase()); onMounted(async () => {
const walletMatch = !filterWallet.value || (contact.wallet || '').toLowerCase().includes(filterWallet.value.toLowerCase()); await loadTags();
let dateFromMatch = true, dateToMatch = true; await fetchContacts();
if (filterDateFrom.value && contact.created_at) {
dateFromMatch = new Date(contact.created_at) >= new Date(filterDateFrom.value);
}
if (filterDateTo.value && contact.created_at) {
dateToMatch = new Date(contact.created_at) <= new Date(filterDateTo.value);
}
const newMsgMatch = !filterOnlyNewMessages.value || newMsgUserIds.value.includes(String(contact.id));
return nameMatch && emailMatch && telegramMatch && walletMatch && dateFromMatch && dateToMatch && newMsgMatch;
});
}); });
async function loadTags() {
const res = await fetch('/api/tags');
allTags.value = await res.json();
}
function buildQuery() {
const params = new URLSearchParams();
if (selectedTagIds.value.length > 0) params.append('tagIds', selectedTagIds.value.join(','));
if (filterDateFrom.value) params.append('dateFrom', formatDateOnly(filterDateFrom.value));
if (filterDateTo.value) params.append('dateTo', formatDateOnly(filterDateTo.value));
if (filterContactType.value && filterContactType.value !== 'all') params.append('contactType', filterContactType.value);
if (filterSearch.value) params.append('search', filterSearch.value);
if (filterNewMessages.value) params.append('newMessages', filterNewMessages.value);
return params.toString();
}
async function fetchContacts() {
let url = '/api/users';
const query = buildQuery();
if (query) url += '?' + query;
const res = await fetch(url);
const data = await res.json();
contactsArray.value = data.contacts || [];
}
function onTagsFilterChange() {
onAnyFilterChange();
}
function onAnyFilterChange() {
fetchContacts();
}
function resetFilters() {
filterSearch.value = '';
filterContactType.value = 'all';
filterDateFrom.value = '';
filterDateTo.value = '';
filterNewMessages.value = '';
selectedTagIds.value = [];
fetchContacts();
}
function formatDateOnly(date) {
if (!date) return '';
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function formatDate(date) { function formatDate(date) {
if (!date) return '-'; if (!date) return '-';
return new Date(date).toLocaleString(); return new Date(date).toLocaleString();
@@ -201,27 +278,21 @@ async function showDetails(contact) {
background: #e6ffe6 !important; background: #e6ffe6 !important;
transition: background 0.3s; transition: background 0.3s;
} }
.filters-panel { .filters-form {
display: flex; display: flex;
gap: 10px; flex-wrap: wrap;
margin-bottom: 18px; gap: 1.2em 1.5em;
align-items: center; align-items: flex-end;
background: #f7f9fa;
border-radius: 12px;
padding: 1.2em 1em 0.7em 1em;
margin-bottom: 1.2em;
} }
.filters-panel input { @media (max-width: 900px) {
padding: 6px 10px; .filters-form {
border: 1px solid #d0d7de; flex-direction: column;
border-radius: 6px; gap: 0.7em 0;
font-size: 1em; }
min-width: 110px;
}
.filters-panel input[type="checkbox"] {
margin-right: 4px;
}
.checkbox-label {
display: flex;
align-items: center;
font-size: 0.98em;
user-select: none;
} }
.new-msg-icon { .new-msg-icon {
color: #ff9800; color: #ff9800;

View File

@@ -1,5 +1,21 @@
<template> <template>
<td> <template v-if="column.type === 'tags'">
<div v-if="!editing" @click="editing = true" class="tags-cell-view">
<span v-if="selectedTagNames.length">{{ selectedTagNames.join(', ') }}</span>
<span v-else style="color:#bbb"></span>
</div>
<div v-else class="tags-cell-edit">
<div class="tags-multiselect">
<div v-for="tag in allTags" :key="tag.id" class="tag-option">
<input type="checkbox" :id="'cell-tag-' + tag.id + '-' + rowId" :value="tag.id" v-model="editTagIds" />
<label :for="'cell-tag-' + tag.id + '-' + rowId">{{ tag.name }}</label>
</div>
</div>
<button class="save-btn" @click="saveTags">Сохранить</button>
<button class="cancel-btn" @click="cancelTags">Отмена</button>
</div>
</template>
<template v-else>
<input <input
v-model="localValue" v-model="localValue"
@blur="save" @blur="save"
@@ -7,26 +23,77 @@
:placeholder="column.name" :placeholder="column.name"
class="cell-input" class="cell-input"
/> />
</td> </template>
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref, watch, onMounted } from 'vue';
const props = defineProps(['rowId', 'column', 'cellValues']); const props = defineProps(['rowId', 'column', 'cellValues']);
const emit = defineEmits(['update']); const emit = defineEmits(['update']);
const localValue = ref(''); const localValue = ref('');
const editing = ref(false);
const allTags = ref([]); // Все теги из /api/tags
const editTagIds = ref([]); // id выбранных тегов в режиме редактирования
// Для отображения выбранных тегов
const selectedTagNames = ref([]);
onMounted(async () => {
if (props.column.type === 'tags') {
await loadTags();
updateSelectedTagNames();
}
});
async function loadTags() {
const res = await fetch('/api/tags');
allTags.value = await res.json();
}
watch( watch(
() => [props.rowId, props.column.id, props.cellValues], () => [props.rowId, props.column.id, props.cellValues],
() => { () => {
if (props.column.type === 'tags') {
// Значение ячейки — строка с JSON-массивом id тегов
const cell = props.cellValues.find(
c => c.row_id === props.rowId && c.column_id === props.column.id
);
let ids = [];
if (cell && cell.value) {
try {
ids = JSON.parse(cell.value);
} catch {}
}
editTagIds.value = Array.isArray(ids) ? ids : [];
updateSelectedTagNames();
} else {
const cell = props.cellValues.find( const cell = props.cellValues.find(
c => c.row_id === props.rowId && c.column_id === props.column.id c => c.row_id === props.rowId && c.column_id === props.column.id
); );
localValue.value = cell ? cell.value : ''; localValue.value = cell ? cell.value : '';
}
}, },
{ immediate: true } { immediate: true }
); );
function updateSelectedTagNames() {
if (props.column.type === 'tags') {
selectedTagNames.value = allTags.value
.filter(tag => editTagIds.value.includes(tag.id))
.map(tag => tag.name);
}
}
function saveTags() {
emit('update', JSON.stringify(editTagIds.value));
editing.value = false;
}
function cancelTags() {
editing.value = false;
updateSelectedTagNames();
}
function save() { function save() {
emit('update', localValue.value); emit('update', localValue.value);
} }
@@ -46,4 +113,52 @@ function save() {
border: 1.5px solid #2ecc40; border: 1.5px solid #2ecc40;
outline: none; outline: none;
} }
.tags-cell-view {
min-height: 1.7em;
cursor: pointer;
padding: 0.2em 0.1em;
}
.tags-cell-edit {
background: #f8f8f8;
border-radius: 6px;
padding: 0.3em 0.2em 0.5em 0.2em;
}
.tags-multiselect {
display: flex;
flex-wrap: wrap;
gap: 0.5em 1.2em;
margin-bottom: 0.7em;
}
.tag-option {
display: flex;
align-items: center;
gap: 0.3em;
}
.save-btn {
background: #2ecc40;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.3em 1em;
font-weight: 600;
cursor: pointer;
margin-right: 0.7em;
transition: background 0.2s;
}
.save-btn:hover {
background: #27ae38;
}
.cancel-btn {
background: #eaeaea;
color: #333;
border: none;
border-radius: 8px;
padding: 0.3em 1em;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.cancel-btn:hover {
background: #d5d5d5;
}
</style> </style>

View File

@@ -7,32 +7,38 @@
<table class="notion-table"> <table class="notion-table">
<thead> <thead>
<tr> <tr>
<th v-for="col in columns" :key="col.id" @dblclick="editColumn(col)"> <th v-for="col in columns" :key="col.id" @dblclick="editColumn(col)" class="th-col">
<span v-if="!editingCol || editingCol.id !== col.id">{{ col.name }}</span> <span v-if="!editingCol || editingCol.id !== col.id">{{ col.name }}</span>
<input v-else v-model="colEditValue" @blur="saveColEdit(col)" @keyup.enter="saveColEdit(col)" @keyup.esc="cancelColEdit" class="notion-input" /> <input v-else v-model="colEditValue" @blur="saveColEdit(col)" @keyup.enter="saveColEdit(col)" @keyup.esc="cancelColEdit" class="notion-input" />
<button class="col-menu" @click.stop="openColMenu(col)"></button> <button class="col-menu" @click.stop="openColMenu(col, $event)"></button>
<!-- Меню столбца -->
<div v-if="openedColMenuId === col.id" class="context-menu" :style="colMenuStyle">
<button class="menu-item" @click="startRenameCol(col)">Переименовать</button>
<button class="menu-item" @click="startChangeTypeCol(col)">Изменить тип</button>
<button class="menu-item danger" @click="deleteColumn(col)">Удалить</button>
</div>
</th> </th>
<th> <th>
<button class="add-col" @click="addColumn">+</button> <button class="add-col" @click="showAddColModal = true">+</button>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="row in rows" :key="row.id"> <tr v-for="row in rows" :key="row.id">
<td v-for="col in columns" :key="col.id" @click="startEdit(row, col)"> <td v-for="col in columns" :key="col.id">
<span v-if="!isEditing(row, col)">{{ getCellValue(row, col) || '' }}</span> <TableCell
<input :rowId="row.id"
v-else :column="col"
v-model="editValue" :cellValues="cellValues"
@blur="saveEdit(row, col)" @update="val => saveCellValue(row.id, col.id, val)"
@keyup.enter="saveEdit(row, col)"
@keyup.esc="cancelEdit"
class="notion-input"
autofocus
/> />
</td> </td>
<td> <td>
<button class="row-menu" @click.stop="openRowMenu(row)"></button> <button class="row-menu" @click.stop="openRowMenu(row, $event)"></button>
<!-- Меню строки -->
<div v-if="openedRowMenuId === row.id" class="context-menu" :style="rowMenuStyle">
<button class="menu-item danger" @click="deleteRow(row)">Удалить</button>
</div>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -42,13 +48,55 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<!-- Модалки и меню можно реализовать через отдельные компоненты или простые div --> <!-- Оверлей для закрытия меню по клику вне -->
<div v-if="openedColMenuId || openedRowMenuId" class="menu-overlay" @click="closeMenus"></div>
<!-- Модалка добавления столбца -->
<div v-if="showAddColModal" class="modal-backdrop">
<div class="modal add-col-modal">
<h4>Добавить столбец</h4>
<label>Название</label>
<input v-model="newColName" class="notion-input" placeholder="Название столбца" />
<label>Тип</label>
<select v-model="newColType" class="notion-input">
<option value="text">Текст</option>
<option value="number">Число</option>
<option value="tags">Теги</option>
</select>
<label>Назначение столбца</label>
<select v-model="newColPurpose" class="notion-input">
<option value=""> Не выбрано </option>
<option value="question">Это столбец с вопросами</option>
<option value="answer">Это столбец с ответами</option>
<option value="clarifyingAnswer">Ответ с уточняющим вопросом</option>
<option value="objectionAnswer">Ответ на возражение</option>
<option value="userTags">Это столбец с тегами пользователей</option>
<option value="context">Это столбец с дополнительным контекстом</option>
<option value="product">Это столбец с продуктом/услугой</option>
<option value="priority">Это столбец с приоритетом</option>
<option value="date">Это столбец с датой</option>
</select>
<div v-if="newColType === 'tags'">
<label>Выберите теги</label>
<div class="tags-multiselect">
<div v-for="tag in tags" :key="tag.id" class="tag-option">
<input type="checkbox" :id="'tag-' + tag.id" :value="tag.id" v-model="selectedTagIds" />
<label :for="'tag-' + tag.id">{{ tag.name }}</label>
</div>
</div>
</div>
<div class="modal-actions">
<button class="save-btn" @click="handleAddColumn">Добавить</button>
<button class="cancel-btn" @click="closeAddColModal">Отмена</button>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'; import { ref, onMounted } from 'vue';
import tablesService from '../../services/tablesService'; import tablesService from '../../services/tablesService';
import TableCell from './TableCell.vue';
const props = defineProps({ tableId: Number }); const props = defineProps({ tableId: Number });
const columns = ref([]); const columns = ref([]);
@@ -56,6 +104,52 @@ const rows = ref([]);
const cellValues = ref([]); const cellValues = ref([]);
const tableMeta = ref(null); const tableMeta = ref(null);
// Для модалки добавления столбца
const showAddColModal = ref(false);
const newColName = ref('');
const newColType = ref('text');
const tags = ref([]);
const selectedTagIds = ref([]);
const newColPurpose = ref("");
// Меню столбца
const openedColMenuId = ref(null);
const openedRowMenuId = ref(null);
const colMenuStyle = ref('');
const rowMenuStyle = ref('');
function closeAddColModal() {
showAddColModal.value = false;
newColName.value = '';
newColType.value = 'text';
selectedTagIds.value = [];
newColPurpose.value = '';
}
async function handleAddColumn() {
if (!newColName.value) return;
const data = { name: newColName.value, type: newColType.value };
if (newColType.value === 'tags') {
data.tagIds = selectedTagIds.value;
}
if (newColPurpose.value) {
data.purpose = newColPurpose.value;
}
await tablesService.addColumn(props.tableId, data);
closeAddColModal();
fetchTable();
}
async function loadTags() {
const res = await fetch('/api/tags');
tags.value = await res.json();
}
onMounted(() => {
fetchTable();
loadTags();
});
// Для редактирования ячеек // Для редактирования ячеек
const editing = ref({ rowId: null, colId: null }); const editing = ref({ rowId: null, colId: null });
const editValue = ref(''); const editValue = ref('');
@@ -95,13 +189,40 @@ function cancelColEdit() {
// Добавление/удаление // Добавление/удаление
function addColumn() { function addColumn() {
tablesService.addColumn(props.tableId, { name: 'Новый столбец', type: 'text' }).then(fetchTable); showAddColModal.value = true;
} }
function addRow() { function addRow() {
tablesService.addRow(props.tableId).then(fetchTable); tablesService.addRow(props.tableId).then(fetchTable);
} }
function openColMenu(col) { /* TODO: контекстное меню */ } function openColMenu(col, event) {
function openRowMenu(row) { /* TODO: контекстное меню */ } openedColMenuId.value = col.id;
openedRowMenuId.value = null;
setMenuPosition(event, colMenuStyle);
}
function openRowMenu(row, event) {
openedRowMenuId.value = row.id;
openedColMenuId.value = null;
setMenuPosition(event, rowMenuStyle);
}
function closeMenus() {
openedColMenuId.value = null;
openedRowMenuId.value = null;
}
function setMenuPosition(event, styleRef) {
// Позиционируем меню под кнопкой
const rect = event.target.getBoundingClientRect();
styleRef.value = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;z-index:2000;`;
}
// Действия меню столбца
function startRenameCol(col) {
closeMenus();
editColumn(col);
}
function startChangeTypeCol(col) {
closeMenus();
// TODO: реализовать смену типа столбца (можно открыть модалку выбора типа)
alert('Изменение типа столбца пока не реализовано');
}
// Загрузка данных // Загрузка данных
async function fetchTable() { async function fetchTable() {
@@ -111,7 +232,21 @@ async function fetchTable() {
cellValues.value = data.cellValues; cellValues.value = data.cellValues;
tableMeta.value = { name: data.name, description: data.description }; tableMeta.value = { name: data.name, description: data.description };
} }
fetchTable();
function saveCellValue(rowId, columnId, value) {
tablesService.saveCell({ row_id: rowId, column_id: columnId, value }).then(fetchTable);
}
function deleteRow(row) {
if (confirm('Удалить эту строку?')) {
tablesService.deleteRow(row.id).then(fetchTable);
}
}
function deleteColumn(col) {
if (confirm('Удалить этот столбец?')) {
tablesService.deleteColumn(col.id).then(fetchTable);
}
}
</script> </script>
<style scoped> <style scoped>
@@ -174,4 +309,138 @@ fetchTable();
color: #888; color: #888;
font-size: 1em; font-size: 1em;
} }
.modal-backdrop {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.18);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 16px rgba(0,0,0,0.13);
padding: 2em 2em 1.5em 2em;
min-width: 320px;
max-width: 95vw;
}
.add-col-modal label {
font-weight: 500;
margin-top: 0.7em;
display: block;
}
.add-col-modal input,
.add-col-modal select {
width: 100%;
border: 1px solid #ececec;
border-radius: 7px;
padding: 0.5em 0.8em;
font-size: 1em;
background: #fafbfc;
margin-bottom: 0.7em;
}
.tags-multiselect {
display: flex;
flex-wrap: wrap;
gap: 0.5em 1.2em;
margin-bottom: 1em;
}
.tag-option {
display: flex;
align-items: center;
gap: 0.3em;
}
.save-btn {
background: #2ecc40;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.5em 1.2em;
font-weight: 600;
cursor: pointer;
margin-right: 0.7em;
transition: background 0.2s;
}
.save-btn:hover {
background: #27ae38;
}
.cancel-btn {
background: #eaeaea;
color: #333;
border: none;
border-radius: 8px;
padding: 0.5em 1.2em;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.cancel-btn:hover {
background: #d5d5d5;
}
.th-col {
position: relative;
}
.delete-col-btn {
position: absolute;
top: 6px;
right: 6px;
background: none;
border: none;
color: #ff4d4f;
font-size: 1.1em;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
.th-col:hover .delete-col-btn {
opacity: 1;
}
.delete-row-btn {
background: none;
border: none;
color: #ff4d4f;
font-size: 1.1em;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
tr:hover .delete-row-btn {
opacity: 1;
}
.context-menu {
background: #fff;
border: 1px solid #ececec;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.13);
min-width: 150px;
padding: 0.3em 0.2em;
display: flex;
flex-direction: column;
position: fixed;
z-index: 2001;
}
.menu-item {
background: none;
border: none;
text-align: left;
padding: 0.6em 1.1em;
font-size: 1em;
color: #222;
cursor: pointer;
border-radius: 6px;
transition: background 0.18s;
}
.menu-item:hover {
background: #f2f8f4;
}
.menu-item.danger {
color: #ff4d4f;
}
.menu-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 1999;
}
</style> </style>

View File

@@ -33,6 +33,15 @@ export default {
async generateAiDraft(conversationId, messages, language = 'auto') { async generateAiDraft(conversationId, messages, language = 'auto') {
const { data } = await axios.post('/api/chat/ai-draft', { conversationId, messages, language }); const { data } = await axios.post('/api/chat/ai-draft', { conversationId, messages, language });
return data; return data;
},
async broadcastMessage({ userId, message }) {
const { data } = await axios.post('/api/messages/broadcast', {
user_id: userId,
content: message
}, {
withCredentials: true
});
return data;
} }
}; };

View File

@@ -116,6 +116,7 @@ import ChatInterface from '../../components/ChatInterface.vue';
import contactsService from '../../services/contactsService.js'; import contactsService from '../../services/contactsService.js';
import messagesService from '../../services/messagesService.js'; import messagesService from '../../services/messagesService.js';
import { useAuth } from '../../composables/useAuth'; import { useAuth } from '../../composables/useAuth';
import { ElMessageBox } from 'element-plus';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -318,31 +319,45 @@ function goBack() {
} }
async function handleSendMessage({ message, attachments }) { async function handleSendMessage({ message, attachments }) {
console.log('handleSendMessage', message, attachments); if (!contact.value || !contact.value.id) return;
if (!contact.value || !contact.value.id || !conversationId.value) return; // Проверка наличия хотя бы одного идентификатора
const tempId = 'local-' + Date.now(); const hasAnyId = contact.value.email || contact.value.telegram || contact.value.wallet;
const optimisticMsg = { if (!hasAnyId) {
id: tempId, if (typeof ElMessageBox === 'function') {
conversation_id: conversationId.value, ElMessageBox.alert('У пользователя нет ни одного идентификатора (email, telegram, wallet). Сообщение не может быть отправлено.', 'Ошибка', { type: 'warning' });
user_id: null, } else {
content: message, alert('У пользователя нет ни одного идентификатора (email, telegram, wallet). Сообщение не может быть отправлено.');
sender_type: 'user', }
role: 'user', return;
channel: 'web', }
created_at: new Date().toISOString(),
attachments: [],
isLocal: true
};
messages.value.push(optimisticMsg);
try { try {
await messagesService.sendMessage({ const result = await messagesService.broadcastMessage({
userId: contact.value.id,
message, message,
conversationId: conversationId.value, attachments
attachments,
toUserId: contact.value.id
}); });
} 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(); 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));
}
} }
} }

View File

@@ -15,11 +15,7 @@ import { useRouter } from 'vue-router';
// import TagsTableView from '../../components/tables/TagsTableView.vue'; // больше не используется // import TagsTableView from '../../components/tables/TagsTableView.vue'; // больше не используется
const router = useRouter(); const router = useRouter();
function goBack() { function goBack() {
if (window.history.length > 1) {
router.back();
} else {
router.push({ name: 'crm' }); router.push({ name: 'crm' });
}
} }
</script> </script>