ваше сообщение коммита
This commit is contained in:
37
MASKING_AND_ENCRYPTION_PLAN.md
Normal file
37
MASKING_AND_ENCRYPTION_PLAN.md
Normal 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.
|
||||||
92
RAG_ASSISTANT_INTEGRATION.md
Normal file
92
RAG_ASSISTANT_INTEGRATION.md
Normal 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 и системный промт.
|
||||||
|
- Возможность расширения под любые бизнес-сценарии.
|
||||||
@@ -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
@@ -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
31
backend/routes/rag.js
Normal 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;
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
104
backend/services/ragService.js
Normal file
104
backend/services/ragService.js
Normal 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 };
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,8 @@ 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>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user