ваше сообщение коммита
This commit is contained in:
78
RAG_TASKS.md
78
RAG_TASKS.md
@@ -126,4 +126,82 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Требования к CRM-интерфейсу для работы с контактами, тегами и настройками RAG-ассистента
|
||||||
|
|
||||||
|
### 1. Раздел "Контакты" в CRM
|
||||||
|
- **Фильтры:**
|
||||||
|
- Новые пользователи (по дате создания или статусу "новый").
|
||||||
|
- Новые входящие сообщения (по наличию непрочитанных/неотвеченных сообщений).
|
||||||
|
- Теги (мультиселект по тегам пользователя).
|
||||||
|
- **Детали контакта:**
|
||||||
|
- Просмотр истории сообщений.
|
||||||
|
- Список тегов пользователя.
|
||||||
|
- Добавление/удаление тегов через выпадающий список или автокомплит (создание связи в таблице user_tags).
|
||||||
|
|
||||||
|
### 2. Настройки ИИ-ассистента
|
||||||
|
- **Выбор RAG-таблиц:**
|
||||||
|
- В настройках ассистента отображается список всех доступных RAG-таблиц.
|
||||||
|
- Администратор выбирает (чекбоксами или мультиселектом), какие таблицы использовать для поиска ответов.
|
||||||
|
- Для каждой выбранной таблицы отображается список тегов, которые она содержит.
|
||||||
|
- **Связь с тегами:**
|
||||||
|
- При генерации ответа ИИ использует только те RAG-таблицы и записи, которые соответствуют тегам пользователя.
|
||||||
|
|
||||||
|
### 3. Рекомендации по интерфейсу (Vue)
|
||||||
|
- Компоненты:
|
||||||
|
- `ContactList.vue` — фильтры, список пользователей
|
||||||
|
- `ContactDetails.vue` — история сообщений, теги, добавление тегов
|
||||||
|
- `AssistantSettings.vue` — выбор RAG-таблиц
|
||||||
|
- `RagTableSelector.vue` — список таблиц с чекбоксами
|
||||||
|
- `TagList.vue` — просмотр тегов в выбранной таблице
|
||||||
|
|
||||||
|
### 4. Схема действий администратора
|
||||||
|
1. В разделе "Контакты" находит нового пользователя/сообщение через фильтры.
|
||||||
|
2. В деталях контакта добавляет нужные теги пользователю.
|
||||||
|
3. В настройках ассистента выбирает, какие RAG-таблицы использовать для поиска по тегам.
|
||||||
|
4. ИИ-ассистент при ответе использует только релевантные RAG-таблицы и теги.
|
||||||
|
|
||||||
|
### 5. Пример структуры таблиц для RAG и тегов
|
||||||
|
- `users` — пользователи
|
||||||
|
- `messages` — сообщения
|
||||||
|
- `tags` — справочник тегов
|
||||||
|
- `user_tags` — связь пользователей и тегов (user_id, tag_id)
|
||||||
|
- `rag_tables` — таблицы знаний (например, FAQ, инструкции)
|
||||||
|
- `rag_entries` — записи в таблицах знаний (content, rag_table_id, ...)
|
||||||
|
- `rag_entry_tags` — связь записей знаний и тегов (rag_entry_id, tag_id)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## План внедрения RAG-ассистента в CRM
|
||||||
|
|
||||||
|
1. **Создать RAG-таблицы для ИИ-ассистента**
|
||||||
|
- Таблицы для хранения знаний о компании, продуктах, услугах (например, `rag_tables`, `rag_entries`).
|
||||||
|
- Возможность добавлять, редактировать, удалять записи через UI.
|
||||||
|
- Каждая запись может быть связана с тегами (например, категория продукта, язык, сегмент клиента).
|
||||||
|
|
||||||
|
2. **Создать таблицы с тегами для пользователей**
|
||||||
|
- Таблица тегов (`tags`).
|
||||||
|
- Связующая таблица `user_tags` (user_id, tag_id).
|
||||||
|
- UI для управления тегами и их привязкой к пользователям.
|
||||||
|
|
||||||
|
3. **Отредактировать страницу настройки ИИ-ассистента**
|
||||||
|
- Добавить выбор, какие RAG-таблицы использовать для поиска.
|
||||||
|
- Отображать список тегов, связанных с выбранными таблицами.
|
||||||
|
- Возможность быстро подключать/отключать таблицы и теги.
|
||||||
|
|
||||||
|
4. **Добавить в раздел "Контакты" фильтры (отдельные компоненты)**
|
||||||
|
- Фильтр по новым пользователям.
|
||||||
|
- Фильтр по новым входящим сообщениям.
|
||||||
|
- Фильтр по тегам (мультиселект).
|
||||||
|
- Каждый фильтр реализовать отдельным Vue-компонентом для переиспользования.
|
||||||
|
|
||||||
|
5. **В "Детали контакта" добавить инлайн-кнопки**
|
||||||
|
- Кнопки:
|
||||||
|
- Сгенерировать (ответ с помощью ИИ)
|
||||||
|
- Редактировать (отредактировать сгенерированный ответ)
|
||||||
|
- Отправить (отправить ответ пользователю)
|
||||||
|
- Добавить в RAG-таблицу (сделать сообщение или ответ частью базы знаний)
|
||||||
|
- Кнопки должны быть доступны для каждого сообщения в истории.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
**Этот документ будет дополняться по мере реализации каждого этапа.**
|
**Этот документ будет дополняться по мере реализации каждого этапа.**
|
||||||
21
backend/db/migrations/029_create_ai_assistant_settings.sql
Normal file
21
backend/db/migrations/029_create_ai_assistant_settings.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS ai_assistant_settings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
system_prompt TEXT,
|
||||||
|
selected_rag_tables INTEGER[],
|
||||||
|
languages TEXT[],
|
||||||
|
model TEXT,
|
||||||
|
rules JSONB,
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_by INTEGER,
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Вставить дефолтную строку (глобальные настройки)
|
||||||
|
INSERT INTO ai_assistant_settings (system_prompt, selected_rag_tables, languages, model, rules)
|
||||||
|
VALUES (
|
||||||
|
'Вы — полезный ассистент. Отвечайте на русском языке.',
|
||||||
|
ARRAY[]::INTEGER[],
|
||||||
|
ARRAY['ru'],
|
||||||
|
'qwen2.5',
|
||||||
|
'{"checkUserTags": true, "searchRagFirst": true, "generateIfNoRag": true, "requireAdminApproval": true}'
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
11
backend/db/migrations/030_create_ai_assistant_rules.sql
Normal file
11
backend/db/migrations/030_create_ai_assistant_rules.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS ai_assistant_rules (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
rules JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE ai_assistant_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS rules_id INTEGER REFERENCES ai_assistant_rules(id);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Добавление недостающих полей для интеграции с Telegram и Email, а также для системного сообщения
|
||||||
|
ALTER TABLE ai_assistant_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS telegram_settings_id INTEGER REFERENCES telegram_settings(id),
|
||||||
|
ADD COLUMN IF NOT EXISTS email_settings_id INTEGER REFERENCES email_settings(id),
|
||||||
|
ADD COLUMN IF NOT EXISTS system_message TEXT;
|
||||||
@@ -6,6 +6,8 @@ const db = require('../db');
|
|||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { requireAuth } = require('../middleware/auth');
|
const { requireAuth } = require('../middleware/auth');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const aiAssistantSettingsService = require('../services/aiAssistantSettingsService');
|
||||||
|
const aiAssistantRulesService = require('../services/aiAssistantRulesService');
|
||||||
|
|
||||||
// Настройка multer для обработки файлов в памяти
|
// Настройка multer для обработки файлов в памяти
|
||||||
const storage = multer.memoryStorage();
|
const storage = multer.memoryStorage();
|
||||||
@@ -61,19 +63,28 @@ async function processGuestMessages(userId, guestId) {
|
|||||||
const guestMessages = guestMessagesResult.rows;
|
const guestMessages = guestMessagesResult.rows;
|
||||||
logger.info(`Found ${guestMessages.length} guest messages for guest ID ${guestId}`);
|
logger.info(`Found ${guestMessages.length} guest messages for guest ID ${guestId}`);
|
||||||
|
|
||||||
// Создаем новый диалог для этих сообщений
|
// --- Новый порядок: ищем последний диалог пользователя ---
|
||||||
const firstMessage = guestMessages[0];
|
let conversation = null;
|
||||||
const title = firstMessage.content
|
const lastConvResult = await db.getQuery()(
|
||||||
? (firstMessage.content.length > 30 ? `${firstMessage.content.substring(0, 30)}...` : firstMessage.content)
|
'SELECT * FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC, created_at DESC LIMIT 1',
|
||||||
: (firstMessage.attachment_filename ? `Файл: ${firstMessage.attachment_filename}` : 'Новый диалог');
|
[userId]
|
||||||
|
|
||||||
const newConversationResult = await db.getQuery()(
|
|
||||||
'INSERT INTO conversations (user_id, title) VALUES ($1, $2) RETURNING *',
|
|
||||||
[userId, title]
|
|
||||||
);
|
);
|
||||||
|
if (lastConvResult.rows.length > 0) {
|
||||||
const conversation = newConversationResult.rows[0];
|
conversation = lastConvResult.rows[0];
|
||||||
logger.info(`Created new conversation ${conversation.id} for guest messages`);
|
} else {
|
||||||
|
// Если нет ни одного диалога, создаём новый
|
||||||
|
const firstMessage = guestMessages[0];
|
||||||
|
const title = firstMessage.content
|
||||||
|
? (firstMessage.content.length > 30 ? `${firstMessage.content.substring(0, 30)}...` : firstMessage.content)
|
||||||
|
: (firstMessage.attachment_filename ? `Файл: ${firstMessage.attachment_filename}` : 'Новый диалог');
|
||||||
|
const newConversationResult = await db.getQuery()(
|
||||||
|
'INSERT INTO conversations (user_id, title) VALUES ($1, $2) RETURNING *',
|
||||||
|
[userId, title]
|
||||||
|
);
|
||||||
|
conversation = newConversationResult.rows[0];
|
||||||
|
logger.info(`Created new conversation ${conversation.id} for guest messages`);
|
||||||
|
}
|
||||||
|
// --- КОНЕЦ блока поиска/создания диалога ---
|
||||||
|
|
||||||
// Отслеживаем успешные сохранения сообщений
|
// Отслеживаем успешные сохранения сообщений
|
||||||
const savedMessageIds = [];
|
const savedMessageIds = [];
|
||||||
@@ -81,7 +92,6 @@ async function processGuestMessages(userId, guestId) {
|
|||||||
// Обрабатываем каждое гостевое сообщение
|
// Обрабатываем каждое гостевое сообщение
|
||||||
for (const guestMessage of guestMessages) {
|
for (const guestMessage of guestMessages) {
|
||||||
logger.info(`Processing guest message ID ${guestMessage.id}: ${guestMessage.content || guestMessage.attachment_filename || '(empty)'}`);
|
logger.info(`Processing guest message ID ${guestMessage.id}: ${guestMessage.content || guestMessage.attachment_filename || '(empty)'}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Сохраняем сообщение пользователя в таблицу messages, включая данные файла
|
// Сохраняем сообщение пользователя в таблицу messages, включая данные файла
|
||||||
const userMessageResult = await db.getQuery()(
|
const userMessageResult = await db.getQuery()(
|
||||||
@@ -103,39 +113,59 @@ async function processGuestMessages(userId, guestId) {
|
|||||||
guestMessage.attachment_data // BYTEA
|
guestMessage.attachment_data // BYTEA
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const savedUserMessage = userMessageResult.rows[0];
|
const savedUserMessage = userMessageResult.rows[0];
|
||||||
logger.info(`Saved user message with ID ${savedUserMessage.id}`);
|
logger.info(`Saved user message with ID ${savedUserMessage.id}`);
|
||||||
savedMessageIds.push(guestMessage.id);
|
savedMessageIds.push(guestMessage.id);
|
||||||
|
// --- Генерируем ответ ИИ на гостевое сообщение, если это текст ---
|
||||||
// Получаем ответ от ИИ только для текстовых сообщений
|
if (guestMessage.content) {
|
||||||
if (!guestMessage.is_ai && guestMessage.content) {
|
// Проверяем, что на это сообщение ещё нет ответа ассистента
|
||||||
logger.info('Getting AI response for:', guestMessage.content);
|
const aiReplyExists = await db.getQuery()(
|
||||||
const language = guestMessage.language || 'auto';
|
`SELECT 1 FROM messages WHERE conversation_id = $1 AND sender_type = 'assistant' AND created_at > $2 LIMIT 1`,
|
||||||
// Предполагаем, что aiAssistant.getResponse принимает только текст
|
[conversation.id, guestMessage.created_at]
|
||||||
const aiResponseContent = await aiAssistant.getResponse(guestMessage.content, language);
|
);
|
||||||
logger.info('AI response received' + (aiResponseContent ? '' : ' (empty)'), 'for conversation', conversation.id);
|
if (!aiReplyExists.rows.length) {
|
||||||
|
try {
|
||||||
if (aiResponseContent) {
|
// Получаем настройки ассистента
|
||||||
// Сохраняем ответ от ИИ (у него нет вложений)
|
const aiSettings = await aiAssistantSettingsService.getSettings();
|
||||||
const aiMessageResult = await db.getQuery()(
|
let rules = null;
|
||||||
`INSERT INTO messages
|
if (aiSettings && aiSettings.rules_id) {
|
||||||
(conversation_id, content, sender_type, role, channel, created_at, user_id)
|
rules = await aiAssistantRulesService.getRuleById(aiSettings.rules_id);
|
||||||
VALUES
|
}
|
||||||
($1, $2, 'assistant', 'assistant', 'web', $3, $4)
|
// Получаем историю сообщений до этого guestMessage (до created_at)
|
||||||
RETURNING *`,
|
const historyResult = await db.getQuery()(
|
||||||
[
|
'SELECT sender_type, content FROM messages WHERE conversation_id = $1 AND created_at < $2 ORDER BY created_at DESC LIMIT 10',
|
||||||
conversation.id,
|
[conversation.id, guestMessage.created_at]
|
||||||
aiResponseContent,
|
|
||||||
new Date(),
|
|
||||||
userId
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
logger.info(`Saved AI response with ID ${aiMessageResult.rows[0].id}`);
|
const history = historyResult.rows.reverse().map(msg => ({
|
||||||
|
role: msg.sender_type === 'user' ? 'user' : 'assistant',
|
||||||
|
content: msg.content
|
||||||
|
}));
|
||||||
|
// Язык guestMessage.language или auto
|
||||||
|
const detectedLanguage = guestMessage.language === 'auto' ? aiAssistant.detectLanguage(guestMessage.content) : guestMessage.language;
|
||||||
|
logger.info('Getting AI response for guest message:', guestMessage.content);
|
||||||
|
const aiResponseContent = await aiAssistant.getResponse(
|
||||||
|
guestMessage.content,
|
||||||
|
detectedLanguage,
|
||||||
|
history,
|
||||||
|
aiSettings ? aiSettings.system_prompt : '',
|
||||||
|
rules ? rules.rules : null
|
||||||
|
);
|
||||||
|
logger.info('AI response for guest message received' + (aiResponseContent ? '' : ' (empty)'), { conversationId: conversation.id });
|
||||||
|
if (aiResponseContent) {
|
||||||
|
await db.getQuery()(
|
||||||
|
`INSERT INTO messages
|
||||||
|
(conversation_id, user_id, content, sender_type, role, channel)
|
||||||
|
VALUES ($1, $2, $3, 'assistant', 'assistant', 'web')`,
|
||||||
|
[conversation.id, userId, aiResponseContent]
|
||||||
|
);
|
||||||
|
logger.info('AI response for guest message saved', { conversationId: conversation.id });
|
||||||
|
}
|
||||||
|
} catch (aiError) {
|
||||||
|
logger.error('Error getting or saving AI response for guest message:', aiError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logger.info(`Skipping AI response for guest message ID ${guestMessage.id} (is_ai: ${guestMessage.is_ai}, hasContent: ${!!guestMessage.content})`);
|
|
||||||
}
|
}
|
||||||
|
// --- конец блока генерации ответа ИИ ---
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error processing guest message ${guestMessage.id}: ${error.message}`, { stack: error.stack });
|
logger.error(`Error processing guest message ${guestMessage.id}: ${error.message}`, { stack: error.stack });
|
||||||
// Продолжаем с другими сообщениями в случае ошибки
|
// Продолжаем с другими сообщениями в случае ошибки
|
||||||
@@ -254,10 +284,28 @@ router.post('/guest-message', upload.array('attachments'), async (req, res) => {
|
|||||||
// Не прерываем ответ пользователю из-за ошибки сессии
|
// Не прерываем ответ пользователю из-за ошибки сессии
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Получаем настройки ассистента для systemMessage
|
||||||
|
let telegramBotUrl = null;
|
||||||
|
let supportEmailAddr = null;
|
||||||
|
try {
|
||||||
|
const aiSettings = await aiAssistantSettingsService.getSettings();
|
||||||
|
if (aiSettings && aiSettings.telegramBot && aiSettings.telegramBot.bot_username) {
|
||||||
|
telegramBotUrl = `https://t.me/${aiSettings.telegramBot.bot_username}`;
|
||||||
|
}
|
||||||
|
if (aiSettings && aiSettings.supportEmail && aiSettings.supportEmail.from_email) {
|
||||||
|
supportEmailAddr = aiSettings.supportEmail.from_email;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Ошибка получения настроек ассистента для systemMessage:', e);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
messageId: savedMessageId, // Возвращаем ID сохраненного сообщения
|
messageId: savedMessageId, // Возвращаем ID сохраненного сообщения
|
||||||
guestId: guestId // Возвращаем использованный guestId
|
guestId: guestId, // Возвращаем использованный guestId
|
||||||
|
systemMessage: 'Для продолжения диалога авторизуйтесь: подключите кошелек, перейдите в чат-бот Telegram или отправьте письмо на email.',
|
||||||
|
telegramBotUrl,
|
||||||
|
supportEmail: supportEmailAddr
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error saving guest message:', error);
|
logger.error('Error saving guest message:', error);
|
||||||
@@ -303,18 +351,27 @@ router.post('/message', requireAuth, upload.array('attachments'), async (req, re
|
|||||||
}
|
}
|
||||||
conversation = convResult.rows[0];
|
conversation = convResult.rows[0];
|
||||||
} else {
|
} else {
|
||||||
// Создаем новый диалог, если ID не предоставлен
|
// Ищем последний диалог пользователя
|
||||||
const title = message
|
const lastConvResult = await db.getQuery()(
|
||||||
? (message.length > 50 ? `${message.substring(0, 50)}...` : message)
|
'SELECT * FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC, created_at DESC LIMIT 1',
|
||||||
: (file ? `Файл: ${file.originalname}` : 'Новый диалог');
|
[userId]
|
||||||
|
|
||||||
const newConvResult = await db.getQuery()(
|
|
||||||
'INSERT INTO conversations (user_id, title) VALUES ($1, $2) RETURNING *',
|
|
||||||
[userId, title]
|
|
||||||
);
|
);
|
||||||
conversation = newConvResult.rows[0];
|
if (lastConvResult.rows.length > 0) {
|
||||||
conversationId = conversation.id;
|
conversation = lastConvResult.rows[0];
|
||||||
logger.info('Created new conversation', { conversationId, userId });
|
conversationId = conversation.id;
|
||||||
|
} else {
|
||||||
|
// Создаем новый диалог, если нет ни одного
|
||||||
|
const title = message
|
||||||
|
? (message.length > 50 ? `${message.substring(0, 50)}...` : message)
|
||||||
|
: (file ? `Файл: ${file.originalname}` : 'Новый диалог');
|
||||||
|
const newConvResult = await db.getQuery()(
|
||||||
|
'INSERT INTO conversations (user_id, title) VALUES ($1, $2) RETURNING *',
|
||||||
|
[userId, title]
|
||||||
|
);
|
||||||
|
conversation = newConvResult.rows[0];
|
||||||
|
conversationId = conversation.id;
|
||||||
|
logger.info('Created new conversation', { conversationId, userId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Подготавливаем данные для вставки сообщения пользователя
|
// Подготавливаем данные для вставки сообщения пользователя
|
||||||
@@ -348,9 +405,32 @@ router.post('/message', requireAuth, upload.array('attachments'), async (req, re
|
|||||||
let aiMessage = null;
|
let aiMessage = null;
|
||||||
if (messageContent) { // Только для текстовых сообщений
|
if (messageContent) { // Только для текстовых сообщений
|
||||||
try {
|
try {
|
||||||
|
// Получаем настройки ассистента
|
||||||
|
const aiSettings = await aiAssistantSettingsService.getSettings();
|
||||||
|
let rules = null;
|
||||||
|
if (aiSettings && aiSettings.rules_id) {
|
||||||
|
rules = await aiAssistantRulesService.getRuleById(aiSettings.rules_id);
|
||||||
|
}
|
||||||
|
logger.info('AI System Prompt:', aiSettings ? aiSettings.system_prompt : 'not set');
|
||||||
|
logger.info('AI Rules:', rules ? JSON.stringify(rules.rules) : 'not set');
|
||||||
|
// Получаем последние 10 сообщений из диалога для истории (до текущего сообщения)
|
||||||
|
const historyResult = await db.getQuery()(
|
||||||
|
'SELECT sender_type, content FROM messages WHERE conversation_id = $1 AND id < $2 ORDER BY created_at DESC LIMIT 10',
|
||||||
|
[conversationId, userMessage.id]
|
||||||
|
);
|
||||||
|
const history = historyResult.rows.reverse().map(msg => ({
|
||||||
|
role: msg.sender_type === 'user' ? 'user' : 'assistant',
|
||||||
|
content: msg.content
|
||||||
|
}));
|
||||||
const detectedLanguage = language === 'auto' ? aiAssistant.detectLanguage(messageContent) : language;
|
const detectedLanguage = language === 'auto' ? aiAssistant.detectLanguage(messageContent) : language;
|
||||||
logger.info('Getting AI response for:', messageContent);
|
logger.info('Getting AI response for:', messageContent);
|
||||||
const aiResponseContent = await aiAssistant.getResponse(messageContent, detectedLanguage);
|
const aiResponseContent = await aiAssistant.getResponse(
|
||||||
|
messageContent,
|
||||||
|
detectedLanguage,
|
||||||
|
history,
|
||||||
|
aiSettings ? aiSettings.system_prompt : '',
|
||||||
|
rules ? rules.rules : null
|
||||||
|
);
|
||||||
logger.info('AI response received' + (aiResponseContent ? '' : ' (empty)'), { conversationId });
|
logger.info('AI response received' + (aiResponseContent ? '' : ' (empty)'), { conversationId });
|
||||||
|
|
||||||
if (aiResponseContent) {
|
if (aiResponseContent) {
|
||||||
@@ -396,6 +476,12 @@ router.post('/message', requireAuth, upload.array('attachments'), async (req, re
|
|||||||
return formatted;
|
return formatted;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Обновляем updated_at у диалога
|
||||||
|
await db.getQuery()(
|
||||||
|
'UPDATE conversations SET updated_at = NOW() WHERE id = $1',
|
||||||
|
[conversationId]
|
||||||
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
conversationId: conversationId,
|
conversationId: conversationId,
|
||||||
@@ -541,6 +627,26 @@ router.get('/history', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Новый роут для связывания гостя после аутентификации ---
|
||||||
|
router.post('/process-guest', requireAuth, async (req, res) => {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const { guestId } = req.body;
|
||||||
|
if (!guestId) {
|
||||||
|
return res.status(400).json({ success: false, error: 'guestId is required' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await module.exports.processGuestMessages(userId, guestId);
|
||||||
|
if (result && result.conversationId) {
|
||||||
|
return res.json({ success: true, conversationId: result.conversationId });
|
||||||
|
} else {
|
||||||
|
return res.json({ success: false, error: result.error || 'No conversation created' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in /process-guest:', error);
|
||||||
|
return res.status(500).json({ success: false, error: 'Internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Экспортируем маршрутизатор и функцию processGuestMessages отдельно
|
// Экспортируем маршрутизатор и функцию processGuestMessages отдельно
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
module.exports.processGuestMessages = processGuestMessages;
|
module.exports.processGuestMessages = processGuestMessages;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const authTokenService = require('../services/authTokenService');
|
|||||||
const aiProviderSettingsService = require('../services/aiProviderSettingsService');
|
const aiProviderSettingsService = require('../services/aiProviderSettingsService');
|
||||||
const aiAssistant = require('../services/ai-assistant');
|
const aiAssistant = require('../services/ai-assistant');
|
||||||
const dns = require('node:dns').promises;
|
const dns = require('node:dns').promises;
|
||||||
|
const aiAssistantSettingsService = require('../services/aiAssistantSettingsService');
|
||||||
|
const aiAssistantRulesService = require('../services/aiAssistantRulesService');
|
||||||
|
|
||||||
// Логируем версию ethers для отладки
|
// Логируем версию ethers для отладки
|
||||||
logger.info(`Ethers version: ${ethers.version || 'unknown'}`);
|
logger.info(`Ethers version: ${ethers.version || 'unknown'}`);
|
||||||
@@ -239,4 +241,92 @@ router.post('/ai-settings/:provider/verify', requireAdmin, async (req, res, next
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/ai-assistant', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const settings = await aiAssistantSettingsService.getSettings();
|
||||||
|
res.json({ success: true, settings });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/ai-assistant', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const updated = await aiAssistantSettingsService.upsertSettings({ ...req.body, updated_by: req.session.userId || null });
|
||||||
|
res.json({ success: true, settings: updated });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получить все наборы правил
|
||||||
|
router.get('/ai-assistant-rules', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rules = await aiAssistantRulesService.getAllRules();
|
||||||
|
res.json({ success: true, rules });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получить набор правил по id
|
||||||
|
router.get('/ai-assistant-rules/:id', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rule = await aiAssistantRulesService.getRuleById(req.params.id);
|
||||||
|
res.json({ success: true, rule });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Создать набор правил
|
||||||
|
router.post('/ai-assistant-rules', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const created = await aiAssistantRulesService.createRule(req.body);
|
||||||
|
res.json({ success: true, rule: created });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновить набор правил
|
||||||
|
router.put('/ai-assistant-rules/:id', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const updated = await aiAssistantRulesService.updateRule(req.params.id, req.body);
|
||||||
|
res.json({ success: true, rule: updated });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Удалить набор правил
|
||||||
|
router.delete('/ai-assistant-rules/:id', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await aiAssistantRulesService.deleteRule(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получить все email_settings для выпадающего списка
|
||||||
|
router.get('/email-settings', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await require('../db').getQuery()('SELECT id, from_email FROM email_settings ORDER BY id');
|
||||||
|
res.json({ success: true, items: rows });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получить все telegram_settings для выпадающего списка
|
||||||
|
router.get('/telegram-settings', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await require('../db').getQuery()('SELECT id, bot_username FROM telegram_settings ORDER BY id');
|
||||||
|
res.json({ success: true, items: rows });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -34,30 +34,51 @@ class AIAssistant {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Основной метод для получения ответа
|
// Основной метод для получения ответа
|
||||||
async getResponse(message, language = 'auto') {
|
async getResponse(message, language = 'auto', history = null, systemPrompt = '', rules = null) {
|
||||||
try {
|
try {
|
||||||
console.log('getResponse called with:', { message, language });
|
console.log('getResponse called with:', { message, language, history, systemPrompt, rules });
|
||||||
|
|
||||||
// Определяем язык, если не указан явно
|
// Определяем язык, если не указан явно
|
||||||
const detectedLanguage = language === 'auto' ? this.detectLanguage(message) : language;
|
const detectedLanguage = language === 'auto' ? this.detectLanguage(message) : language;
|
||||||
|
|
||||||
console.log('Detected language:', detectedLanguage);
|
console.log('Detected language:', detectedLanguage);
|
||||||
|
|
||||||
// Сначала пробуем прямой API запрос
|
// Формируем system prompt с учётом правил
|
||||||
|
let fullSystemPrompt = systemPrompt || '';
|
||||||
|
if (rules && typeof rules === 'object') {
|
||||||
|
fullSystemPrompt += '\n' + JSON.stringify(rules, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем массив сообщений для Qwen2.5/OpenAI API
|
||||||
|
const messages = [];
|
||||||
|
if (fullSystemPrompt) {
|
||||||
|
messages.push({ role: 'system', content: fullSystemPrompt });
|
||||||
|
}
|
||||||
|
if (Array.isArray(history) && history.length > 0) {
|
||||||
|
for (const msg of history) {
|
||||||
|
if (msg.role && msg.content) {
|
||||||
|
messages.push({ role: msg.role, content: msg.content });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Добавляем текущее сообщение пользователя
|
||||||
|
messages.push({ role: 'user', content: message });
|
||||||
|
|
||||||
|
// Пробуем прямой API запрос (OpenAI-совместимый endpoint)
|
||||||
try {
|
try {
|
||||||
console.log('Trying direct API request...');
|
console.log('Trying direct API request...');
|
||||||
const response = await this.fallbackRequest(message, detectedLanguage);
|
const response = await this.fallbackRequestOpenAI(messages, detectedLanguage);
|
||||||
console.log('Direct API response received:', response);
|
console.log('Direct API response received:', response);
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in direct API request:', error);
|
console.error('Error in direct API request:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если прямой запрос не удался, пробуем через ChatOllama
|
// Если прямой запрос не удался, пробуем через ChatOllama (склеиваем сообщения в текст)
|
||||||
const chat = this.createChat(detectedLanguage);
|
const chat = this.createChat(detectedLanguage);
|
||||||
try {
|
try {
|
||||||
|
const prompt = messages.map(m => `${m.role === 'user' ? 'Пользователь' : m.role === 'assistant' ? 'Ассистент' : 'Система'}: ${m.content}`).join('\n');
|
||||||
console.log('Sending request to ChatOllama...');
|
console.log('Sending request to ChatOllama...');
|
||||||
const response = await chat.invoke(message);
|
const response = await chat.invoke(prompt);
|
||||||
console.log('ChatOllama response:', response);
|
console.log('ChatOllama response:', response);
|
||||||
return response.content;
|
return response.content;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -70,24 +91,17 @@ class AIAssistant {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Альтернативный метод запроса через прямой API
|
// Новый метод для OpenAI/Qwen2.5 совместимого endpoint
|
||||||
async fallbackRequest(message, language) {
|
async fallbackRequestOpenAI(messages, language) {
|
||||||
try {
|
try {
|
||||||
console.log('Using fallback request method with:', { message, language });
|
console.log('Using fallbackRequestOpenAI with:', { messages, language });
|
||||||
|
const model = this.defaultModel;
|
||||||
const systemPrompt =
|
const response = await fetch(`${this.baseUrl}/v1/chat/completions`, {
|
||||||
language === 'ru'
|
|
||||||
? 'Вы - полезный ассистент. Отвечайте на русском языке.'
|
|
||||||
: 'You are a helpful assistant. Respond in English.';
|
|
||||||
|
|
||||||
console.log('Sending request to Ollama API...');
|
|
||||||
const response = await fetch(`${this.baseUrl}/api/generate`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: this.defaultModel,
|
model,
|
||||||
prompt: message,
|
messages,
|
||||||
system: systemPrompt,
|
|
||||||
stream: false,
|
stream: false,
|
||||||
options: {
|
options: {
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
@@ -95,16 +109,17 @@ class AIAssistant {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('Ollama API response:', data);
|
// Qwen2.5/OpenAI API возвращает ответ в data.choices[0].message.content
|
||||||
return data.response;
|
if (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) {
|
||||||
|
return data.choices[0].message.content;
|
||||||
|
}
|
||||||
|
return data.response || '';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in fallback request:', error);
|
console.error('Error in fallbackRequestOpenAI:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
backend/services/aiAssistantRulesService.js
Normal file
35
backend/services/aiAssistantRulesService.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
const db = require('../db');
|
||||||
|
const TABLE = 'ai_assistant_rules';
|
||||||
|
|
||||||
|
async function getAllRules() {
|
||||||
|
const { rows } = await db.getQuery()(`SELECT * FROM ${TABLE} ORDER BY id`);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRuleById(id) {
|
||||||
|
const { rows } = await db.getQuery()(`SELECT * FROM ${TABLE} WHERE id = $1`, [id]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRule({ name, description, rules }) {
|
||||||
|
const { rows } = await db.getQuery()(
|
||||||
|
`INSERT INTO ${TABLE} (name, description, rules, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, NOW(), NOW()) RETURNING *`,
|
||||||
|
[name, description, rules]
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateRule(id, { name, description, rules }) {
|
||||||
|
const { rows } = await db.getQuery()(
|
||||||
|
`UPDATE ${TABLE} SET name = $1, description = $2, rules = $3, updated_at = NOW() WHERE id = $4 RETURNING *`,
|
||||||
|
[name, description, rules, id]
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRule(id) {
|
||||||
|
await db.getQuery()(`DELETE FROM ${TABLE} WHERE id = $1`, [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getAllRules, getRuleById, createRule, updateRule, deleteRule };
|
||||||
48
backend/services/aiAssistantSettingsService.js
Normal file
48
backend/services/aiAssistantSettingsService.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const db = require('../db');
|
||||||
|
const TABLE = 'ai_assistant_settings';
|
||||||
|
|
||||||
|
async function getSettings() {
|
||||||
|
const { rows } = await db.getQuery()(`SELECT * FROM ${TABLE} ORDER BY id LIMIT 1`);
|
||||||
|
const settings = rows[0] || null;
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
// Получаем связанные данные из telegram_settings и email_settings
|
||||||
|
let telegramBot = null;
|
||||||
|
let supportEmail = null;
|
||||||
|
if (settings.telegram_settings_id) {
|
||||||
|
const tg = await db.getQuery()('SELECT * FROM telegram_settings WHERE id = $1', [settings.telegram_settings_id]);
|
||||||
|
telegramBot = tg.rows[0] || null;
|
||||||
|
}
|
||||||
|
if (settings.email_settings_id) {
|
||||||
|
const em = await db.getQuery()('SELECT * FROM email_settings WHERE id = $1', [settings.email_settings_id]);
|
||||||
|
supportEmail = em.rows[0] || null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...settings,
|
||||||
|
telegramBot,
|
||||||
|
supportEmail
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertSettings({ system_prompt, selected_rag_tables, languages, model, rules, updated_by, telegram_settings_id, email_settings_id, system_message }) {
|
||||||
|
const { rows } = await db.getQuery()(
|
||||||
|
`INSERT INTO ${TABLE} (id, system_prompt, selected_rag_tables, languages, model, rules, updated_at, updated_by, telegram_settings_id, email_settings_id, system_message)
|
||||||
|
VALUES (1, $1, $2, $3, $4, $5, NOW(), $6, $7, $8, $9)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
system_prompt = EXCLUDED.system_prompt,
|
||||||
|
selected_rag_tables = EXCLUDED.selected_rag_tables,
|
||||||
|
languages = EXCLUDED.languages,
|
||||||
|
model = EXCLUDED.model,
|
||||||
|
rules = EXCLUDED.rules,
|
||||||
|
updated_at = NOW(),
|
||||||
|
updated_by = EXCLUDED.updated_by,
|
||||||
|
telegram_settings_id = EXCLUDED.telegram_settings_id,
|
||||||
|
email_settings_id = EXCLUDED.email_settings_id,
|
||||||
|
system_message = EXCLUDED.system_message
|
||||||
|
RETURNING *`,
|
||||||
|
[system_prompt, selected_rag_tables, languages, model, rules, updated_by, telegram_settings_id, email_settings_id, system_message]
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getSettings, upsertSettings };
|
||||||
@@ -15,6 +15,12 @@
|
|||||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<div v-if="message.content" class="message-content" v-html="formattedContent" />
|
<div v-if="message.content" class="message-content" v-html="formattedContent" />
|
||||||
|
|
||||||
|
<!-- Кнопки для системного сообщения -->
|
||||||
|
<div v-if="message.sender_type === 'system' && (message.telegramBotUrl || message.supportEmail)" class="system-actions">
|
||||||
|
<button v-if="message.telegramBotUrl" @click="openTelegram(message.telegramBotUrl)" class="system-btn">Перейти в Telegram-бот</button>
|
||||||
|
<button v-if="message.supportEmail" @click="copyEmail(message.supportEmail)" class="system-btn">Скопировать email</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Блок для отображения прикрепленного файла (теперь с плеерами/изображением/ссылкой) -->
|
<!-- Блок для отображения прикрепленного файла (теперь с плеерами/изображением/ссылкой) -->
|
||||||
<div v-if="attachment" class="message-attachments">
|
<div v-if="attachment" class="message-attachments">
|
||||||
<div class="attachment-item">
|
<div class="attachment-item">
|
||||||
@@ -168,6 +174,14 @@ const formatFileSize = (bytes) => {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function openTelegram(url) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
function copyEmail(email) {
|
||||||
|
navigator.clipboard.writeText(email);
|
||||||
|
// Можно добавить уведомление "Email скопирован"
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -360,4 +374,23 @@ const formatFileSize = (bytes) => {
|
|||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.system-btn {
|
||||||
|
background: var(--color-primary, #3b82f6);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1em;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.system-btn:hover {
|
||||||
|
background: var(--color-primary-dark, #2563eb);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
122
frontend/src/components/ai-assistant/RuleEditor.vue
Normal file
122
frontend/src/components/ai-assistant/RuleEditor.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<div class='modal-bg'>
|
||||||
|
<div class='modal'>
|
||||||
|
<h3>{{ rule ? 'Редактировать' : 'Создать' }} набор правил</h3>
|
||||||
|
<label>Название</label>
|
||||||
|
<input v-model="name" />
|
||||||
|
<label>Описание</label>
|
||||||
|
<textarea v-model="description" rows="3" placeholder="Опишите правило в свободной форме" />
|
||||||
|
<button type="button" @click="convertToJson" style="margin: 0.5rem 0;">Преобразовать в JSON</button>
|
||||||
|
<label>Правила (JSON)</label>
|
||||||
|
<textarea v-model="rulesJson" rows="6"></textarea>
|
||||||
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button @click="save">Сохранить</button>
|
||||||
|
<button @click="close">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
const props = defineProps({ rule: Object });
|
||||||
|
const name = ref(props.rule ? props.rule.name : '');
|
||||||
|
const description = ref(props.rule ? props.rule.description : '');
|
||||||
|
const rulesJson = ref(props.rule ? JSON.stringify(props.rule.rules, null, 2) : '{\n "checkUserTags": true\n}');
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
watch(() => props.rule, (newRule) => {
|
||||||
|
name.value = newRule ? newRule.name : '';
|
||||||
|
description.value = newRule ? newRule.description : '';
|
||||||
|
rulesJson.value = newRule ? JSON.stringify(newRule.rules, null, 2) : '{\n "checkUserTags": true\n}';
|
||||||
|
});
|
||||||
|
|
||||||
|
function convertToJson() {
|
||||||
|
// Простейший пример: если в описании есть "теги", выставляем checkUserTags
|
||||||
|
// В реальном проекте здесь можно интегрировать LLM или шаблоны
|
||||||
|
try {
|
||||||
|
if (/тег[а-я]* пользов/.test(description.value.toLowerCase())) {
|
||||||
|
rulesJson.value = JSON.stringify({ checkUserTags: true }, null, 2);
|
||||||
|
error.value = '';
|
||||||
|
} else {
|
||||||
|
rulesJson.value = JSON.stringify({ customRule: description.value }, null, 2);
|
||||||
|
error.value = '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = 'Не удалось преобразовать описание в JSON';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
let rules;
|
||||||
|
try {
|
||||||
|
rules = JSON.parse(rulesJson.value);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = 'Ошибка в формате JSON!';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.rule && props.rule.id) {
|
||||||
|
await axios.put(`/api/settings/ai-assistant-rules/${props.rule.id}`, { name: name.value, description: description.value, rules });
|
||||||
|
} else {
|
||||||
|
await axios.post('/api/settings/ai-assistant-rules', { name: name.value, description: description.value, rules });
|
||||||
|
}
|
||||||
|
emit('close', true);
|
||||||
|
}
|
||||||
|
function close() { emit('close', false); }
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.modal-bg {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.25);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 16px rgba(0,0,0,0.12);
|
||||||
|
padding: 2rem;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
input, textarea {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
button:last-child {
|
||||||
|
background: #eee;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #c00;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,15 @@ import api from '../api/axios';
|
|||||||
import { getFromStorage, setToStorage, removeFromStorage } from '../utils/storage';
|
import { getFromStorage, setToStorage, removeFromStorage } from '../utils/storage';
|
||||||
import { generateUniqueId } from '../utils/helpers';
|
import { generateUniqueId } from '../utils/helpers';
|
||||||
|
|
||||||
|
function initGuestId() {
|
||||||
|
let id = getFromStorage('guestId', '');
|
||||||
|
if (!id) {
|
||||||
|
id = generateUniqueId();
|
||||||
|
setToStorage('guestId', id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
export function useChat(auth) {
|
export function useChat(auth) {
|
||||||
const messages = ref([]);
|
const messages = ref([]);
|
||||||
const newMessage = ref('');
|
const newMessage = ref('');
|
||||||
@@ -20,7 +29,7 @@ export function useChat(auth) {
|
|||||||
isLinkingGuest: false, // Флаг для процесса связывания гостевых сообщений (пока не используется активно)
|
isLinkingGuest: false, // Флаг для процесса связывания гостевых сообщений (пока не используется активно)
|
||||||
});
|
});
|
||||||
|
|
||||||
const guestId = ref(getFromStorage('guestId', ''));
|
const guestId = ref(initGuestId());
|
||||||
|
|
||||||
const shouldLoadHistory = computed(() => {
|
const shouldLoadHistory = computed(() => {
|
||||||
return auth.isAuthenticated.value || !!guestId.value;
|
return auth.isAuthenticated.value || !!guestId.value;
|
||||||
@@ -133,7 +142,7 @@ export function useChat(auth) {
|
|||||||
// Очищаем гостевые данные после успешной аутентификации и загрузки
|
// Очищаем гостевые данные после успешной аутентификации и загрузки
|
||||||
if (authType) {
|
if (authType) {
|
||||||
removeFromStorage('guestMessages');
|
removeFromStorage('guestMessages');
|
||||||
removeFromStorage('guestId');
|
// removeFromStorage('guestId'); // Удаление guestId теперь только после успешного связывания
|
||||||
guestId.value = '';
|
guestId.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +228,7 @@ export function useChat(auth) {
|
|||||||
let apiUrl = '/api/chat/message';
|
let apiUrl = '/api/chat/message';
|
||||||
if (isGuestMessage) {
|
if (isGuestMessage) {
|
||||||
if (!guestId.value) {
|
if (!guestId.value) {
|
||||||
guestId.value = generateUniqueId();
|
guestId.value = initGuestId();
|
||||||
setToStorage('guestId', guestId.value);
|
setToStorage('guestId', guestId.value);
|
||||||
}
|
}
|
||||||
formData.append('guestId', guestId.value);
|
formData.append('guestId', guestId.value);
|
||||||
@@ -254,6 +263,20 @@ export function useChat(auth) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Добавляем системное сообщение для гостя (только на клиенте, не сохраняется в истории)
|
||||||
|
if (isGuestMessage && response.data.systemMessage) {
|
||||||
|
messages.value.push({
|
||||||
|
id: `system-${Date.now()}`,
|
||||||
|
content: response.data.systemMessage,
|
||||||
|
sender_type: 'system',
|
||||||
|
role: 'system',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
isSystem: true,
|
||||||
|
telegramBotUrl: response.data.telegramBotUrl,
|
||||||
|
supportEmail: response.data.supportEmail
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Сохраняем гостевое сообщение (если нужно)
|
// Сохраняем гостевое сообщение (если нужно)
|
||||||
// В текущей реализации HomeView гостевые сообщения из localstorage загружаются только при старте
|
// В текущей реализации HomeView гостевые сообщения из localstorage загружаются только при старте
|
||||||
// Если нужна синхронизация после отправки, логику нужно добавить/изменить
|
// Если нужна синхронизация после отправки, логику нужно добавить/изменить
|
||||||
@@ -325,6 +348,23 @@ export function useChat(auth) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Связывание гостевых сообщений после аутентификации ---
|
||||||
|
const linkGuestMessagesAfterAuth = async () => {
|
||||||
|
if (!guestId.value) return;
|
||||||
|
try {
|
||||||
|
const response = await api.post('/api/chat/process-guest', { guestId: guestId.value });
|
||||||
|
if (response.data.success && response.data.conversationId) {
|
||||||
|
// Можно сразу загрузить историю по этому диалогу, если нужно
|
||||||
|
await loadMessages({ initial: true });
|
||||||
|
// Удаляем guestId только после успешного связывания
|
||||||
|
removeFromStorage('guestId');
|
||||||
|
guestId.value = '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useChat] Ошибка связывания гостевых сообщений:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// --- Watchers ---
|
// --- Watchers ---
|
||||||
// Сортировка сообщений при изменении
|
// Сортировка сообщений при изменении
|
||||||
watch(messages, (newMessages) => {
|
watch(messages, (newMessages) => {
|
||||||
@@ -379,5 +419,6 @@ export function useChat(auth) {
|
|||||||
loadMessages,
|
loadMessages,
|
||||||
handleSendMessage,
|
handleSendMessage,
|
||||||
loadGuestMessagesFromStorage, // Экспортируем на всякий случай
|
loadGuestMessagesFromStorage, // Экспортируем на всякий случай
|
||||||
|
linkGuestMessagesAfterAuth, // Экспортируем для вызова после авторизации
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
messageLoading,
|
messageLoading,
|
||||||
loadMessages,
|
loadMessages,
|
||||||
handleSendMessage,
|
handleSendMessage,
|
||||||
|
linkGuestMessagesAfterAuth,
|
||||||
} = useChat(auth);
|
} = useChat(auth);
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -91,10 +92,12 @@
|
|||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
// Функция обновления сообщений после авторизации
|
// Функция обновления сообщений после авторизации
|
||||||
const handleAuthEvent = (eventData) => {
|
const handleAuthEvent = async (eventData) => {
|
||||||
console.log('[HomeView] Получено событие изменения авторизации:', eventData);
|
console.log('[HomeView] Получено событие изменения авторизации:', eventData);
|
||||||
if (eventData.isAuthenticated) {
|
if (eventData.isAuthenticated) {
|
||||||
// Пользователь только что авторизовался - загрузим сообщения
|
// Сначала связываем гостевые сообщения, если есть
|
||||||
|
await linkGuestMessagesAfterAuth();
|
||||||
|
// Затем загружаем сообщения (если не было гостя, просто загрузка)
|
||||||
loadMessages({ initial: true, authType: eventData.authType || 'wallet' });
|
loadMessages({ initial: true, authType: eventData.authType || 'wallet' });
|
||||||
} else {
|
} else {
|
||||||
// Пользователь вышел из системы - можно очистить или обновить данные
|
// Пользователь вышел из системы - можно очистить или обновить данные
|
||||||
|
|||||||
211
frontend/src/views/settings/AiAssistantSettings.vue
Normal file
211
frontend/src/views/settings/AiAssistantSettings.vue
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-assistant-settings-modal">
|
||||||
|
<h2>Настройки ИИ-ассистента</h2>
|
||||||
|
<form @submit.prevent="saveSettings">
|
||||||
|
<label>Системный промт</label>
|
||||||
|
<textarea v-model="settings.system_prompt" rows="3" />
|
||||||
|
<label>Языки</label>
|
||||||
|
<input v-model="languagesInput" placeholder="ru, en, es" />
|
||||||
|
<label>Модель</label>
|
||||||
|
<input v-model="settings.model" placeholder="qwen2.5" />
|
||||||
|
<label>Выбранные RAG-таблицы</label>
|
||||||
|
<select v-model="settings.selected_rag_tables" multiple>
|
||||||
|
<option v-for="table in userTables" :key="table.id" :value="table.id">
|
||||||
|
{{ table.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<label>Набор правил</label>
|
||||||
|
<div class="rules-row">
|
||||||
|
<select v-model="settings.rules_id">
|
||||||
|
<option v-for="rule in rulesList" :key="rule.id" :value="rule.id">
|
||||||
|
{{ rule.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" @click="openRuleEditor()">Создать</button>
|
||||||
|
<button type="button" :disabled="!settings.rules_id" @click="openRuleEditor(settings.rules_id)">Редактировать</button>
|
||||||
|
<button type="button" :disabled="!settings.rules_id" @click="deleteRule(settings.rules_id)">Удалить</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedRule">
|
||||||
|
<p><b>Описание:</b> {{ selectedRule.description }}</p>
|
||||||
|
<pre class="rules-json">{{ JSON.stringify(selectedRule.rules, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
<label>Telegram-бот</label>
|
||||||
|
<select v-model="settings.telegram_settings_id">
|
||||||
|
<option v-for="tg in telegramBots" :key="tg.id" :value="tg.id">
|
||||||
|
{{ tg.bot_username }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<label>Email для связи</label>
|
||||||
|
<select v-model="settings.email_settings_id">
|
||||||
|
<option v-for="em in emailList" :key="em.id" :value="em.id">
|
||||||
|
{{ em.from_email }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Сохранить</button>
|
||||||
|
<button type="button" @click="emit('cancel')">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<RuleEditor v-if="showRuleEditor" :rule="editingRule" @close="onRuleEditorClose" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import RuleEditor from '../../components/ai-assistant/RuleEditor.vue';
|
||||||
|
const emit = defineEmits(['cancel']);
|
||||||
|
const settings = ref({ system_prompt: '', model: '', selected_rag_tables: [], languages: [], rules_id: null });
|
||||||
|
const languagesInput = ref('');
|
||||||
|
const userTables = ref([]);
|
||||||
|
const rulesList = ref([]);
|
||||||
|
const showRuleEditor = ref(false);
|
||||||
|
const editingRule = ref(null);
|
||||||
|
const telegramBots = ref([]);
|
||||||
|
const emailList = ref([]);
|
||||||
|
|
||||||
|
const selectedRule = computed(() => rulesList.value.find(r => r.id === settings.value.rules_id) || null);
|
||||||
|
|
||||||
|
async function loadUserTables() {
|
||||||
|
const { data } = await axios.get('/api/tables');
|
||||||
|
userTables.value = Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
async function loadRules() {
|
||||||
|
const { data } = await axios.get('/api/settings/ai-assistant-rules');
|
||||||
|
rulesList.value = data.rules || [];
|
||||||
|
}
|
||||||
|
async function loadSettings() {
|
||||||
|
const { data } = await axios.get('/api/settings/ai-assistant');
|
||||||
|
if (data.success && data.settings) {
|
||||||
|
settings.value = data.settings;
|
||||||
|
languagesInput.value = (data.settings.languages || []).join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadTelegramBots() {
|
||||||
|
const { data } = await axios.get('/api/settings/telegram-settings');
|
||||||
|
telegramBots.value = data.items || [];
|
||||||
|
}
|
||||||
|
async function loadEmailList() {
|
||||||
|
const { data } = await axios.get('/api/settings/email-settings');
|
||||||
|
emailList.value = data.items || [];
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
loadSettings();
|
||||||
|
loadUserTables();
|
||||||
|
loadRules();
|
||||||
|
loadTelegramBots();
|
||||||
|
loadEmailList();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
settings.value.languages = languagesInput.value.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
await axios.put('/api/settings/ai-assistant', settings.value);
|
||||||
|
emit('cancel');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRuleEditor(ruleId = null) {
|
||||||
|
if (ruleId) {
|
||||||
|
editingRule.value = rulesList.value.find(r => r.id === ruleId) || null;
|
||||||
|
} else {
|
||||||
|
editingRule.value = null;
|
||||||
|
}
|
||||||
|
showRuleEditor.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRule(ruleId) {
|
||||||
|
if (!confirm('Удалить этот набор правил?')) return;
|
||||||
|
await axios.delete(`/api/settings/ai-assistant-rules/${ruleId}`);
|
||||||
|
await loadRules();
|
||||||
|
if (settings.value.rules_id === ruleId) settings.value.rules_id = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRuleEditorClose(updated) {
|
||||||
|
showRuleEditor.value = false;
|
||||||
|
editingRule.value = null;
|
||||||
|
if (updated) await loadRules();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ai-assistant-settings-modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 540px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
textarea, input, select {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
select[multiple] {
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
.rules-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.rules-json {
|
||||||
|
background: #f7f7f7;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.95em;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
button[type="submit"], .actions button {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
button[type="button"] {
|
||||||
|
background: #eee;
|
||||||
|
color: #333;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.modal-bg {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.25);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 16px rgba(0,0,0,0.12);
|
||||||
|
padding: 2rem;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #c00;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -37,6 +37,11 @@
|
|||||||
<p>Интеграция с PostgreSQL для хранения данных приложения и управления настройками.</p>
|
<p>Интеграция с PostgreSQL для хранения данных приложения и управления настройками.</p>
|
||||||
<button class="details-btn" @click="showDbSettings = true">Подробнее</button>
|
<button class="details-btn" @click="showDbSettings = true">Подробнее</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="integration-block">
|
||||||
|
<h3>ИИ-ассистент</h3>
|
||||||
|
<p>Настройки поведения, языков, моделей и правил работы ассистента.</p>
|
||||||
|
<button class="details-btn" @click="showAiAssistantSettings = true">Подробнее</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AIProviderSettings
|
<AIProviderSettings
|
||||||
v-if="showProvider"
|
v-if="showProvider"
|
||||||
@@ -52,6 +57,7 @@
|
|||||||
<TelegramSettingsView v-if="showTelegramSettings" @cancel="showTelegramSettings = false" />
|
<TelegramSettingsView v-if="showTelegramSettings" @cancel="showTelegramSettings = false" />
|
||||||
<EmailSettingsView v-if="showEmailSettings" @cancel="showEmailSettings = false" />
|
<EmailSettingsView v-if="showEmailSettings" @cancel="showEmailSettings = false" />
|
||||||
<DatabaseSettingsView v-if="showDbSettings" @cancel="showDbSettings = false" />
|
<DatabaseSettingsView v-if="showDbSettings" @cancel="showDbSettings = false" />
|
||||||
|
<AiAssistantSettings v-if="showAiAssistantSettings" @cancel="showAiAssistantSettings = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -61,10 +67,13 @@ import AIProviderSettings from './AIProviderSettings.vue';
|
|||||||
import TelegramSettingsView from './TelegramSettingsView.vue';
|
import TelegramSettingsView from './TelegramSettingsView.vue';
|
||||||
import EmailSettingsView from './EmailSettingsView.vue';
|
import EmailSettingsView from './EmailSettingsView.vue';
|
||||||
import DatabaseSettingsView from './DatabaseSettingsView.vue';
|
import DatabaseSettingsView from './DatabaseSettingsView.vue';
|
||||||
|
import AiAssistantSettings from './AiAssistantSettings.vue';
|
||||||
|
|
||||||
const showProvider = ref(null);
|
const showProvider = ref(null);
|
||||||
const showTelegramSettings = ref(false);
|
const showTelegramSettings = ref(false);
|
||||||
const showEmailSettings = ref(false);
|
const showEmailSettings = ref(false);
|
||||||
const showDbSettings = ref(false);
|
const showDbSettings = ref(false);
|
||||||
|
const showAiAssistantSettings = ref(false);
|
||||||
|
|
||||||
const providerLabels = {
|
const providerLabels = {
|
||||||
openai: {
|
openai: {
|
||||||
|
|||||||
Reference in New Issue
Block a user