ваше сообщение коммита
This commit is contained in:
@@ -10,7 +10,9 @@ CREATE TABLE IF NOT EXISTS db_settings (
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Пропускаем INSERT, так как данные должны быть зашифрованы
|
||||
-- INSERT INTO db_settings (db_host, db_port, db_name, db_user, db_password)
|
||||
-- VALUES ('postgres', 5432, 'dapp_db', 'dapp_user', 'dapp_password')
|
||||
-- ON CONFLICT DO NOTHING;
|
||||
-- Добавляем дефолтные настройки базы данных
|
||||
INSERT INTO db_settings (id, db_port, created_at, updated_at)
|
||||
VALUES (1, 5432, NOW(), NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
db_port = EXCLUDED.db_port,
|
||||
updated_at = NOW();
|
||||
@@ -51,6 +51,18 @@ module.exports = {
|
||||
disambiguatePaths: false,
|
||||
},
|
||||
networks: getNetworks(),
|
||||
etherscan: {
|
||||
apiKey: {
|
||||
sepolia: process.env.ETHERSCAN_API_KEY || '',
|
||||
mainnet: process.env.ETHERSCAN_API_KEY || '',
|
||||
polygon: process.env.POLYGONSCAN_API_KEY || '',
|
||||
arbitrumOne: process.env.ARBISCAN_API_KEY || '',
|
||||
bsc: process.env.BSCSCAN_API_KEY || '',
|
||||
base: process.env.BASESCAN_API_KEY || '',
|
||||
baseSepolia: process.env.BASESCAN_API_KEY || '',
|
||||
arbitrumSepolia: process.env.ARBISCAN_API_KEY || '',
|
||||
}
|
||||
},
|
||||
solidityCoverage: {
|
||||
excludeContracts: [],
|
||||
skipFiles: [],
|
||||
|
||||
@@ -50,39 +50,56 @@ const requireAuth = async (req, res, next) => {
|
||||
*/
|
||||
async function requireAdmin(req, res, next) {
|
||||
try {
|
||||
// Подробное логирование для отладки
|
||||
logger.info(`[requireAdmin] Проверка доступа для ${req.method} ${req.url}`);
|
||||
logger.info(`[requireAdmin] Session:`, {
|
||||
exists: !!req.session,
|
||||
authenticated: req.session?.authenticated,
|
||||
isAdmin: req.session?.isAdmin,
|
||||
userId: req.session?.userId,
|
||||
address: req.session?.address
|
||||
});
|
||||
|
||||
// Проверка аутентификации
|
||||
if (!req.session || !req.session.authenticated) {
|
||||
logger.warn(`[requireAdmin] Сессия не аутентифицирована`);
|
||||
return next(createError('Требуется аутентификация', 401));
|
||||
}
|
||||
|
||||
// Проверка через сессию
|
||||
if (req.session.isAdmin) {
|
||||
logger.info(`[requireAdmin] Доступ разрешен через сессию isAdmin`);
|
||||
return next();
|
||||
}
|
||||
|
||||
// Проверка через кошелек
|
||||
if (req.session.address) {
|
||||
logger.info(`[requireAdmin] Проверка через кошелек: ${req.session.address}`);
|
||||
const isAdmin = await authService.checkAdminTokens(req.session.address);
|
||||
if (isAdmin) {
|
||||
// Обновляем сессию
|
||||
req.session.isAdmin = true;
|
||||
logger.info(`[requireAdmin] Доступ разрешен через кошелек`);
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка через ID пользователя
|
||||
if (req.session.userId) {
|
||||
logger.info(`[requireAdmin] Проверка через userId: ${req.session.userId}`);
|
||||
const userResult = await db.getQuery()('SELECT role FROM users WHERE id = $1', [
|
||||
req.session.userId,
|
||||
]);
|
||||
if (userResult.rows.length > 0 && userResult.rows[0].role === USER_ROLES.ADMIN) {
|
||||
// Обновляем сессию
|
||||
req.session.isAdmin = true;
|
||||
logger.info(`[requireAdmin] Доступ разрешен через userId`);
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
// Если ни одна проверка не прошла
|
||||
logger.warn(`[requireAdmin] Доступ запрещен - все проверки не прошли`);
|
||||
return next(createError('Доступ запрещен', 403));
|
||||
} catch (error) {
|
||||
logger.error(`Error in requireAdmin middleware: ${error.message}`);
|
||||
|
||||
@@ -483,6 +483,105 @@ router.get('/email-settings', requireAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить настройки Email
|
||||
router.put('/email-settings', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
imap_host,
|
||||
imap_port,
|
||||
imap_user,
|
||||
imap_password,
|
||||
smtp_host,
|
||||
smtp_port,
|
||||
smtp_user,
|
||||
smtp_password,
|
||||
from_email,
|
||||
is_active
|
||||
} = req.body;
|
||||
|
||||
// Валидация обязательных полей
|
||||
if (!imap_host || !imap_port || !imap_user || !imap_password ||
|
||||
!smtp_host || !smtp_port || !smtp_user || !smtp_password || !from_email) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Все поля обязательны для заполнения'
|
||||
});
|
||||
}
|
||||
|
||||
const settings = {
|
||||
imap_host,
|
||||
imap_port: parseInt(imap_port),
|
||||
imap_user,
|
||||
imap_password,
|
||||
smtp_host,
|
||||
smtp_port: parseInt(smtp_port),
|
||||
smtp_user,
|
||||
smtp_password,
|
||||
from_email,
|
||||
is_active: is_active !== undefined ? is_active : true,
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
const result = await emailBotService.saveEmailSettings(settings);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
logger.error('Ошибка при обновлении email настроек:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Тест email функциональности
|
||||
router.post('/email-settings/test', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const { test_email } = req.body;
|
||||
|
||||
if (!test_email) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'test_email обязателен для тестирования'
|
||||
});
|
||||
}
|
||||
|
||||
// Отправляем тестовое письмо
|
||||
const result = await emailBotService.sendEmail(
|
||||
test_email,
|
||||
'Тест Email системы DLE',
|
||||
'Это тестовое письмо для проверки работы email системы. Если вы его получили, значит настройки работают корректно!'
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Тестовое письмо отправлено успешно',
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Ошибка при тестировании email:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Тест IMAP подключения
|
||||
router.post('/email-settings/test-imap', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const result = await emailBotService.testImapConnection();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Ошибка при тестировании IMAP подключения:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Тест SMTP подключения
|
||||
router.post('/email-settings/test-smtp', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const result = await emailBotService.testSmtpConnection();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Ошибка при тестировании SMTP подключения:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Получить список всех email (для ассистента)
|
||||
router.get('/email-settings/list', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
@@ -503,6 +602,35 @@ router.get('/telegram-settings', requireAdmin, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить настройки Telegram-бота
|
||||
router.put('/telegram-settings', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const { bot_token, bot_username, webhook_url, is_active } = req.body;
|
||||
|
||||
// Валидация обязательных полей
|
||||
if (!bot_token || !bot_username) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'bot_token и bot_username обязательны'
|
||||
});
|
||||
}
|
||||
|
||||
const settings = {
|
||||
bot_token,
|
||||
bot_username,
|
||||
webhook_url: webhook_url || null,
|
||||
is_active: is_active !== undefined ? is_active : true,
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
const result = await telegramBot.saveTelegramSettings(settings);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
logger.error('Ошибка при обновлении настроек Telegram:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Получить список всех Telegram-ботов (для ассистента)
|
||||
router.get('/telegram-settings/list', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
|
||||
@@ -40,7 +40,8 @@ router.post('/logo', auth.requireAuth, auth.requireAdmin, upload.single('logo'),
|
||||
if (!req.file) return res.status(400).json({ success: false, message: 'Файл не получен' });
|
||||
const rel = path.posix.join('uploads', 'logos', path.basename(req.file.filename));
|
||||
const urlPath = `/uploads/logos/${path.basename(req.file.filename)}`;
|
||||
return res.json({ success: true, data: { path: rel, url: urlPath } });
|
||||
const fullUrl = `http://localhost:8000${urlPath}`;
|
||||
return res.json({ success: true, data: { path: rel, url: fullUrl } });
|
||||
} catch (e) {
|
||||
return res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ router.get('/', requireAuth, async (req, res, next) => {
|
||||
// --- Формируем ответ ---
|
||||
const contacts = users.map(u => ({
|
||||
id: u.id,
|
||||
name: null, // Имена теперь только в зашифрованных колонках
|
||||
name: [u.first_name, u.last_name].filter(Boolean).join(' ').trim() || null,
|
||||
email: u.email || null,
|
||||
telegram: u.telegram || null,
|
||||
wallet: u.wallet || null,
|
||||
@@ -446,9 +446,19 @@ router.get('/:id', async (req, res, next) => {
|
||||
for (const id of identitiesResult.rows) {
|
||||
identityMap[id.provider] = id.provider_id;
|
||||
}
|
||||
// Получаем имя пользователя из зашифрованных полей
|
||||
const nameResult = await query('SELECT CASE WHEN first_name_encrypted IS NULL OR first_name_encrypted = \'\' THEN NULL ELSE decrypt_text(first_name_encrypted, $2) END as first_name, CASE WHEN last_name_encrypted IS NULL OR last_name_encrypted = \'\' THEN NULL ELSE decrypt_text(last_name_encrypted, $2) END as last_name FROM users WHERE id = $1', [userId, encryptionKey]);
|
||||
|
||||
let fullName = null;
|
||||
if (nameResult.rows.length > 0) {
|
||||
const firstName = nameResult.rows[0].first_name || '';
|
||||
const lastName = nameResult.rows[0].last_name || '';
|
||||
fullName = [firstName, lastName].filter(Boolean).join(' ').trim() || null;
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: user.id,
|
||||
name: null, // Пока не используем имена
|
||||
name: fullName,
|
||||
email: identityMap.email || null,
|
||||
telegram: identityMap.telegram || null,
|
||||
wallet: identityMap.wallet || null,
|
||||
|
||||
@@ -174,7 +174,7 @@ async function deployInNetwork(rpcUrl, pk, salt, initCodeHash, targetDLENonce, d
|
||||
}
|
||||
|
||||
// Деплой модулей в одной сети
|
||||
async function deployModulesInNetwork(rpcUrl, pk, dleAddress) {
|
||||
async function deployModulesInNetwork(rpcUrl, pk, dleAddress, params) {
|
||||
const { ethers } = hre;
|
||||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
||||
const wallet = new ethers.Wallet(pk, provider);
|
||||
@@ -184,77 +184,130 @@ async function deployModulesInNetwork(rpcUrl, pk, dleAddress) {
|
||||
|
||||
const modules = {};
|
||||
|
||||
// Получаем начальный nonce для всех модулей
|
||||
let currentNonce = await wallet.getNonce();
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} starting nonce for modules: ${currentNonce}`);
|
||||
|
||||
// Функция для безопасного деплоя с правильным nonce
|
||||
async function deployWithNonce(contractFactory, args, moduleName) {
|
||||
try {
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} deploying ${moduleName} with nonce: ${currentNonce}`);
|
||||
|
||||
// Проверяем, что nonce актуален
|
||||
const actualNonce = await wallet.getNonce();
|
||||
if (actualNonce > currentNonce) {
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} nonce mismatch, updating from ${currentNonce} to ${actualNonce}`);
|
||||
currentNonce = actualNonce;
|
||||
}
|
||||
|
||||
const contract = await contractFactory.connect(wallet).deploy(...args);
|
||||
await contract.waitForDeployment();
|
||||
const address = await contract.getAddress();
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} ${moduleName} deployed at: ${address}`);
|
||||
currentNonce++;
|
||||
return address;
|
||||
} catch (error) {
|
||||
console.error(`[MULTI_DBG] chainId=${Number(net.chainId)} ${moduleName} deployment failed:`, error.message);
|
||||
// Даже при ошибке увеличиваем nonce, чтобы не было конфликтов
|
||||
currentNonce++;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Деплой TreasuryModule
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} deploying TreasuryModule...`);
|
||||
const TreasuryModule = await hre.ethers.getContractFactory('TreasuryModule');
|
||||
const treasuryModule = await TreasuryModule.connect(wallet).deploy(
|
||||
dleAddress, // _dleContract
|
||||
Number(net.chainId), // _chainId
|
||||
wallet.address // _emergencyAdmin
|
||||
modules.treasuryModule = await deployWithNonce(
|
||||
TreasuryModule,
|
||||
[dleAddress, Number(net.chainId), wallet.address], // _dleContract, _chainId, _emergencyAdmin
|
||||
'TreasuryModule'
|
||||
);
|
||||
await treasuryModule.waitForDeployment();
|
||||
const treasuryAddress = await treasuryModule.getAddress();
|
||||
modules.treasuryModule = treasuryAddress;
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} TreasuryModule deployed at: ${treasuryAddress}`);
|
||||
|
||||
// Деплой TimelockModule
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} deploying TimelockModule...`);
|
||||
const TimelockModule = await hre.ethers.getContractFactory('TimelockModule');
|
||||
const timelockModule = await TimelockModule.connect(wallet).deploy(
|
||||
dleAddress // _dleContract
|
||||
modules.timelockModule = await deployWithNonce(
|
||||
TimelockModule,
|
||||
[dleAddress], // _dleContract
|
||||
'TimelockModule'
|
||||
);
|
||||
await timelockModule.waitForDeployment();
|
||||
const timelockAddress = await timelockModule.getAddress();
|
||||
modules.timelockModule = timelockAddress;
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} TimelockModule deployed at: ${timelockAddress}`);
|
||||
|
||||
// Деплой DLEReader
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} deploying DLEReader...`);
|
||||
const DLEReader = await hre.ethers.getContractFactory('DLEReader');
|
||||
const dleReader = await DLEReader.connect(wallet).deploy(
|
||||
dleAddress // _dleContract
|
||||
modules.dleReader = await deployWithNonce(
|
||||
DLEReader,
|
||||
[dleAddress], // _dleContract
|
||||
'DLEReader'
|
||||
);
|
||||
await dleReader.waitForDeployment();
|
||||
const readerAddress = await dleReader.getAddress();
|
||||
modules.dleReader = readerAddress;
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLEReader deployed at: ${readerAddress}`);
|
||||
|
||||
// Инициализация модулей в DLE
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} initializing modules in DLE...`);
|
||||
try {
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} initializing modules in DLE with nonce: ${currentNonce}`);
|
||||
|
||||
// Проверяем, что nonce актуален
|
||||
const actualNonce = await wallet.getNonce();
|
||||
if (actualNonce > currentNonce) {
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} nonce mismatch before module init, updating from ${currentNonce} to ${actualNonce}`);
|
||||
currentNonce = actualNonce;
|
||||
}
|
||||
|
||||
const dleContract = await hre.ethers.getContractAt('DLE', dleAddress, wallet);
|
||||
|
||||
// Проверяем, что все модули задеплоены
|
||||
const treasuryAddress = modules.treasuryModule;
|
||||
const timelockAddress = modules.timelockModule;
|
||||
const readerAddress = modules.dleReader;
|
||||
|
||||
if (treasuryAddress && timelockAddress && readerAddress) {
|
||||
// Инициализация базовых модулей
|
||||
await dleContract.initializeBaseModules(treasuryAddress, timelockAddress, readerAddress);
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} base modules initialized`);
|
||||
currentNonce++;
|
||||
} else {
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} skipping module initialization - not all modules deployed`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[MULTI_DBG] chainId=${Number(net.chainId)} module initialization failed:`, error.message);
|
||||
// Даже при ошибке увеличиваем nonce
|
||||
currentNonce++;
|
||||
}
|
||||
|
||||
// Инициализация logoURI
|
||||
try {
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} initializing logoURI with nonce: ${currentNonce}`);
|
||||
|
||||
// Проверяем, что nonce актуален
|
||||
const actualNonce = await wallet.getNonce();
|
||||
if (actualNonce > currentNonce) {
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} nonce mismatch before logoURI init, updating from ${currentNonce} to ${actualNonce}`);
|
||||
currentNonce = actualNonce;
|
||||
}
|
||||
|
||||
// Используем логотип из параметров деплоя или fallback
|
||||
const logoURL = params.logoURI || "https://via.placeholder.com/200x200/0066cc/ffffff?text=DLE";
|
||||
const dleContract = await hre.ethers.getContractAt('DLE', dleAddress, wallet);
|
||||
await dleContract.initializeLogoURI(logoURL);
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} logoURI initialized: ${logoURL}`);
|
||||
currentNonce++;
|
||||
} catch (e) {
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} logoURI initialization failed: ${e.message}`);
|
||||
// Fallback на базовый логотип
|
||||
try {
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} trying fallback logoURI with nonce: ${currentNonce}`);
|
||||
const dleContract = await hre.ethers.getContractAt('DLE', dleAddress, wallet);
|
||||
await dleContract.initializeLogoURI("https://via.placeholder.com/200x200/0066cc/ffffff?text=DLE");
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} fallback logoURI initialized`);
|
||||
currentNonce++;
|
||||
} catch (fallbackError) {
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} fallback logoURI also failed: ${fallbackError.message}`);
|
||||
// Даже при ошибке увеличиваем nonce
|
||||
currentNonce++;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[MULTI_DBG] chainId=${Number(net.chainId)} module deployment failed:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return modules;
|
||||
}
|
||||
|
||||
// Деплой модулей во всех сетях
|
||||
async function deployModulesInAllNetworks(networks, pk, dleAddress) {
|
||||
async function deployModulesInAllNetworks(networks, pk, dleAddress, params) {
|
||||
const moduleResults = [];
|
||||
|
||||
for (let i = 0; i < networks.length; i++) {
|
||||
@@ -262,7 +315,7 @@ async function deployModulesInAllNetworks(networks, pk, dleAddress) {
|
||||
console.log(`[MULTI_DBG] deploying modules to network ${i + 1}/${networks.length}: ${rpcUrl}`);
|
||||
|
||||
try {
|
||||
const modules = await deployModulesInNetwork(rpcUrl, pk, dleAddress);
|
||||
const modules = await deployModulesInNetwork(rpcUrl, pk, dleAddress, params);
|
||||
moduleResults.push(modules);
|
||||
} catch (error) {
|
||||
console.error(`[MULTI_DBG] Failed to deploy modules in network ${i + 1}:`, error.message);
|
||||
@@ -312,6 +365,7 @@ async function verifyContractsInNetwork(rpcUrl, pk, dleAddress, modules, params)
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLE verification successful`);
|
||||
} catch (error) {
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLE verification failed: ${error.message}`);
|
||||
console.log(`[MULTI_DBG] chainId=${Number(net.chainId)} DLE verification error details:`, error);
|
||||
verification.dle = 'failed';
|
||||
}
|
||||
|
||||
@@ -485,7 +539,7 @@ async function main() {
|
||||
|
||||
// Деплой модулей во всех сетях
|
||||
console.log('[MULTI_DBG] Starting module deployment...');
|
||||
const moduleResults = await deployModulesInAllNetworks(networks, pk, uniqueAddresses[0]);
|
||||
const moduleResults = await deployModulesInAllNetworks(networks, pk, uniqueAddresses[0], params);
|
||||
|
||||
// Верификация контрактов
|
||||
console.log('[MULTI_DBG] Starting contract verification...');
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
|
||||
const { ChatOllama } = require('@langchain/ollama');
|
||||
const aiCache = require('./ai-cache');
|
||||
const aiQueue = require('./ai-queue');
|
||||
const AIQueue = require('./ai-queue');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Константы для AI параметров
|
||||
const AI_CONFIG = {
|
||||
temperature: 0.3,
|
||||
maxTokens: 512,
|
||||
timeout: 180000,
|
||||
timeout: 120000, // Уменьшаем до 120 секунд, чтобы соответствовать EmailBot
|
||||
numCtx: 2048,
|
||||
numGpu: 1,
|
||||
numThread: 4,
|
||||
@@ -42,6 +42,110 @@ class AIAssistant {
|
||||
this.defaultModel = process.env.OLLAMA_MODEL || 'qwen2.5:7b';
|
||||
this.lastHealthCheck = 0;
|
||||
this.healthCheckInterval = 30000; // 30 секунд
|
||||
|
||||
// Создаем экземпляр AIQueue
|
||||
this.aiQueue = new AIQueue();
|
||||
this.isProcessingQueue = false;
|
||||
|
||||
// Запускаем обработку очереди
|
||||
this.startQueueProcessing();
|
||||
}
|
||||
|
||||
// Запуск обработки очереди
|
||||
async startQueueProcessing() {
|
||||
if (this.isProcessingQueue) return;
|
||||
|
||||
this.isProcessingQueue = true;
|
||||
logger.info('[AIAssistant] Запущена обработка очереди AIQueue');
|
||||
|
||||
while (this.isProcessingQueue) {
|
||||
try {
|
||||
// Получаем следующий запрос из очереди
|
||||
const requestItem = this.aiQueue.getNextRequest();
|
||||
|
||||
if (!requestItem) {
|
||||
// Если очередь пуста, ждем немного
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`[AIAssistant] Обрабатываем запрос ${requestItem.id} из очереди`);
|
||||
|
||||
// Обновляем статус на "processing"
|
||||
this.aiQueue.updateRequestStatus(requestItem.id, 'processing');
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Обрабатываем запрос
|
||||
const result = await this.processQueueRequest(requestItem.request);
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// Обновляем статус на "completed"
|
||||
this.aiQueue.updateRequestStatus(requestItem.id, 'completed', result, null, responseTime);
|
||||
|
||||
logger.info(`[AIAssistant] Запрос ${requestItem.id} завершен за ${responseTime}ms`);
|
||||
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// Обновляем статус на "failed"
|
||||
this.aiQueue.updateRequestStatus(requestItem.id, 'failed', null, error.message, responseTime);
|
||||
|
||||
logger.error(`[AIAssistant] Запрос ${requestItem.id} завершился с ошибкой:`, error.message);
|
||||
logger.error(`[AIAssistant] Детали ошибки:`, error.stack || error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[AIAssistant] Ошибка в обработке очереди:', error);
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Остановка обработки очереди
|
||||
stopQueueProcessing() {
|
||||
this.isProcessingQueue = false;
|
||||
logger.info('[AIAssistant] Остановлена обработка очереди AIQueue');
|
||||
}
|
||||
|
||||
// Обработка запроса из очереди
|
||||
async processQueueRequest(request) {
|
||||
try {
|
||||
const { message, history, systemPrompt, rules } = request;
|
||||
|
||||
logger.info(`[AIAssistant] Обрабатываю запрос: message="${message?.substring(0, 50)}...", history=${history?.length || 0}, systemPrompt="${systemPrompt?.substring(0, 50)}..."`);
|
||||
|
||||
// Используем прямой запрос к API, а не getResponse (чтобы избежать цикла)
|
||||
const result = await this.directRequest(
|
||||
[{ role: 'user', content: message }],
|
||||
systemPrompt,
|
||||
{ temperature: 0.3, maxTokens: 150 }
|
||||
);
|
||||
|
||||
logger.info(`[AIAssistant] Запрос успешно обработан, результат: "${result?.substring(0, 100)}..."`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`[AIAssistant] Ошибка в processQueueRequest:`, error.message);
|
||||
logger.error(`[AIAssistant] Stack trace:`, error.stack);
|
||||
throw error; // Перебрасываем ошибку дальше
|
||||
}
|
||||
}
|
||||
|
||||
// Добавление запроса в очередь
|
||||
async addToQueue(request, priority = 0) {
|
||||
return await this.aiQueue.addRequest(request, priority);
|
||||
}
|
||||
|
||||
// Получение статистики очереди
|
||||
getQueueStats() {
|
||||
return this.aiQueue.getStats();
|
||||
}
|
||||
|
||||
// Получение размера очереди
|
||||
getQueueSize() {
|
||||
return this.aiQueue.getQueueSize();
|
||||
}
|
||||
|
||||
// Проверка здоровья модели
|
||||
@@ -148,7 +252,7 @@ class AIAssistant {
|
||||
const priority = this.getRequestPriority(message, history, rules);
|
||||
|
||||
// Добавляем запрос в очередь
|
||||
const requestId = await aiQueue.addRequest({
|
||||
const requestId = await this.addToQueue({
|
||||
message,
|
||||
history,
|
||||
systemPrompt,
|
||||
@@ -164,8 +268,8 @@ class AIAssistant {
|
||||
const onCompleted = (item) => {
|
||||
if (item.id === requestId) {
|
||||
clearTimeout(timeout);
|
||||
aiQueue.off('completed', onCompleted);
|
||||
aiQueue.off('failed', onFailed);
|
||||
this.aiQueue.off('requestCompleted', onCompleted);
|
||||
this.aiQueue.off('requestFailed', onFailed);
|
||||
try {
|
||||
aiCache.set(cacheKey, item.result);
|
||||
} catch {}
|
||||
@@ -176,14 +280,14 @@ class AIAssistant {
|
||||
const onFailed = (item) => {
|
||||
if (item.id === requestId) {
|
||||
clearTimeout(timeout);
|
||||
aiQueue.off('completed', onCompleted);
|
||||
aiQueue.off('failed', onFailed);
|
||||
this.aiQueue.off('requestCompleted', onCompleted);
|
||||
this.aiQueue.off('requestFailed', onFailed);
|
||||
reject(new Error(item.error));
|
||||
}
|
||||
};
|
||||
|
||||
aiQueue.on('completed', onCompleted);
|
||||
aiQueue.on('failed', onFailed);
|
||||
this.aiQueue.on('requestCompleted', onCompleted);
|
||||
this.aiQueue.on('requestFailed', onFailed);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in getResponse:', error);
|
||||
@@ -201,6 +305,8 @@ class AIAssistant {
|
||||
try {
|
||||
const model = this.defaultModel;
|
||||
|
||||
logger.info(`[AIAssistant] directRequest: модель=${model}, сообщений=${messages?.length || 0}, systemPrompt="${systemPrompt?.substring(0, 50)}..."`);
|
||||
|
||||
// Создаем AbortController для таймаута
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), AI_CONFIG.timeout);
|
||||
@@ -241,6 +347,7 @@ class AIAssistant {
|
||||
|
||||
let response;
|
||||
try {
|
||||
logger.info(`[AIAssistant] Вызываю Ollama API: ${this.baseUrl}/api/chat`);
|
||||
response = await fetch(`${this.baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -253,6 +360,7 @@ class AIAssistant {
|
||||
keep_alive: '3m'
|
||||
})
|
||||
});
|
||||
logger.info(`[AIAssistant] Ollama API ответил: status=${response.status}`);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
@@ -17,13 +17,10 @@ class AIQueue extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.queue = [];
|
||||
this.processing = false;
|
||||
this.activeRequests = 0;
|
||||
this.maxConcurrent = 1; // Ограничиваем до 1 для стабильности
|
||||
this.isPaused = false;
|
||||
this.stats = {
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
totalAdded: 0,
|
||||
totalProcessed: 0,
|
||||
totalFailed: 0,
|
||||
avgResponseTime: 0,
|
||||
lastProcessedAt: null,
|
||||
initializedAt: Date.now()
|
||||
@@ -45,165 +42,101 @@ class AIQueue extends EventEmitter {
|
||||
this.queue.push(queueItem);
|
||||
this.queue.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
this.stats.totalAdded++;
|
||||
logger.info(`[AIQueue] Добавлен запрос ${requestId} с приоритетом ${priority}. Очередь: ${this.queue.length}`);
|
||||
|
||||
// Запускаем обработку очереди
|
||||
if (!this.processing) {
|
||||
this.processQueue();
|
||||
}
|
||||
// Эмитим событие о добавлении
|
||||
this.emit('requestAdded', queueItem);
|
||||
|
||||
return requestId;
|
||||
}
|
||||
|
||||
// Обработка очереди
|
||||
async processQueue() {
|
||||
if (this.processing) return;
|
||||
// Получение следующего запроса (без обработки)
|
||||
getNextRequest() {
|
||||
if (this.queue.length === 0) return null;
|
||||
return this.queue.shift();
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
logger.info(`[AIQueue] Начинаем обработку очереди. Запросов в очереди: ${this.queue.length}`);
|
||||
// Получение запроса по ID
|
||||
getRequestById(requestId) {
|
||||
return this.queue.find(item => item.id === requestId);
|
||||
}
|
||||
|
||||
while (!this.isPaused && this.queue.length > 0 && this.activeRequests < this.maxConcurrent) {
|
||||
const item = this.queue.shift();
|
||||
if (!item) continue;
|
||||
// Обновление статуса запроса
|
||||
updateRequestStatus(requestId, status, result = null, error = null, responseTime = null) {
|
||||
const item = this.queue.find(item => item.id === requestId);
|
||||
if (!item) return false;
|
||||
|
||||
this.activeRequests++;
|
||||
item.status = 'processing';
|
||||
logger.info(`[AIQueue] Обрабатываем запрос ${item.id} (приоритет: ${item.priority})`);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const result = await this.processRequest(item.request);
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
item.status = 'completed';
|
||||
item.status = status;
|
||||
item.result = result;
|
||||
item.error = error;
|
||||
item.responseTime = responseTime;
|
||||
item.processedAt = Date.now();
|
||||
|
||||
this.stats.completed++;
|
||||
if (status === 'completed') {
|
||||
this.stats.totalProcessed++;
|
||||
if (responseTime) {
|
||||
this.updateAvgResponseTime(responseTime);
|
||||
}
|
||||
this.stats.lastProcessedAt = Date.now();
|
||||
|
||||
logger.info(`[AIQueue] Запрос ${item.id} завершен за ${responseTime}ms`);
|
||||
|
||||
// Эмитим событие о завершении
|
||||
this.emit('completed', item);
|
||||
|
||||
} catch (error) {
|
||||
item.status = 'failed';
|
||||
item.error = error.message;
|
||||
|
||||
this.stats.failed++;
|
||||
this.emit('requestCompleted', item);
|
||||
} else if (status === 'failed') {
|
||||
this.stats.totalFailed++;
|
||||
this.stats.lastProcessedAt = Date.now();
|
||||
logger.error(`[AIQueue] Запрос ${item.id} завершился с ошибкой:`, error.message);
|
||||
|
||||
// Эмитим событие об ошибке
|
||||
this.emit('failed', item);
|
||||
} finally {
|
||||
this.activeRequests--;
|
||||
}
|
||||
this.emit('requestFailed', item);
|
||||
}
|
||||
|
||||
this.processing = false;
|
||||
logger.info(`[AIQueue] Обработка очереди завершена. Осталось запросов: ${this.queue.length}`);
|
||||
|
||||
// Если в очереди еще есть запросы, продолжаем обработку
|
||||
if (!this.isPaused && this.queue.length > 0) {
|
||||
setTimeout(() => this.processQueue(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка одного запроса
|
||||
async processRequest(request) {
|
||||
const aiAssistant = require('./ai-assistant');
|
||||
|
||||
// Формируем сообщения для API
|
||||
const messages = [];
|
||||
|
||||
// Добавляем системный промпт
|
||||
if (request.systemPrompt) {
|
||||
messages.push({ role: 'system', content: request.systemPrompt });
|
||||
}
|
||||
|
||||
// Добавляем историю сообщений
|
||||
if (request.history && Array.isArray(request.history)) {
|
||||
for (const msg of request.history) {
|
||||
if (msg.role && msg.content) {
|
||||
messages.push({ role: msg.role, content: msg.content });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем текущее сообщение пользователя
|
||||
messages.push({ role: 'user', content: request.message });
|
||||
|
||||
// Используем прямой метод для избежания рекурсии
|
||||
return await aiAssistant.directRequest(messages, request.systemPrompt);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Обновление средней скорости ответа
|
||||
updateAvgResponseTime(responseTime) {
|
||||
const total = this.stats.completed;
|
||||
const total = this.stats.totalProcessed;
|
||||
this.stats.avgResponseTime =
|
||||
(this.stats.avgResponseTime * (total - 1) + responseTime) / total;
|
||||
}
|
||||
|
||||
// Получение статистики
|
||||
getStats() {
|
||||
const totalProcessed = this.stats.completed + this.stats.failed;
|
||||
return {
|
||||
// совместимость с AIQueueMonitor.vue и маршрутами
|
||||
totalProcessed,
|
||||
totalFailed: this.stats.failed,
|
||||
totalAdded: this.stats.totalAdded,
|
||||
totalProcessed: this.stats.totalProcessed,
|
||||
totalFailed: this.stats.totalFailed,
|
||||
averageProcessingTime: this.stats.avgResponseTime,
|
||||
currentQueueSize: this.queue.length,
|
||||
runningTasks: this.activeRequests,
|
||||
lastProcessedAt: this.stats.lastProcessedAt,
|
||||
isInitialized: true,
|
||||
// старые поля на всякий случай
|
||||
queueLength: this.queue.length,
|
||||
activeRequests: this.activeRequests,
|
||||
processing: this.processing
|
||||
uptime: Date.now() - this.stats.initializedAt
|
||||
};
|
||||
}
|
||||
|
||||
// Очистка очереди
|
||||
clear() {
|
||||
this.queue = [];
|
||||
logger.info('[AIQueue] Queue cleared');
|
||||
// Получение размера очереди
|
||||
getQueueSize() {
|
||||
return this.queue.length;
|
||||
}
|
||||
|
||||
// Совместимость с роутами AI Queue
|
||||
// Очистка очереди
|
||||
clearQueue() {
|
||||
const clearedCount = this.queue.length;
|
||||
this.queue = [];
|
||||
logger.info(`[AIQueue] Очередь очищена. Удалено запросов: ${clearedCount}`);
|
||||
return clearedCount;
|
||||
}
|
||||
|
||||
// Пауза/возобновление очереди
|
||||
pause() {
|
||||
this.isPaused = true;
|
||||
logger.info('[AIQueue] Queue paused');
|
||||
logger.info('[AIQueue] Очередь приостановлена');
|
||||
}
|
||||
|
||||
resume() {
|
||||
const wasPaused = this.isPaused;
|
||||
this.isPaused = false;
|
||||
logger.info('[AIQueue] Queue resumed');
|
||||
if (wasPaused) {
|
||||
this.processQueue();
|
||||
logger.info('[AIQueue] Очередь возобновлена');
|
||||
}
|
||||
|
||||
// Проверка статуса паузы
|
||||
isQueuePaused() {
|
||||
return this.isPaused;
|
||||
}
|
||||
}
|
||||
|
||||
async addTask(taskData) {
|
||||
// Маппинг к addRequest
|
||||
const priority = this._calcTaskPriority(taskData);
|
||||
const taskId = await this.addRequest(taskData, priority);
|
||||
return { taskId };
|
||||
}
|
||||
|
||||
_calcTaskPriority({ message = '', type, userRole, history }) {
|
||||
let priority = 0;
|
||||
if (userRole === 'admin') priority += 10;
|
||||
if (type === 'chat') priority += 5;
|
||||
if (type === 'analysis') priority += 3;
|
||||
if (type === 'generation') priority += 1;
|
||||
if (message && message.length < 100) priority += 2;
|
||||
if (history && Array.isArray(history) && history.length > 0) priority += 1;
|
||||
return priority;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AIQueue();
|
||||
module.exports = AIQueue;
|
||||
@@ -11,16 +11,54 @@
|
||||
*/
|
||||
|
||||
const encryptedDb = require('./encryptedDatabaseService');
|
||||
const logger = require('../utils/logger');
|
||||
const TABLE = 'ai_assistant_rules';
|
||||
|
||||
async function getAllRules() {
|
||||
try {
|
||||
logger.info('[aiAssistantRulesService] getAllRules called');
|
||||
const rules = await encryptedDb.getData(TABLE, {}, null, 'id');
|
||||
return rules;
|
||||
|
||||
// Добавляем fallback названия для правил с null именами
|
||||
const processedRules = rules.map(rule => ({
|
||||
...rule,
|
||||
name: rule.name || `Правило ${rule.id}`,
|
||||
displayName: rule.name || `Правило ${rule.id}`
|
||||
}));
|
||||
|
||||
logger.info(`[aiAssistantRulesService] Found ${processedRules.length} rules:`,
|
||||
processedRules.map(r => ({ id: r.id, name: r.name, displayName: r.displayName })));
|
||||
|
||||
return processedRules;
|
||||
} catch (error) {
|
||||
logger.error('[aiAssistantRulesService] Error in getAllRules:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getRuleById(id) {
|
||||
try {
|
||||
logger.info(`[aiAssistantRulesService] getRuleById called for ID: ${id}`);
|
||||
const rules = await encryptedDb.getData(TABLE, { id: id }, 1);
|
||||
return rules[0] || null;
|
||||
const rule = rules[0] || null;
|
||||
|
||||
if (rule) {
|
||||
// Добавляем fallback название
|
||||
rule.displayName = rule.name || `Правило ${rule.id}`;
|
||||
logger.info(`[aiAssistantRulesService] Found rule:`, {
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
displayName: rule.displayName
|
||||
});
|
||||
} else {
|
||||
logger.warn(`[aiAssistantRulesService] Rule with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return rule;
|
||||
} catch (error) {
|
||||
logger.error(`[aiAssistantRulesService] Error in getRuleById for ID ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function createRule({ name, description, rules }) {
|
||||
|
||||
@@ -13,11 +13,20 @@
|
||||
const encryptedDb = require('./encryptedDatabaseService');
|
||||
const db = require('../db');
|
||||
const TABLE = 'ai_assistant_settings';
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
async function getSettings() {
|
||||
try {
|
||||
logger.info('[aiAssistantSettingsService] getSettings called');
|
||||
|
||||
const settings = await encryptedDb.getData(TABLE, {}, 1, 'id');
|
||||
logger.info(`[aiAssistantSettingsService] Raw settings from DB:`, settings);
|
||||
|
||||
const setting = settings[0] || null;
|
||||
if (!setting) return null;
|
||||
if (!setting) {
|
||||
logger.warn('[aiAssistantSettingsService] No settings found in DB');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Получаем ключ шифрования
|
||||
const fs = require('fs');
|
||||
@@ -25,38 +34,60 @@ async function getSettings() {
|
||||
let encryptionKey = 'default-key';
|
||||
|
||||
try {
|
||||
const keyPath = path.join(__dirname, '../ssl/keys/full_db_encryption.key');
|
||||
const keyPath = path.join(__dirname, '../ssl/keys/full_chain.pem');
|
||||
if (fs.existsSync(keyPath)) {
|
||||
encryptionKey = fs.readFileSync(keyPath, 'utf8').trim();
|
||||
encryptionKey = fs.readFileSync(keyPath, 'utf8');
|
||||
}
|
||||
} catch (keyError) {
|
||||
// console.error('Error reading encryption key:', keyError);
|
||||
logger.warn('[aiAssistantSettingsService] Could not read encryption key:', keyError.message);
|
||||
}
|
||||
|
||||
// Получаем связанные данные из telegram_settings и email_settings
|
||||
let telegramBot = null;
|
||||
let supportEmail = null;
|
||||
if (setting.telegram_settings_id) {
|
||||
const tg = await db.getQuery()(
|
||||
'SELECT id, created_at, updated_at, decrypt_text(bot_token_encrypted, $2) as bot_token, decrypt_text(bot_username_encrypted, $2) as bot_username FROM telegram_settings WHERE id = $1',
|
||||
[setting.telegram_settings_id, encryptionKey]
|
||||
);
|
||||
telegramBot = tg.rows[0] || null;
|
||||
}
|
||||
if (setting.email_settings_id) {
|
||||
const em = await db.getQuery()(
|
||||
'SELECT id, smtp_port, imap_port, created_at, updated_at, decrypt_text(smtp_host_encrypted, $2) as smtp_host, decrypt_text(smtp_user_encrypted, $2) as smtp_user, decrypt_text(smtp_password_encrypted, $2) as smtp_password, decrypt_text(imap_host_encrypted, $2) as imap_host, decrypt_text(from_email_encrypted, $2) as from_email FROM email_settings WHERE id = $1',
|
||||
[setting.email_settings_id, encryptionKey]
|
||||
);
|
||||
supportEmail = em.rows[0] || null;
|
||||
// Обрабатываем selected_rag_tables
|
||||
if (setting.selected_rag_tables) {
|
||||
try {
|
||||
// Если это строка JSON, парсим её
|
||||
if (typeof setting.selected_rag_tables === 'string') {
|
||||
setting.selected_rag_tables = JSON.parse(setting.selected_rag_tables);
|
||||
}
|
||||
|
||||
return {
|
||||
...setting,
|
||||
telegramBot,
|
||||
supportEmail,
|
||||
embedding_model: setting.embedding_model
|
||||
};
|
||||
// Убеждаемся, что это массив
|
||||
if (!Array.isArray(setting.selected_rag_tables)) {
|
||||
setting.selected_rag_tables = [setting.selected_rag_tables];
|
||||
}
|
||||
|
||||
logger.info(`[aiAssistantSettingsService] Processed selected_rag_tables:`, setting.selected_rag_tables);
|
||||
} catch (parseError) {
|
||||
logger.error('[aiAssistantSettingsService] Error parsing selected_rag_tables:', parseError);
|
||||
setting.selected_rag_tables = [];
|
||||
}
|
||||
} else {
|
||||
setting.selected_rag_tables = [];
|
||||
}
|
||||
|
||||
// Обрабатываем rules_id
|
||||
if (setting.rules_id && typeof setting.rules_id === 'string') {
|
||||
try {
|
||||
setting.rules_id = parseInt(setting.rules_id);
|
||||
} catch (parseError) {
|
||||
logger.error('[aiAssistantSettingsService] Error parsing rules_id:', parseError);
|
||||
setting.rules_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[aiAssistantSettingsService] Final settings result:`, {
|
||||
id: setting.id,
|
||||
selected_rag_tables: setting.selected_rag_tables,
|
||||
rules_id: setting.rules_id,
|
||||
hasSupportEmail: setting.hasSupportEmail,
|
||||
hasTelegramBot: setting.hasTelegramBot,
|
||||
timestamp: setting.timestamp
|
||||
});
|
||||
|
||||
return setting;
|
||||
} catch (error) {
|
||||
logger.error('[aiAssistantSettingsService] Error in getSettings:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertSettings({ system_prompt, selected_rag_tables, model, embedding_model, rules, updated_by, telegram_settings_id, email_settings_id, system_message }) {
|
||||
|
||||
@@ -462,7 +462,7 @@ class DLEV2Service {
|
||||
if (deployParams.logoURI) {
|
||||
// Если logoURI относительный путь, делаем его абсолютным
|
||||
if (deployParams.logoURI.startsWith('/uploads/')) {
|
||||
deployParams.logoURI = `http://localhost:3000${deployParams.logoURI}`;
|
||||
deployParams.logoURI = `http://localhost:8000${deployParams.logoURI}`;
|
||||
}
|
||||
// Если это placeholder, оставляем как есть
|
||||
if (deployParams.logoURI.includes('placeholder.com')) {
|
||||
|
||||
@@ -73,7 +73,7 @@ class EmailBotService {
|
||||
}
|
||||
|
||||
const { rows } = await db.getQuery()(
|
||||
'SELECT id, smtp_port, imap_port, created_at, updated_at, decrypt_text(smtp_host_encrypted, $1) as smtp_host, decrypt_text(smtp_user_encrypted, $1) as smtp_user, decrypt_text(smtp_password_encrypted, $1) as smtp_password, decrypt_text(imap_host_encrypted, $1) as imap_host, decrypt_text(from_email_encrypted, $1) as from_email FROM email_settings ORDER BY id LIMIT 1',
|
||||
'SELECT id, smtp_port, imap_port, created_at, updated_at, decrypt_text(smtp_host_encrypted, $1) as smtp_host, decrypt_text(smtp_user_encrypted, $1) as smtp_user, decrypt_text(smtp_password_encrypted, $1) as smtp_password, decrypt_text(imap_host_encrypted, $1) as imap_host, decrypt_text(imap_user_encrypted, $1) as imap_user, decrypt_text(imap_password_encrypted, $1) as imap_password, decrypt_text(from_email_encrypted, $1) as from_email FROM email_settings ORDER BY id LIMIT 1',
|
||||
[encryptionKey]
|
||||
);
|
||||
if (!rows.length) throw new Error('Email settings not found in DB');
|
||||
@@ -84,8 +84,8 @@ class EmailBotService {
|
||||
const settings = await this.getSettingsFromDb();
|
||||
return nodemailer.createTransport({
|
||||
host: settings.smtp_host,
|
||||
port: settings.smtp_port,
|
||||
secure: true,
|
||||
port: 465, // Используем порт 465 для SSMTP (SSL)
|
||||
secure: true, // Включаем SSL
|
||||
auth: {
|
||||
user: settings.smtp_user,
|
||||
pass: settings.smtp_password,
|
||||
@@ -93,7 +93,10 @@ class EmailBotService {
|
||||
pool: false, // Отключаем пул соединений
|
||||
maxConnections: 1, // Ограничиваем до 1 соединения
|
||||
maxMessages: 1, // Ограничиваем до 1 сообщения на соединение
|
||||
tls: { rejectUnauthorized: false },
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
// Убираем minVersion и maxVersion для избежания конфликтов TLS
|
||||
},
|
||||
connectionTimeout: 30000, // 30 секунд на подключение
|
||||
greetingTimeout: 30000, // 30 секунд на приветствие
|
||||
socketTimeout: 60000, // 60 секунд на операции сокета
|
||||
@@ -103,18 +106,27 @@ class EmailBotService {
|
||||
async getImapConfig() {
|
||||
const settings = await this.getSettingsFromDb();
|
||||
return {
|
||||
user: settings.smtp_user,
|
||||
password: settings.smtp_password,
|
||||
user: settings.imap_user, // Используем IMAP пользователя
|
||||
password: settings.imap_password, // Используем IMAP пароль
|
||||
host: settings.imap_host,
|
||||
port: settings.imap_port,
|
||||
tls: true,
|
||||
tlsOptions: { rejectUnauthorized: false },
|
||||
port: 993, // Используем порт 993 для IMAPS (SSL)
|
||||
tls: true, // Включаем SSL
|
||||
tlsOptions: {
|
||||
rejectUnauthorized: false,
|
||||
servername: settings.imap_host,
|
||||
// Убираем minVersion и maxVersion для избежания конфликтов TLS
|
||||
ciphers: 'HIGH:!aNULL:!MD5:!RC4' // Безопасные шифры
|
||||
},
|
||||
keepalive: {
|
||||
interval: 10000,
|
||||
idleInterval: 300000,
|
||||
forceNoop: true,
|
||||
},
|
||||
connTimeout: 30000, // 30 секунд
|
||||
connTimeout: 60000, // 60 секунд
|
||||
authTimeout: 60000, // Таймаут на аутентификацию - 60 секунд
|
||||
greetingTimeout: 30000, // Таймаут на приветствие сервера
|
||||
socketTimeout: 60000, // Таймаут на операции сокета
|
||||
debug: console.log // Включаем отладку для диагностики
|
||||
};
|
||||
}
|
||||
|
||||
@@ -328,16 +340,7 @@ class EmailBotService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем время письма - не обрабатываем письма старше 1 часа
|
||||
const emailDate = parsed.date || new Date();
|
||||
const now = new Date();
|
||||
const timeDiff = now.getTime() - emailDate.getTime();
|
||||
const hoursDiff = timeDiff / (1000 * 60 * 60);
|
||||
|
||||
if (hoursDiff > 1) {
|
||||
logger.info(`[EmailBot] Игнорируем старое письмо от ${fromEmail} (${hoursDiff.toFixed(1)} часов назад)`);
|
||||
return;
|
||||
}
|
||||
// Временные ограничения удалены - обрабатываем все письма независимо от возраста
|
||||
|
||||
// 1. Найти или создать пользователя
|
||||
const { userId, role } = await identityService.findOrCreateUserWithRole('email', fromEmail);
|
||||
@@ -371,6 +374,40 @@ class EmailBotService {
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем, не обрабатывали ли мы уже это письмо (улучшенная проверка)
|
||||
if (messageId) {
|
||||
try {
|
||||
// Проверяем, есть ли уже ответ от AI для этого письма
|
||||
// Ищем сообщения с direction='out' и metadata, содержащим originalMessageId
|
||||
const existingResponse = await encryptedDb.getData(
|
||||
'messages',
|
||||
{
|
||||
user_id: userId,
|
||||
channel: 'email',
|
||||
direction: 'out'
|
||||
},
|
||||
1
|
||||
);
|
||||
|
||||
// Проверяем в результатах, есть ли сообщение с metadata.originalMessageId = messageId
|
||||
const hasResponse = existingResponse.some(msg => {
|
||||
try {
|
||||
const metadata = msg.metadata;
|
||||
return metadata && metadata.originalMessageId === messageId;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasResponse) {
|
||||
logger.info(`[EmailBot] Письмо ${messageId} уже обработано - найден ответ от AI`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[EmailBot] Ошибка при проверке существующих ответов: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 1.1 Найти или создать беседу
|
||||
let conversationResult = await encryptedDb.getData(
|
||||
'conversations',
|
||||
@@ -451,18 +488,78 @@ class EmailBotService {
|
||||
if (ragResult && ragResult.answer && typeof ragResult.score === 'number' && Math.abs(ragResult.score) <= 0.1) {
|
||||
aiResponse = ragResult.answer;
|
||||
} else {
|
||||
aiResponse = await generateLLMResponse({
|
||||
userQuestion: text,
|
||||
context: ragResult && ragResult.context ? ragResult.context : '',
|
||||
answer: ragResult && ragResult.answer ? ragResult.answer : '',
|
||||
systemPrompt: aiSettings ? aiSettings.system_prompt : '',
|
||||
// Используем очередь AIQueue для LLM генерации
|
||||
const requestId = await aiAssistant.addToQueue({
|
||||
message: text,
|
||||
history: null,
|
||||
model: aiSettings ? aiSettings.model : undefined,
|
||||
language: aiSettings && aiSettings.languages && aiSettings.languages.length > 0 ? aiSettings.languages[0] : 'ru'
|
||||
systemPrompt: aiSettings ? aiSettings.system_prompt : '',
|
||||
rules: null
|
||||
}, 0);
|
||||
|
||||
// Ждем ответ из очереди
|
||||
aiResponse = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('AI response timeout'));
|
||||
}, 120000); // 2 минуты таймаут
|
||||
|
||||
const onCompleted = (item) => {
|
||||
if (item.id === requestId) {
|
||||
clearTimeout(timeout);
|
||||
aiAssistant.aiQueue.off('requestCompleted', onCompleted);
|
||||
aiAssistant.aiQueue.off('requestFailed', onFailed);
|
||||
resolve(item.result);
|
||||
}
|
||||
};
|
||||
|
||||
const onFailed = (item) => {
|
||||
if (item.id === requestId) {
|
||||
clearTimeout(timeout);
|
||||
aiAssistant.aiQueue.off('requestCompleted', onCompleted);
|
||||
aiAssistant.aiQueue.off('requestFailed', onFailed);
|
||||
reject(new Error(item.error));
|
||||
}
|
||||
};
|
||||
|
||||
aiAssistant.aiQueue.on('requestCompleted', onCompleted);
|
||||
aiAssistant.aiQueue.on('requestFailed', onFailed);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
aiResponse = await aiAssistant.getResponse(text, 'auto');
|
||||
// Используем очередь AIQueue для обработки
|
||||
const requestId = await aiAssistant.addToQueue({
|
||||
message: text,
|
||||
history: null,
|
||||
systemPrompt: aiSettings ? aiSettings.system_prompt : '',
|
||||
rules: null
|
||||
}, 0);
|
||||
|
||||
// Ждем ответ из очереди
|
||||
aiResponse = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('AI response timeout'));
|
||||
}, 120000); // 2 минуты таймаут
|
||||
|
||||
const onCompleted = (item) => {
|
||||
if (item.id === requestId) {
|
||||
clearTimeout(timeout);
|
||||
aiAssistant.aiQueue.off('requestCompleted', onCompleted);
|
||||
aiAssistant.aiQueue.off('requestFailed', onFailed);
|
||||
resolve(item.result);
|
||||
}
|
||||
};
|
||||
|
||||
const onFailed = (item) => {
|
||||
if (item.id === requestId) {
|
||||
clearTimeout(timeout);
|
||||
aiAssistant.aiQueue.off('requestCompleted', onCompleted);
|
||||
aiAssistant.aiQueue.off('requestFailed', onFailed);
|
||||
reject(new Error(item.error));
|
||||
}
|
||||
};
|
||||
|
||||
aiAssistant.aiQueue.on('requestCompleted', onCompleted);
|
||||
aiAssistant.aiQueue.on('requestFailed', onFailed);
|
||||
});
|
||||
}
|
||||
|
||||
if (await isUserBlocked(userId)) {
|
||||
@@ -588,11 +685,36 @@ class EmailBotService {
|
||||
|
||||
this.imap.once('error', (err) => {
|
||||
logger.error(`[EmailBot] IMAP connection error: ${err.message}`);
|
||||
logger.error(`[EmailBot] Error details:`, {
|
||||
code: err.code,
|
||||
errno: err.errno,
|
||||
syscall: err.syscall,
|
||||
hostname: err.hostname,
|
||||
port: err.port,
|
||||
stack: err.stack
|
||||
});
|
||||
this.cleanupImapConnection();
|
||||
|
||||
if (err.message && err.message.toLowerCase().includes('timed out') && attempt < maxAttempts) {
|
||||
logger.warn(`[EmailBot] IMAP reconnecting in 10 seconds (attempt ${attempt + 1})...`);
|
||||
setTimeout(tryConnect, 10000);
|
||||
// Более детальная логика переподключения
|
||||
if (attempt < maxAttempts) {
|
||||
let reconnectDelay = 10000;
|
||||
let reconnectReason = 'default';
|
||||
|
||||
if (err.message && err.message.toLowerCase().includes('timed out')) {
|
||||
reconnectDelay = 15000; // Увеличиваем задержку для таймаутов
|
||||
reconnectReason = 'timeout';
|
||||
} else if (err.code === 'ECONNREFUSED') {
|
||||
reconnectDelay = 30000; // Дольше ждем для отказа в соединении
|
||||
reconnectReason = 'connection refused';
|
||||
} else if (err.code === 'ENOTFOUND') {
|
||||
reconnectDelay = 60000; // Еще дольше для проблем с DNS
|
||||
reconnectReason = 'DNS resolution failed';
|
||||
}
|
||||
|
||||
logger.warn(`[EmailBot] IMAP reconnecting in ${reconnectDelay/1000} seconds (attempt ${attempt + 1}/${maxAttempts}, reason: ${reconnectReason})...`);
|
||||
setTimeout(tryConnect, reconnectDelay);
|
||||
} else {
|
||||
logger.error(`[EmailBot] Max reconnection attempts reached (${maxAttempts}). Stopping reconnection.`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -611,6 +733,112 @@ class EmailBotService {
|
||||
const settings = await encryptedDb.getData('email_settings', {}, null, 'id');
|
||||
return settings;
|
||||
}
|
||||
|
||||
// Сохранение email настроек
|
||||
async saveEmailSettings(settings) {
|
||||
try {
|
||||
// Проверяем, существуют ли уже настройки
|
||||
const existingSettings = await encryptedDb.getData('email_settings', {}, 1);
|
||||
|
||||
let result;
|
||||
if (existingSettings.length > 0) {
|
||||
// Если настройки существуют, обновляем их
|
||||
const existingId = existingSettings[0].id;
|
||||
result = await encryptedDb.saveData('email_settings', settings, { id: existingId });
|
||||
} else {
|
||||
// Если настроек нет, создаем новые
|
||||
result = await encryptedDb.saveData('email_settings', settings, null);
|
||||
}
|
||||
|
||||
logger.info('Email settings saved successfully');
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
logger.error('Error saving email settings:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Тест IMAP подключения
|
||||
async testImapConnection() {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
logger.info('[EmailBot] Testing IMAP connection...');
|
||||
|
||||
// Получаем конфигурацию IMAP
|
||||
const imapConfig = await this.getImapConfig();
|
||||
|
||||
// Создаем временное IMAP соединение для теста
|
||||
const testImap = new Imap(imapConfig);
|
||||
|
||||
let connectionTimeout = setTimeout(() => {
|
||||
testImap.end();
|
||||
reject(new Error('IMAP connection timeout after 30 seconds'));
|
||||
}, 30000);
|
||||
|
||||
testImap.once('ready', () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
logger.info('[EmailBot] IMAP connection test successful');
|
||||
testImap.end();
|
||||
resolve({
|
||||
success: true,
|
||||
message: 'IMAP подключение успешно установлено',
|
||||
details: {
|
||||
host: imapConfig.host,
|
||||
port: imapConfig.port,
|
||||
user: imapConfig.user
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
testImap.once('error', (err) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
logger.error(`[EmailBot] IMAP connection test failed: ${err.message}`);
|
||||
testImap.end();
|
||||
reject(new Error(`IMAP подключение не удалось: ${err.message}`));
|
||||
});
|
||||
|
||||
testImap.once('end', () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
logger.info('[EmailBot] IMAP connection test ended');
|
||||
});
|
||||
|
||||
testImap.connect();
|
||||
|
||||
} catch (error) {
|
||||
reject(new Error(`Ошибка при тестировании IMAP: ${error.message}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Тест SMTP подключения
|
||||
async testSmtpConnection() {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
logger.info('[EmailBot] Testing SMTP connection...');
|
||||
|
||||
// Получаем транспортер SMTP
|
||||
const transporter = await this.getTransporter();
|
||||
|
||||
// Тестируем подключение
|
||||
await transporter.verify();
|
||||
|
||||
logger.info('[EmailBot] SMTP connection test successful');
|
||||
resolve({
|
||||
success: true,
|
||||
message: 'SMTP подключение успешно установлено',
|
||||
details: {
|
||||
host: transporter.options.host,
|
||||
port: transporter.options.port,
|
||||
secure: transporter.options.secure
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`[EmailBot] SMTP connection test failed: ${error.message}`);
|
||||
reject(new Error(`SMTP подключение не удалось: ${error.message}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// console.log('[EmailBot] module.exports = EmailBotService');
|
||||
|
||||
@@ -432,20 +432,77 @@ async function getBot() {
|
||||
if (ragResult && ragResult.answer && typeof ragResult.score === 'number' && Math.abs(ragResult.score) <= 0.1) {
|
||||
aiResponse = ragResult.answer;
|
||||
} else {
|
||||
aiResponse = await generateLLMResponse({
|
||||
userQuestion: content,
|
||||
context: ragResult && ragResult.context ? ragResult.context : '',
|
||||
answer: ragResult && ragResult.answer ? ragResult.answer : '',
|
||||
systemPrompt: aiSettings ? aiSettings.system_prompt : '',
|
||||
// Используем очередь AIQueue для LLM генерации
|
||||
const requestId = await aiAssistant.addToQueue({
|
||||
message: content,
|
||||
history: history,
|
||||
model: aiSettings ? aiSettings.model : undefined,
|
||||
language: aiSettings && aiSettings.languages && aiSettings.languages.length > 0 ? aiSettings.languages[0] : 'ru'
|
||||
systemPrompt: aiSettings ? aiSettings.system_prompt : '',
|
||||
rules: null
|
||||
}, 0);
|
||||
|
||||
// Ждем ответ из очереди
|
||||
aiResponse = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('AI response timeout'));
|
||||
}, 120000); // 2 минуты таймаут
|
||||
|
||||
const onCompleted = (item) => {
|
||||
if (item.id === requestId) {
|
||||
clearTimeout(timeout);
|
||||
aiAssistant.aiQueue.off('requestCompleted', onCompleted);
|
||||
aiAssistant.aiQueue.off('requestFailed', onFailed);
|
||||
resolve(item.result);
|
||||
}
|
||||
};
|
||||
|
||||
const onFailed = (item) => {
|
||||
if (item.id === requestId) {
|
||||
clearTimeout(timeout);
|
||||
aiAssistant.aiQueue.off('requestCompleted', onCompleted);
|
||||
aiAssistant.aiQueue.off('requestFailed', onFailed);
|
||||
reject(new Error(item.error));
|
||||
}
|
||||
};
|
||||
|
||||
aiAssistant.aiQueue.on('requestCompleted', onCompleted);
|
||||
aiAssistant.aiQueue.on('requestFailed', onFailed);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Используем системный промпт из настроек, если RAG не используется
|
||||
const systemPrompt = aiSettings ? aiSettings.system_prompt : '';
|
||||
aiResponse = await aiAssistant.getResponse(content, history, systemPrompt);
|
||||
// Используем очередь AIQueue для обработки
|
||||
const requestId = await aiAssistant.addToQueue({
|
||||
message: content,
|
||||
history: history,
|
||||
systemPrompt: aiSettings ? aiSettings.system_prompt : '',
|
||||
rules: null
|
||||
}, 0);
|
||||
|
||||
// Ждем ответ из очереди
|
||||
aiResponse = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('AI response timeout'));
|
||||
}, 120000); // 2 минуты таймаут
|
||||
|
||||
const onCompleted = (item) => {
|
||||
if (item.id === requestId) {
|
||||
clearTimeout(timeout);
|
||||
aiAssistant.aiQueue.off('requestCompleted', onCompleted);
|
||||
aiAssistant.aiQueue.off('requestFailed', onFailed);
|
||||
resolve(item.result);
|
||||
}
|
||||
};
|
||||
|
||||
const onFailed = (item) => {
|
||||
if (item.id === requestId) {
|
||||
clearTimeout(timeout);
|
||||
aiAssistant.aiQueue.off('requestFailed', onFailed);
|
||||
reject(new Error(item.error));
|
||||
}
|
||||
};
|
||||
|
||||
aiAssistant.aiQueue.on('requestCompleted', onCompleted);
|
||||
aiAssistant.aiQueue.on('requestFailed', onFailed);
|
||||
});
|
||||
}
|
||||
|
||||
return aiResponse;
|
||||
@@ -576,6 +633,36 @@ function clearSettingsCache() {
|
||||
telegramSettingsCache = null;
|
||||
}
|
||||
|
||||
// Сохранение настроек Telegram
|
||||
async function saveTelegramSettings(settings) {
|
||||
try {
|
||||
// Очищаем кэш настроек
|
||||
clearSettingsCache();
|
||||
|
||||
// Проверяем, существуют ли уже настройки
|
||||
const existingSettings = await encryptedDb.getData('telegram_settings', {}, 1);
|
||||
|
||||
let result;
|
||||
if (existingSettings.length > 0) {
|
||||
// Если настройки существуют, обновляем их
|
||||
const existingId = existingSettings[0].id;
|
||||
result = await encryptedDb.saveData('telegram_settings', settings, { id: existingId });
|
||||
} else {
|
||||
// Если настроек нет, создаем новые
|
||||
result = await encryptedDb.saveData('telegram_settings', settings, null);
|
||||
}
|
||||
|
||||
// Обновляем кэш
|
||||
telegramSettingsCache = settings;
|
||||
|
||||
logger.info('Telegram settings saved successfully');
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
logger.error('Error saving Telegram settings:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllBots() {
|
||||
const settings = await encryptedDb.getData('telegram_settings', {}, 1, 'id');
|
||||
return settings;
|
||||
@@ -587,5 +674,6 @@ module.exports = {
|
||||
stopBot,
|
||||
initTelegramAuth,
|
||||
clearSettingsCache,
|
||||
saveTelegramSettings,
|
||||
getAllBots,
|
||||
};
|
||||
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
environment:
|
||||
- OLLAMA_HOST=0.0.0.0
|
||||
- OLLAMA_ORIGINS=*
|
||||
- OLLAMA_NUM_PARALLEL=1
|
||||
- OLLAMA_NUM_PARALLEL=2
|
||||
- OLLAMA_NUM_GPU=0
|
||||
- OLLAMA_KEEP_ALIVE=-1
|
||||
- OLLAMA_MODEL_TIMEOUT=0
|
||||
@@ -64,8 +64,8 @@ services:
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 120s
|
||||
# Современные версии ollama не поддерживают флаг --keep-alive; используем переменные окружения
|
||||
# command: ["serve"]
|
||||
# Предзагружаем модель при запуске контейнера
|
||||
entrypoint: ["/bin/sh", "-c", "ollama serve & sleep 10 && ollama run --keepalive 0 qwen2.5:7b & wait"]
|
||||
vector-search:
|
||||
build:
|
||||
context: ./vector-search
|
||||
|
||||
@@ -20,6 +20,12 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
# Передача cookies и сессии
|
||||
proxy_pass_request_headers on;
|
||||
proxy_pass_request_body on;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
|
||||
# Проксирование к development серверу frontend
|
||||
|
||||
@@ -46,28 +46,68 @@
|
||||
|
||||
<div class="rag-test-section">
|
||||
<h4>🧠 Тест RAG-функциональности</h4>
|
||||
|
||||
|
||||
|
||||
<!-- Выбор RAG-таблицы -->
|
||||
<div class="rag-table-selection">
|
||||
<label>Выберите RAG-таблицу:</label>
|
||||
<div class="rag-table-controls">
|
||||
<select v-model="selectedRagTable" class="rag-table-select">
|
||||
<option v-if="availableRagTables.length === 0" value="" disabled>
|
||||
Нет доступных RAG-таблиц
|
||||
</option>
|
||||
<option v-for="table in availableRagTables" :key="table.id" :value="table.id">
|
||||
{{ table.name }} (ID: {{ table.id }})
|
||||
</option>
|
||||
</select>
|
||||
<button @click="loadRagTables" class="refresh-tables-btn" title="Обновить список таблиц">
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="availableRagTables.length === 0" class="no-rag-tables">
|
||||
<p>Для тестирования RAG необходимо создать таблицу и установить её как источник для ИИ-ассистента.</p>
|
||||
<p>Перейдите в <router-link to="/tables">Таблицы</router-link> и создайте таблицу с вопросами и ответами.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rag-test-controls">
|
||||
<input
|
||||
v-model="ragQuestion"
|
||||
placeholder="Введите вопрос"
|
||||
class="rag-input"
|
||||
/>
|
||||
<button @click="testRAG" :disabled="ragTesting" class="rag-test-btn">
|
||||
<button @click="testRAG" :disabled="ragTesting || !selectedRagTable" class="rag-test-btn">
|
||||
{{ ragTesting ? 'Тестирование...' : 'Тестировать RAG' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Прогресс-бар и статус -->
|
||||
<div v-if="ragTesting" class="rag-progress-section">
|
||||
<div class="rag-status">{{ ragStatus }}</div>
|
||||
<div class="rag-progress-bar">
|
||||
<div class="rag-progress-fill" :style="{ width: ragProgress + '%' }"></div>
|
||||
</div>
|
||||
<div class="rag-progress-text">{{ Math.round(ragProgress) }}%</div>
|
||||
</div>
|
||||
|
||||
<div v-if="ragResult" :class="['rag-result', getRagResultClass()]">
|
||||
<div v-if="ragResult.success">
|
||||
<strong>✅ Успешно!</strong><br>
|
||||
Таблица: {{ availableRagTables.find(t => t.id === selectedRagTable)?.name || 'Неизвестно' }}<br>
|
||||
Вопрос: "{{ ragQuestion }}"<br>
|
||||
Ответ: "{{ ragResult.answer || 'Нет ответа' }}"<br>
|
||||
Score: {{ ragResult.score || 'N/A' }}
|
||||
|
||||
</div>
|
||||
<div v-else>
|
||||
<strong>❌ Ошибка!</strong><br>
|
||||
{{ ragResult.error }}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -78,10 +118,14 @@ import axios from 'axios';
|
||||
|
||||
const loading = ref(false);
|
||||
const lastUpdate = ref('никогда');
|
||||
const ragQuestion = ref('вопрос 1');
|
||||
const ragQuestion = ref('Как работает ИИ-ассистент?');
|
||||
const ragTesting = ref(false);
|
||||
const ragResult = ref(null);
|
||||
const monitoringData = ref(null);
|
||||
const availableRagTables = ref([]);
|
||||
const selectedRagTable = ref(null);
|
||||
const ragProgress = ref(0);
|
||||
const ragStatus = ref('');
|
||||
|
||||
const serviceLabels = {
|
||||
backend: 'Backend',
|
||||
@@ -127,6 +171,25 @@ const getRagResultClass = () => {
|
||||
return ragResult.value.success ? 'success' : 'error';
|
||||
};
|
||||
|
||||
const loadRagTables = async () => {
|
||||
try {
|
||||
const response = await axios.get('/tables');
|
||||
const tables = response.data || [];
|
||||
|
||||
// Фильтруем только таблицы, которые являются источниками для RAG
|
||||
const ragTables = tables.filter(table => table.is_rag_source_id === 1);
|
||||
|
||||
availableRagTables.value = ragTables;
|
||||
|
||||
// Если есть доступные таблицы, выбираем первую по умолчанию
|
||||
if (availableRagTables.value.length > 0 && !selectedRagTable.value) {
|
||||
selectedRagTable.value = availableRagTables.value[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
availableRagTables.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const refreshStatus = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -142,31 +205,74 @@ const refreshStatus = async () => {
|
||||
|
||||
const testRAG = async () => {
|
||||
if (!ragQuestion.value.trim()) return;
|
||||
|
||||
if (!selectedRagTable.value) {
|
||||
ragResult.value = {
|
||||
success: false,
|
||||
error: 'Не выбрана RAG-таблица для тестирования'
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
ragTesting.value = true;
|
||||
ragResult.value = null;
|
||||
ragProgress.value = 0;
|
||||
ragStatus.value = '🔍 Ищем ответ в базе знаний...';
|
||||
|
||||
// Симуляция прогресса для лучшего UX
|
||||
const progressInterval = setInterval(() => {
|
||||
if (ragProgress.value < 90) {
|
||||
ragProgress.value += Math.random() * 15;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
ragStatus.value = '🤖 Генерируем ответ с помощью ИИ...';
|
||||
|
||||
const response = await axios.post('/rag/answer', {
|
||||
tableId: 28,
|
||||
tableId: selectedRagTable.value,
|
||||
question: ragQuestion.value,
|
||||
product: null
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
ragProgress.value = 100;
|
||||
ragStatus.value = '✅ Готово!';
|
||||
|
||||
ragResult.value = {
|
||||
success: true,
|
||||
answer: response.data.answer,
|
||||
score: response.data.score,
|
||||
llmResponse: response.data.llmResponse
|
||||
};
|
||||
|
||||
// Обновляем список таблиц после успешного тестирования
|
||||
await loadRagTables();
|
||||
} catch (error) {
|
||||
clearInterval(progressInterval);
|
||||
ragProgress.value = 0;
|
||||
ragStatus.value = '';
|
||||
|
||||
ragResult.value = {
|
||||
success: false,
|
||||
error: error.response?.data?.error || error.message || 'Неизвестная ошибка'
|
||||
};
|
||||
}
|
||||
|
||||
ragTesting.value = false;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
refreshStatus();
|
||||
loadRagTables();
|
||||
|
||||
// Подписываемся на обновление плейсхолдеров (когда создаются новые таблицы)
|
||||
window.addEventListener('placeholders-updated', loadRagTables);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Отписываемся от события
|
||||
window.removeEventListener('placeholders-updated', loadRagTables);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -311,6 +417,132 @@ onMounted(() => {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.rag-table-selection {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.rag-table-selection label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.rag-table-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rag-table-select {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.refresh-tables-btn {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.refresh-tables-btn:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.no-rag-tables {
|
||||
padding: 15px;
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 6px;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.no-rag-tables p {
|
||||
margin: 5px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.no-rag-tables a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.no-rag-tables a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rag-progress-section {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.rag-status {
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.rag-progress-bar {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background: #e9ecef;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rag-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #28a745, #20c997);
|
||||
border-radius: 10px;
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rag-progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.rag-progress-text {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #28a745;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.rag-test-section h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
|
||||
@@ -168,6 +168,9 @@ function autoResize() {
|
||||
|
||||
watch(editing, (val) => {
|
||||
if (val) {
|
||||
if (props.column.type === 'multiselect-relation') {
|
||||
loadMultiRelationOptions();
|
||||
}
|
||||
nextTick(() => {
|
||||
if (textareaRef.value) {
|
||||
autoResize();
|
||||
@@ -220,6 +223,7 @@ let unsubscribeFromTags = null;
|
||||
// Флаг для предотвращения повторных вызовов
|
||||
let isInitialized = false;
|
||||
let isMultiRelationValuesLoaded = false;
|
||||
let lastLoadedOptionsKey = null;
|
||||
|
||||
onMounted(async () => {
|
||||
const startTime = Date.now();
|
||||
@@ -250,14 +254,12 @@ onMounted(async () => {
|
||||
} else if (props.column.type === 'multiselect-relation') {
|
||||
// Загружаем опции только один раз
|
||||
if (!isInitialized) {
|
||||
// console.log(`[TableCell] 📥 Загружаем опции для row:${props.rowId} col:${props.column.id}`);
|
||||
await loadMultiRelationOptions();
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
// Загружаем relations только один раз для каждой комбинации rowId + columnId
|
||||
if (!isMultiRelationValuesLoaded) {
|
||||
// console.log(`[TableCell] 📥 Загружаем relations для row:${props.rowId} col:${props.column.id}`);
|
||||
await loadMultiRelationValues();
|
||||
isMultiRelationValuesLoaded = true;
|
||||
}
|
||||
@@ -326,6 +328,12 @@ onUnmounted(() => {
|
||||
watch(
|
||||
() => [props.rowId, props.column.id, props.cellValues],
|
||||
async () => {
|
||||
// Сбрасываем флаги при изменении столбца
|
||||
if (props.column.type === 'multiselect-relation') {
|
||||
isMultiRelationValuesLoaded = false;
|
||||
lastLoadedOptionsKey = null;
|
||||
isInitialized = false;
|
||||
}
|
||||
if (props.column.type === 'multiselect') {
|
||||
multiOptions.value = (props.column.options && props.column.options.options) || [];
|
||||
const cell = props.cellValues.find(
|
||||
@@ -485,9 +493,9 @@ async function loadLookupValues() {
|
||||
}
|
||||
|
||||
async function loadMultiRelationOptions() {
|
||||
// Проверяем, не загружены ли уже опции
|
||||
if (multiRelationOptions.value.length > 0) {
|
||||
// console.log('[loadMultiRelationOptions] Опции уже загружены, пропускаем');
|
||||
// Проверяем, не загружены ли уже опции для текущего столбца
|
||||
const cacheKey = `${props.column.id}_${props.column.options?.relatedTableId}`;
|
||||
if (multiRelationOptions.value.length > 0 && lastLoadedOptionsKey === cacheKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -517,7 +525,16 @@ async function loadMultiRelationOptions() {
|
||||
opts.push({ id: row.id, display: cell ? cell.value : `ID ${row.id}` });
|
||||
}
|
||||
multiRelationOptions.value = opts;
|
||||
// console.log(`[loadMultiRelationOptions] Загружено ${opts.length} опций для таблицы ${rel.relatedTableId}`);
|
||||
lastLoadedOptionsKey = cacheKey;
|
||||
|
||||
// Обновляем selectedMultiRelationNames на основе текущих значений
|
||||
if (editMultiRelationValues.value.length > 0) {
|
||||
selectedMultiRelationNames.value = opts
|
||||
.filter(opt => editMultiRelationValues.value.includes(String(opt.id)))
|
||||
.map(opt => opt.display);
|
||||
} else {
|
||||
selectedMultiRelationNames.value = [];
|
||||
}
|
||||
} catch (e) {
|
||||
// console.error('[loadMultiRelationOptions] Error:', e);
|
||||
}
|
||||
@@ -531,7 +548,6 @@ const LOAD_DEBOUNCE_DELAY = 50; // 50ms (уменьшено для ускоре
|
||||
async function loadMultiRelationValues() {
|
||||
// Проверяем, не загружены ли уже данные
|
||||
if (isMultiRelationValuesLoaded) {
|
||||
// console.log('[loadMultiRelationValues] Данные уже загружены, пропускаем');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -603,7 +619,6 @@ async function loadMultiRelationValues() {
|
||||
selectedMultiRelationNames.value = multiRelationOptions.value
|
||||
.filter(opt => relatedRowIds.includes(String(opt.id)))
|
||||
.map(opt => opt.display);
|
||||
// console.log('[loadMultiRelationValues] selectedMultiRelationNames:', selectedMultiRelationNames.value);
|
||||
|
||||
// Отмечаем, что данные загружены
|
||||
isMultiRelationValuesLoaded = true;
|
||||
|
||||
@@ -68,13 +68,17 @@
|
||||
<input v-else v-model="settings.embedding_model" placeholder="bge-base-zh" />
|
||||
<label>Выбранные RAG-таблицы</label>
|
||||
<select v-model="settings.selected_rag_tables" :multiple="false">
|
||||
<option v-for="table in ragTables" :key="table.id" :value="table.id">{{ table.name }} (id: {{ table.id }})</option>
|
||||
<option value="">Выберите таблицу</option>
|
||||
<option v-for="table in ragTables" :key="table.id" :value="table.id">
|
||||
{{ getTableDisplayName(table) }}
|
||||
</option>
|
||||
</select>
|
||||
<label>Набор правил</label>
|
||||
<div class="rules-row">
|
||||
<select v-model="settings.rules_id">
|
||||
<option value="">Выберите набор правил</option>
|
||||
<option v-for="rule in rulesList" :key="rule.id" :value="rule.id">
|
||||
{{ rule.name }}
|
||||
{{ getRuleDisplayName(rule) }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" @click="openRuleEditor()">Создать</button>
|
||||
@@ -150,7 +154,18 @@ async function loadRules() {
|
||||
async function loadSettings() {
|
||||
const { data } = await axios.get('/settings/ai-assistant');
|
||||
if (data.success && data.settings) {
|
||||
settings.value = data.settings;
|
||||
// Обрабатываем selected_rag_tables - если это массив, берем первый элемент для single select
|
||||
const settingsData = { ...data.settings };
|
||||
if (Array.isArray(settingsData.selected_rag_tables) && settingsData.selected_rag_tables.length > 0) {
|
||||
// Для single select берем первый элемент массива
|
||||
settingsData.selected_rag_tables = settingsData.selected_rag_tables[0];
|
||||
} else if (!Array.isArray(settingsData.selected_rag_tables)) {
|
||||
// Если это не массив, устанавливаем пустое значение
|
||||
settingsData.selected_rag_tables = '';
|
||||
}
|
||||
|
||||
settings.value = settingsData;
|
||||
console.log('[AiAssistantSettings] Loaded settings:', settings.value);
|
||||
}
|
||||
}
|
||||
async function loadTelegramBots() {
|
||||
@@ -204,7 +219,14 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('placeholders-updated', loadPlaceholders);
|
||||
});
|
||||
async function saveSettings() {
|
||||
await axios.put('/settings/ai-assistant', settings.value);
|
||||
// Преобразуем selected_rag_tables в массив перед сохранением
|
||||
const settingsToSave = { ...settings.value };
|
||||
if (settingsToSave.selected_rag_tables && !Array.isArray(settingsToSave.selected_rag_tables)) {
|
||||
settingsToSave.selected_rag_tables = [settingsToSave.selected_rag_tables];
|
||||
}
|
||||
|
||||
console.log('[AiAssistantSettings] Saving settings:', settingsToSave);
|
||||
await axios.put('/settings/ai-assistant', settingsToSave);
|
||||
goBack();
|
||||
}
|
||||
function openRuleEditor(ruleId = null) {
|
||||
@@ -226,6 +248,16 @@ async function onRuleEditorClose(updated) {
|
||||
editingRule.value = null;
|
||||
if (updated) await loadRules();
|
||||
}
|
||||
|
||||
function getTableDisplayName(table) {
|
||||
if (!table) return '';
|
||||
return table.name || `Таблица ${table.id}`;
|
||||
}
|
||||
|
||||
function getRuleDisplayName(rule) {
|
||||
if (!rule) return '';
|
||||
return rule.name || `Набор правил ${rule.id}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -41,6 +41,14 @@
|
||||
<label for="imapPort">IMAP Port</label>
|
||||
<input id="imapPort" v-model.number="form.imapPort" type="number" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="imapUser">IMAP User</label>
|
||||
<input id="imapUser" v-model="form.imapUser" type="text" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="imapPassword">IMAP Password</label>
|
||||
<input id="imapPassword" v-model="form.imapPassword" type="password" :placeholder="form.imapPassword ? 'Изменить пароль' : 'Введите пароль'" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fromEmail">From Email</label>
|
||||
<input id="fromEmail" v-model="form.fromEmail" type="email" required />
|
||||
@@ -54,6 +62,8 @@
|
||||
<div class="view-row"><span>SMTP User:</span> <b>{{ form.smtpUser }}</b></div>
|
||||
<div class="view-row"><span>IMAP Host:</span> <b>{{ form.imapHost }}</b></div>
|
||||
<div class="view-row"><span>IMAP Port:</span> <b>{{ form.imapPort }}</b></div>
|
||||
<div class="view-row"><span>IMAP User:</span> <b>{{ form.imapUser }}</b></div>
|
||||
<div class="view-row"><span>IMAP Password:</span> <b>{{ form.imapPassword ? '••••••••' : 'Не установлен' }}</b></div>
|
||||
<div class="view-row"><span>From Email:</span> <b>{{ form.fromEmail }}</b></div>
|
||||
<button type="button" class="edit-btn" @click="editMode = true">Изменить</button>
|
||||
<button type="button" class="cancel-btn" @click="goBack">Закрыть</button>
|
||||
@@ -79,6 +89,8 @@ const form = reactive({
|
||||
smtpPassword: '',
|
||||
imapHost: '',
|
||||
imapPort: 993,
|
||||
imapUser: '',
|
||||
imapPassword: '',
|
||||
fromEmail: ''
|
||||
});
|
||||
const original = reactive({});
|
||||
@@ -94,6 +106,8 @@ const loadEmailSettings = async () => {
|
||||
form.smtpUser = s.smtp_user;
|
||||
form.imapHost = s.imap_host || '';
|
||||
form.imapPort = s.imap_port || 993;
|
||||
form.imapUser = s.imap_user || '';
|
||||
form.imapPassword = '';
|
||||
form.fromEmail = s.from_email;
|
||||
form.smtpPassword = '';
|
||||
Object.assign(original, JSON.parse(JSON.stringify(form)));
|
||||
@@ -117,10 +131,13 @@ const saveEmailSettings = async () => {
|
||||
smtp_password: form.smtpPassword || undefined,
|
||||
imap_host: form.imapHost,
|
||||
imap_port: form.imapPort,
|
||||
imap_user: form.imapUser,
|
||||
imap_password: form.imapPassword || undefined,
|
||||
from_email: form.fromEmail
|
||||
});
|
||||
alert('Настройки Email сохранены');
|
||||
form.smtpPassword = '';
|
||||
form.imapPassword = '';
|
||||
Object.assign(original, JSON.parse(JSON.stringify(form)));
|
||||
editMode.value = false;
|
||||
} catch (e) {
|
||||
@@ -131,6 +148,7 @@ const saveEmailSettings = async () => {
|
||||
const cancelEdit = () => {
|
||||
Object.assign(form, JSON.parse(JSON.stringify(original)));
|
||||
form.smtpPassword = '';
|
||||
form.imapPassword = '';
|
||||
editMode.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -76,7 +76,7 @@ onMounted(async () => {
|
||||
|
||||
const saveTelegramSettings = async () => {
|
||||
try {
|
||||
await api.put('/telegram-settings', {
|
||||
await api.put('/settings/telegram-settings', {
|
||||
bot_token: form.botToken,
|
||||
bot_username: form.botUsername
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user