feature/worker #111

Merged
primakov merged 190 commits from feature/worker into master 2025-12-05 16:59:42 +03:00
19 changed files with 3819 additions and 545 deletions
Showing only changes of commit ad35d47ff5 - Show all commits

2358
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,9 @@
"homepage": "https://bitbucket.org/online-mentor/multi-stub#readme",
"dependencies": {
"@supabase/supabase-js": "^2.49.4",
"@langchain/community": "^0.3.41",
"@langchain/core": "^0.3.46",
"@langchain/langgraph": "^0.2.65",
"ai": "^4.1.13",
"axios": "^1.7.7",
"bcrypt": "^5.1.0",
@@ -34,8 +37,11 @@
"express": "5.0.1",
"express-jwt": "^8.5.1",
"express-session": "^1.18.1",
"gigachat": "^0.0.14",
"jsdom": "^25.0.1",
"jsonwebtoken": "^9.0.2",
"langchain": "^0.3.7",
"langchain-gigachat": "^0.0.11",
"mongodb": "^6.12.0",
"mongoose": "^8.9.2",
"mongoose-sequence": "^6.0.1",
@@ -44,7 +50,7 @@
"pbkdf2-password": "^1.2.1",
"rotating-file-stream": "^3.2.5",
"socket.io": "^4.8.1",
"uuid": "^11.0.3"
"zod": "^3.24.3"
},
"devDependencies": {
"@eslint/js": "^9.17.0",

View File

@@ -21,6 +21,7 @@ import escRouter from './routers/esc'
import connectmeRouter from './routers/connectme'
import questioneerRouter from './routers/questioneer'
import { setIo } from './io'
const { createChatPollingRouter } = require('./routers/kfu-m-24-1/sber_mobile/polling-chat')
export const app = express()
@@ -64,8 +65,6 @@ const initServer = async () => {
console.log('warming up 🔥')
const server = setIo(app)
const sess = {
secret: "super-secret-key",
resave: true,
@@ -90,10 +89,18 @@ const initServer = async () => {
)
app.use(root)
// Инициализация Polling для чата (после настройки middleware)
const { router: chatPollingRouter, chatHandler } = createChatPollingRouter(express)
/**
* Добавляйте сюда свои routers.
*/
app.use("/kfu-m-24-1", kfuM241Router)
// Добавляем Polling роутер для чата
app.use("/kfu-m-24-1/sber_mobile", chatPollingRouter)
app.use("/epja-2024-1", epja20241Router)
app.use("/v1/todo", todoRouter)
app.use("/dogsitters-finder", dogsittersFinderRouter)
@@ -109,9 +116,10 @@ const initServer = async () => {
app.use(errorHandler)
server.listen(process.env.PORT ?? 8044, () =>
// Создаем обычный HTTP сервер
const server = app.listen(process.env.PORT ?? 8044, () => {
console.log(`🚀 Сервер запущен на http://localhost:${process.env.PORT ?? 8044}`)
)
})
// Обработка сигналов завершения процесса
process.on('SIGTERM', () => {
@@ -145,6 +153,8 @@ const initServer = async () => {
process.exit(1)
})
})
return server
}
initServer().catch(console.error)

View File

@@ -170,7 +170,7 @@ CREATE TABLE payment_service_details (
CREATE TABLE tickets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id),
building_id UUID NOT NULL REFERENCES buildings(id),
apartment_id UUID NOT NULL REFERENCES apartments(id),
title TEXT NOT NULL,
description TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('open', 'in_progress', 'resolved')),
@@ -197,6 +197,7 @@ CREATE INDEX idx_votes_initiative ON votes(initiative_id);
CREATE INDEX idx_messages_chat ON messages(chat_id);
CREATE INDEX idx_cameras_building ON cameras(building_id);
CREATE INDEX idx_tickets_user ON tickets(user_id);
CREATE INDEX idx_tickets_apartment ON tickets(apartment_id);
CREATE INDEX idx_apartments_building ON apartments(building_id);
CREATE INDEX idx_apartment_residents_apartment ON apartment_residents(apartment_id);
CREATE INDEX idx_apartment_residents_user ON apartment_residents(user_id);

View File

@@ -3,31 +3,22 @@ const { getSupabaseClient } = require('./supabaseClient');
// Получить все чаты по дому
router.get('/chats', async (req, res) => {
console.log('🏠 [Server] GET /chats запрос получен');
console.log('🏠 [Server] Query параметры:', req.query);
const supabase = getSupabaseClient();
const { building_id } = req.query;
if (!building_id) {
console.log('❌ [Server] Ошибка: building_id обязателен');
return res.status(400).json({ error: 'building_id required' });
}
try {
console.log('🔍 [Server] Выполняем запрос к Supabase для здания:', building_id);
const { data, error } = await supabase.from('chats').select('*').eq('building_id', building_id);
if (error) {
console.log('❌ [Server] Ошибка Supabase:', error);
return res.status(400).json({ error: error.message });
}
console.log('✅ [Server] Чаты получены:', data?.length || 0, 'шт.');
res.json(data || []);
} catch (err) {
console.log('❌ [Server] Неожиданная ошибка:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
@@ -184,15 +175,10 @@ router.get('/chats/:chat_id/stats', async (req, res) => {
// Получить последнее сообщение в чате
router.get('/chats/:chat_id/last-message', async (req, res) => {
console.log('💬 [Server] GET /chats/:chat_id/last-message запрос получен');
console.log('💬 [Server] Chat ID:', req.params.chat_id);
const supabase = getSupabaseClient();
const { chat_id } = req.params;
try {
console.log('🔍 [Server] Выполняем запрос последнего сообщения для чата:', chat_id);
// Получаем последнее сообщение
const { data: lastMessage, error } = await supabase
.from('messages')
@@ -205,10 +191,8 @@ router.get('/chats/:chat_id/last-message', async (req, res) => {
let data = null;
if (error && error.code === 'PGRST116') {
console.log(' [Server] Сообщений в чате нет (PGRST116)');
data = null;
} else if (error) {
console.log('❌ [Server] Ошибка Supabase при получении последнего сообщения:', error);
return res.status(400).json({ error: error.message });
} else if (lastMessage) {
// Получаем профиль пользователя для сообщения
@@ -223,12 +207,10 @@ router.get('/chats/:chat_id/last-message', async (req, res) => {
...lastMessage,
user_profiles: userProfile || null
};
console.log('✅ [Server] Последнее сообщение получено для чата:', chat_id);
}
}
res.json(data);
res.json(data);
} catch (err) {
console.log('❌ [Server] Неожиданная ошибка при получении последнего сообщения:', err);
res.status(500).json({ error: 'Internal server error' });
}
});

View File

@@ -1,5 +1,3 @@
const fetch = require('node-fetch');
const getSupabaseUrl = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json();
@@ -18,8 +16,75 @@ const getSupabaseServiceKey = async () => {
return data.features['sber_mobile'].SUPABASE_SERVICE_KEY.value;
};
const getGigaAuth = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json();
return data.features['sber_mobile'].GIGA_AUTH.value;
};
const getLangsmithApiKey = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json();
return data.features['sber_mobile'].LANGSMITH_API_KEY.value;
};
const getLangsmithEndpoint = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json();
return data.features['sber_mobile'].LANGSMITH_ENDPOINT.value;
};
const getLangsmithTracing = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json();
return data.features['sber_mobile'].LANGSMITH_TRACING.value;
};
const getLangsmithProject = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json();
return data.features['sber_mobile'].LANGSMITH_PROJECT.value;
};
const getTavilyApiKey = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json();
return data.features['sber_mobile'].TAVILY_API_KEY.value;
};
const getRagSupabaseServiceRoleKey = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json();
return data.features['sber_mobile'].RAG_SUPABASE_SERVICE_ROLE_KEY.value;
};
const getRagSupabaseUrl = async () => {
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev');
const data = await response.json();
return data.features['sber_mobile'].RAG_SUPABASE_URL.value;
};
module.exports = {
getSupabaseUrl,
getSupabaseKey,
getSupabaseServiceKey
};
getSupabaseServiceKey,
getGigaAuth
};
// IIFE для установки переменных окружения
(async () => {
try {
process.env.GIGA_AUTH = await getGigaAuth();
process.env.LANGSMITH_API_KEY = await getLangsmithApiKey();
process.env.LANGSMITH_ENDPOINT = await getLangsmithEndpoint();
process.env.LANGSMITH_TRACING = await getLangsmithTracing();
process.env.LANGSMITH_PROJECT = await getLangsmithProject();
process.env.TAVILY_API_KEY = await getTavilyApiKey();
process.env.RAG_SUPABASE_SERVICE_ROLE_KEY = await getRagSupabaseServiceRoleKey();
process.env.RAG_SUPABASE_URL = await getRagSupabaseUrl();
console.log('Environment variables loaded successfully');
} catch (error) {
console.error('Error loading environment variables:', error);
}
})();

View File

@@ -17,6 +17,7 @@ const avatarRouter = require('./media');
const supportRouter = require('./supportApi');
const moderateRouter = require('./moderate.js');
module.exports = router;
router.use('/auth', authRouter);
@@ -35,4 +36,5 @@ router.use('', buildingsRouter);
router.use('', userApartmentsRouter);
router.use('', avatarRouter);
router.use('', supportRouter);
router.use('', moderateRouter);
router.use('', moderateRouter);

View File

@@ -1,64 +1,59 @@
const router = require('express').Router();
const { getSupabaseClient } = require('./supabaseClient');
const { getIo } = require('../../../io'); // Импортируем Socket.IO
// Получить все сообщения в чате с информацией о пользователе
router.get('/messages', async (req, res) => {
console.log('📬 [Server] GET /messages запрос получен');
console.log('📬 [Server] Query параметры:', req.query);
const supabase = getSupabaseClient();
const { chat_id, limit = 50, offset = 0 } = req.query;
if (!chat_id) {
console.log('❌ [Server] Ошибка: chat_id обязателен');
return res.status(400).json({ error: 'chat_id required' });
}
try {
console.log('🔍 [Server] Выполняем запрос к Supabase для чата:', chat_id);
// Получаем сообщения
const { data: messages, error } = await supabase
.from('messages')
.select('*')
.eq('chat_id', chat_id)
.order('created_at', { ascending: false })
.limit(limit)
.range(offset, offset + limit - 1);
if (error) {
console.log('❌ [Server] Ошибка получения сообщений:', error);
return res.status(400).json({ error: error.message });
const { chat_id, limit = 50, offset = 0 } = req.query;
if (!chat_id) {
return res.status(400).json({ error: 'chat_id is required' });
}
const supabase = getSupabaseClient();
// Получаем профили пользователей для всех уникальных user_id
let data = messages || [];
if (data.length > 0) {
const userIds = [...new Set(data.map(msg => msg.user_id))];
console.log('👥 [Server] Получаем профили для пользователей:', userIds);
const { data, error } = await supabase
.from('messages')
.select(`
*,
user_profiles (
id,
full_name,
avatar_url
)
`)
.eq('chat_id', chat_id)
.order('created_at', { ascending: true })
.range(offset, offset + limit - 1);
if (error) {
return res.status(500).json({ error: 'Failed to fetch messages' });
}
// Получаем уникальные ID пользователей из сообщений, у которых нет профиля
const messagesWithoutProfiles = data.filter(msg => !msg.user_profiles);
const userIds = [...new Set(messagesWithoutProfiles.map(msg => msg.user_id))];
if (userIds.length > 0) {
const { data: profiles, error: profilesError } = await supabase
.from('user_profiles')
.select('id, full_name, avatar_url')
.in('id', userIds);
if (!profilesError && profiles) {
// Объединяем сообщения с профилями
data = data.map(msg => ({
...msg,
user_profiles: profiles.find(profile => profile.id === msg.user_id) || null
}));
console.log('✅ [Server] Профили пользователей добавлены к сообщениям');
} else {
console.log('⚠️ [Server] Ошибка получения профилей пользователей:', profilesError);
// Добавляем профили к сообщениям
data.forEach(message => {
if (!message.user_profiles) {
message.user_profiles = profiles.find(profile => profile.id === message.user_id) || null;
}
});
}
}
console.log('✅ [Server] Сообщения получены:', data?.length || 0, 'шт.');
res.json(data?.reverse() || []); // Возвращаем в хронологическом порядке
}
res.json(data);
} catch (err) {
console.log('❌ [Server] Неожиданная ошибка:', err);
res.status(500).json({ error: 'Internal server error' });
res.status(500).json({ error: 'Unexpected error occurred' });
}
});
@@ -94,6 +89,9 @@ router.post('/messages', async (req, res) => {
...newMessage,
user_profiles: userProfile || null
};
// Отправка через Socket.IO теперь происходит автоматически через Supabase Real-time подписку
// Это предотвращает дублирование сообщений
res.json(data);
});

View File

@@ -0,0 +1,822 @@
const { getSupabaseClient, initializationPromise } = require('./supabaseClient');
class ChatPollingHandler {
constructor() {
this.connectedClients = new Map(); // user_id -> { user_info, chats: Set(), lastActivity: Date }
this.chatParticipants = new Map(); // chat_id -> Set(user_id)
this.userEventQueues = new Map(); // user_id -> [{id, event, data, timestamp}]
this.eventIdCounter = 0;
this.realtimeSubscription = null;
// Инициализируем Supabase подписку с задержкой и проверками
this.initializeWithRetry();
// Очистка старых событий каждые 5 минут
setInterval(() => {
this.cleanupOldEvents();
}, 5 * 60 * 1000);
}
// Инициализация с повторными попытками
async initializeWithRetry() {
try {
// Сначала ждем завершения основной инициализации
await initializationPromise;
this.setupRealtimeSubscription();
this.testRealtimeConnection();
return;
} catch (error) {
console.log('❌ [Supabase] Основная инициализация неудачна, пробуем альтернативный подход');
}
// Если основная инициализация не удалась, используем повторные попытки
let attempts = 0;
const maxAttempts = 10;
const baseDelay = 2000; // 2 секунды
while (attempts < maxAttempts) {
try {
attempts++;
// Ждем перед попыткой
await new Promise(resolve => setTimeout(resolve, baseDelay * attempts));
// Проверяем готовность Supabase клиента
const supabase = getSupabaseClient();
if (supabase) {
this.setupRealtimeSubscription();
this.testRealtimeConnection();
return; // Успех, выходим
}
} catch (error) {
console.log(`❌ [Supabase] Попытка #${attempts} неудачна:`, error.message);
if (attempts === maxAttempts) {
console.error('❌ [Supabase] Все попытки инициализации исчерпаны');
console.error('❌ [Supabase] Realtime подписка будет недоступна');
return;
}
}
}
}
// Аутентификация пользователя
async handleAuthentication(req, res) {
const { user_id, token } = req.body;
if (!user_id) {
res.status(400).json({ error: 'user_id is required' });
return;
}
try {
// Проверяем пользователя в базе данных
const supabase = getSupabaseClient();
const { data: userProfile, error } = await supabase
.from('user_profiles')
.select('*')
.eq('id', user_id)
.single();
if (error) {
console.log('❌ [Polling Server] Пользователь не найден:', error);
res.status(401).json({ error: 'User not found' });
return;
}
// Регистрируем пользователя
this.connectedClients.set(user_id, {
user_info: {
user_id,
profile: userProfile,
last_seen: new Date()
},
chats: new Set(),
lastActivity: new Date()
});
// Создаем очередь событий для пользователя
if (!this.userEventQueues.has(user_id)) {
this.userEventQueues.set(user_id, []);
}
// Добавляем событие аутентификации в очередь
this.addEventToQueue(user_id, 'authenticated', {
message: 'Successfully authenticated',
user: userProfile
});
res.json({
success: true,
message: 'Successfully authenticated',
user: userProfile
});
} catch (error) {
console.error('❌ [Polling Server] Ошибка аутентификации:', error);
res.status(500).json({ error: 'Authentication failed' });
}
}
// Эндпоинт для получения событий (polling)
async handleGetEvents(req, res) {
try {
const { user_id, last_event_id } = req.query;
if (!user_id) {
res.status(400).json({ error: 'user_id is required' });
return;
}
const client = this.connectedClients.get(user_id);
if (!client) {
res.status(401).json({ error: 'Not authenticated' });
return;
}
// Обновляем время последней активности
client.lastActivity = new Date();
// Получаем очередь событий пользователя
const eventQueue = this.userEventQueues.get(user_id) || [];
// Фильтруем события после last_event_id
const lastEventId = parseInt(last_event_id) || 0;
const newEvents = eventQueue.filter(event => event.id > lastEventId);
res.json({
success: true,
events: newEvents,
last_event_id: eventQueue.length > 0 ? Math.max(...eventQueue.map(e => e.id)) : lastEventId
});
} catch (error) {
console.error('❌ [Polling Server] Ошибка получения событий:', error);
res.status(500).json({ error: 'Failed to get events' });
}
}
// HTTP эндпоинт для присоединения к чату
async handleJoinChat(req, res) {
try {
const { user_id, chat_id } = req.body;
if (!user_id || !chat_id) {
res.status(400).json({ error: 'user_id and chat_id are required' });
return;
}
const client = this.connectedClients.get(user_id);
if (!client) {
res.status(401).json({ error: 'Not authenticated' });
return;
}
// Проверяем, что чат существует и пользователь имеет доступ к нему
const supabase = getSupabaseClient();
const { data: chat, error } = await supabase
.from('chats')
.select(`
*,
buildings (
management_company_id,
apartments (
apartment_residents (
user_id
)
)
)
`)
.eq('id', chat_id)
.single();
if (error || !chat) {
res.status(404).json({ error: 'Chat not found' });
return;
}
// Проверяем доступ пользователя к чату через квартиры в доме
const hasAccess = chat.buildings.apartments.some(apartment =>
apartment.apartment_residents.some(resident =>
resident.user_id === user_id
)
);
if (!hasAccess) {
res.status(403).json({ error: 'Access denied to this chat' });
return;
}
// Добавляем пользователя в чат
client.chats.add(chat_id);
if (!this.chatParticipants.has(chat_id)) {
this.chatParticipants.set(chat_id, new Set());
}
this.chatParticipants.get(chat_id).add(user_id);
// Добавляем событие присоединения в очередь пользователя
this.addEventToQueue(user_id, 'joined_chat', {
chat_id,
chat: chat,
message: 'Successfully joined chat'
});
// Уведомляем других участников о подключении
this.broadcastToChatExcludeUser(chat_id, user_id, 'user_joined', {
chat_id,
user: client.user_info.profile,
timestamp: new Date()
});
res.json({ success: true, message: 'Joined chat successfully' });
} catch (error) {
res.status(500).json({ error: 'Failed to join chat' });
}
}
// HTTP эндпоинт для покидания чата
async handleLeaveChat(req, res) {
try {
const { user_id, chat_id } = req.body;
if (!user_id || !chat_id) {
res.status(400).json({ error: 'user_id and chat_id are required' });
return;
}
const client = this.connectedClients.get(user_id);
if (!client) {
res.status(401).json({ error: 'Not authenticated' });
return;
}
// Удаляем пользователя из чата
client.chats.delete(chat_id);
if (this.chatParticipants.has(chat_id)) {
this.chatParticipants.get(chat_id).delete(user_id);
// Если чат пуст, удаляем его
if (this.chatParticipants.get(chat_id).size === 0) {
this.chatParticipants.delete(chat_id);
}
}
// Уведомляем других участников об отключении
this.broadcastToChatExcludeUser(chat_id, user_id, 'user_left', {
chat_id,
user: client.user_info.profile,
timestamp: new Date()
});
res.json({ success: true, message: 'Left chat successfully' });
} catch (error) {
res.status(500).json({ error: 'Failed to leave chat' });
}
}
// HTTP эндпоинт для отправки сообщения
async handleSendMessage(req, res) {
try {
const { user_id, chat_id, text } = req.body;
if (!user_id || !chat_id || !text) {
res.status(400).json({ error: 'user_id, chat_id and text are required' });
return;
}
const client = this.connectedClients.get(user_id);
if (!client) {
res.status(401).json({ error: 'Not authenticated' });
return;
}
if (!client.chats.has(chat_id)) {
res.status(403).json({ error: 'Not joined to this chat' });
return;
}
// Сохраняем сообщение в базу данных
const supabase = getSupabaseClient();
const { data: message, error } = await supabase
.from('messages')
.insert({
chat_id,
user_id,
text
})
.select(`
*,
user_profiles (
id,
full_name,
avatar_url
)
`)
.single();
if (error) {
res.status(500).json({ error: 'Failed to save message' });
return;
}
// Отправляем сообщение всем участникам чата
this.broadcastToChat(chat_id, 'new_message', {
message,
timestamp: new Date()
});
res.json({ success: true, message: 'Message sent successfully' });
} catch (error) {
res.status(500).json({ error: 'Failed to send message' });
}
}
// HTTP эндпоинт для индикации печатания
async handleTypingStart(req, res) {
try {
const { user_id, chat_id } = req.body;
if (!user_id || !chat_id) {
res.status(400).json({ error: 'user_id and chat_id are required' });
return;
}
const client = this.connectedClients.get(user_id);
if (!client) {
res.status(401).json({ error: 'Not authenticated' });
return;
}
if (!client.chats.has(chat_id)) {
res.status(403).json({ error: 'Not joined to this chat' });
return;
}
this.broadcastToChatExcludeUser(chat_id, user_id, 'user_typing_start', {
chat_id,
user: client.user_info.profile,
timestamp: new Date()
});
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to send typing indicator' });
}
}
// HTTP эндпоинт для остановки индикации печатания
async handleTypingStop(req, res) {
try {
const { user_id, chat_id } = req.body;
if (!user_id || !chat_id) {
res.status(400).json({ error: 'user_id and chat_id are required' });
return;
}
const client = this.connectedClients.get(user_id);
if (!client) {
res.status(401).json({ error: 'Not authenticated' });
return;
}
if (!client.chats.has(chat_id)) {
res.status(403).json({ error: 'Not joined to this chat' });
return;
}
this.broadcastToChatExcludeUser(chat_id, user_id, 'user_typing_stop', {
chat_id,
user: client.user_info.profile,
timestamp: new Date()
});
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to send typing indicator' });
}
}
// Обработка отключения клиента
handleClientDisconnect(user_id) {
const client = this.connectedClients.get(user_id);
if (!client) return;
// Удаляем пользователя из всех чатов
client.chats.forEach(chat_id => {
if (this.chatParticipants.has(chat_id)) {
this.chatParticipants.get(chat_id).delete(user_id);
// Уведомляем других участников об отключении
this.broadcastToChatExcludeUser(chat_id, user_id, 'user_left', {
chat_id,
user: client.user_info.profile,
timestamp: new Date()
});
// Если чат пуст, удаляем его
if (this.chatParticipants.get(chat_id).size === 0) {
this.chatParticipants.delete(chat_id);
}
}
});
// Удаляем клиента
this.connectedClients.delete(user_id);
}
// Добавление события в очередь пользователя
addEventToQueue(user_id, event, data) {
if (!this.userEventQueues.has(user_id)) {
this.userEventQueues.set(user_id, []);
}
const eventQueue = this.userEventQueues.get(user_id);
const eventId = ++this.eventIdCounter;
eventQueue.push({
id: eventId,
event,
data,
timestamp: new Date()
});
// Ограничиваем размер очереди (последние 100 событий)
if (eventQueue.length > 100) {
eventQueue.splice(0, eventQueue.length - 100);
}
}
// Рассылка события всем участникам чата
broadcastToChat(chat_id, event, data) {
const participants = this.chatParticipants.get(chat_id);
if (!participants) return;
participants.forEach(user_id => {
this.addEventToQueue(user_id, event, data);
});
}
// Рассылка события всем участникам чата кроме отправителя
broadcastToChatExcludeUser(chat_id, exclude_user_id, event, data) {
const participants = this.chatParticipants.get(chat_id);
if (!participants) return;
participants.forEach(user_id => {
if (user_id !== exclude_user_id) {
this.addEventToQueue(user_id, event, data);
}
});
}
// Получение списка онлайн пользователей в чате
getOnlineUsersInChat(chat_id) {
const participants = this.chatParticipants.get(chat_id) || new Set();
const onlineUsers = [];
const now = new Date();
const ONLINE_THRESHOLD = 2 * 60 * 1000; // 2 минуты
participants.forEach(user_id => {
const client = this.connectedClients.get(user_id);
if (client && (now - client.lastActivity) < ONLINE_THRESHOLD) {
onlineUsers.push(client.user_info.profile);
}
});
return onlineUsers;
}
// Отправка системного сообщения в чат
async sendSystemMessage(chat_id, text) {
this.broadcastToChat(chat_id, 'system_message', {
chat_id,
text,
timestamp: new Date()
});
}
// Очистка старых событий
cleanupOldEvents() {
const now = new Date();
const MAX_EVENT_AGE = 24 * 60 * 60 * 1000; // 24 часа
const INACTIVE_USER_THRESHOLD = 60 * 60 * 1000; // 1 час
// Очищаем старые события
this.userEventQueues.forEach((eventQueue, user_id) => {
const filteredEvents = eventQueue.filter(event =>
(now - event.timestamp) < MAX_EVENT_AGE
);
if (filteredEvents.length !== eventQueue.length) {
this.userEventQueues.set(user_id, filteredEvents);
}
});
// Удаляем неактивных пользователей
this.connectedClients.forEach((client, user_id) => {
if ((now - client.lastActivity) > INACTIVE_USER_THRESHOLD) {
this.handleClientDisconnect(user_id);
this.userEventQueues.delete(user_id);
}
});
}
// Тестирование Real-time подписки
async testRealtimeConnection() {
try {
const supabase = getSupabaseClient();
if (!supabase) {
return false;
}
// Создаем тестовый канал для проверки подключения
const testChannel = supabase
.channel('test_connection')
.subscribe((status, error) => {
if (error) {
console.error('❌ [Supabase] Тестовый канал - ошибка:', error);
}
if (status === 'SUBSCRIBED') {
// Отписываемся от тестового канала
setTimeout(() => {
testChannel.unsubscribe();
}, 2000);
}
});
return true;
} catch (error) {
console.error('❌ [Supabase] Ошибка тестирования Realtime:', error);
return false;
}
}
// Проверка статуса подписки
checkSubscriptionStatus() {
if (this.realtimeSubscription) {
return true;
} else {
return false;
}
}
setupRealtimeSubscription() {
// Убираем setTimeout, вызываем сразу
this._doSetupRealtimeSubscription();
}
_doSetupRealtimeSubscription() {
try {
const supabase = getSupabaseClient();
if (!supabase) {
console.log('❌ [Supabase] Supabase клиент не найден');
throw new Error('Supabase client not available');
}
// Подписываемся на изменения в таблице messages
const subscription = supabase
.channel('messages_changes')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages'
},
async (payload) => {
try {
const newMessage = payload.new;
if (!newMessage) {
return;
}
if (!newMessage.chat_id) {
return;
}
// Получаем профиль пользователя
const { data: userProfile, error: profileError } = await supabase
.from('user_profiles')
.select('id, full_name, avatar_url')
.eq('id', newMessage.user_id)
.single();
if (profileError) {
console.error('❌ [Supabase] Ошибка получения профиля пользователя:', profileError);
}
// Объединяем сообщение с профилем
const messageWithProfile = {
...newMessage,
user_profiles: userProfile || null
};
// Отправляем сообщение всем участникам чата
this.broadcastToChat(newMessage.chat_id, 'new_message', {
message: messageWithProfile,
timestamp: new Date()
});
} catch (callbackError) {
console.error('❌ [Supabase] Ошибка в обработчике сообщения:', callbackError);
}
}
)
.subscribe((status, error) => {
if (error) {
console.error('❌ [Supabase] Ошибка подписки:', error);
}
if (status === 'CHANNEL_ERROR') {
console.error('❌ [Supabase] Ошибка канала');
} else if (status === 'TIMED_OUT') {
console.error('❌ [Supabase] Таймаут подписки');
}
});
// Сохраняем ссылку на подписку для возможности отписки
this.realtimeSubscription = subscription;
} catch (error) {
console.error('❌ [Supabase] Критическая ошибка при настройке подписки:', error);
throw error; // Пробрасываем ошибку для обработки в initializeWithRetry
}
}
// Получение статистики подключений
getConnectionStats() {
return {
connectedClients: this.connectedClients.size,
activeChats: this.chatParticipants.size,
totalChatParticipants: Array.from(this.chatParticipants.values())
.reduce((total, participants) => total + participants.size, 0),
totalEventQueues: this.userEventQueues.size,
totalEvents: Array.from(this.userEventQueues.values())
.reduce((total, queue) => total + queue.length, 0)
};
}
}
// Функция для создания роутера с polling эндпоинтами
function createChatPollingRouter(express) {
const router = express.Router();
const chatHandler = new ChatPollingHandler();
// CORS middleware для всех запросов
router.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Cache-Control, Authorization');
res.header('Access-Control-Allow-Credentials', 'true');
// Обрабатываем OPTIONS запросы
if (req.method === 'OPTIONS') {
res.status(200).end();
return;
}
next();
});
// Эндпоинт для аутентификации
router.post('/auth', (req, res) => {
chatHandler.handleAuthentication(req, res);
});
// Эндпоинт для получения событий (polling)
router.get('/events', (req, res) => {
chatHandler.handleGetEvents(req, res);
});
// HTTP эндпоинты для действий
router.post('/join-chat', (req, res) => {
chatHandler.handleJoinChat(req, res);
});
router.post('/leave-chat', (req, res) => {
chatHandler.handleLeaveChat(req, res);
});
router.post('/send-message', (req, res) => {
chatHandler.handleSendMessage(req, res);
});
router.post('/typing-start', (req, res) => {
chatHandler.handleTypingStart(req, res);
});
router.post('/typing-stop', (req, res) => {
chatHandler.handleTypingStop(req, res);
});
// Эндпоинт для получения онлайн пользователей в чате
router.get('/online-users/:chat_id', (req, res) => {
const { chat_id } = req.params;
const onlineUsers = chatHandler.getOnlineUsersInChat(chat_id);
res.json({ onlineUsers });
});
// Эндпоинт для получения статистики
router.get('/stats', (req, res) => {
const stats = chatHandler.getConnectionStats();
res.json(stats);
});
// Эндпоинт для проверки статуса Supabase подписки
router.get('/supabase-status', (req, res) => {
const isConnected = chatHandler.checkSubscriptionStatus();
res.json({
supabaseSubscriptionActive: isConnected,
subscriptionExists: !!chatHandler.realtimeSubscription,
subscriptionInfo: chatHandler.realtimeSubscription ? {
channel: chatHandler.realtimeSubscription.topic,
state: chatHandler.realtimeSubscription.state
} : null
});
});
// Эндпоинт для принудительного переподключения к Supabase
router.post('/reconnect-supabase', (req, res) => {
try {
// Отписываемся от текущей подписки
if (chatHandler.realtimeSubscription) {
chatHandler.realtimeSubscription.unsubscribe();
chatHandler.realtimeSubscription = null;
}
// Создаем новую подписку
chatHandler.setupRealtimeSubscription();
res.json({
success: true,
message: 'Reconnection initiated'
});
} catch (error) {
console.error('❌ [Polling Server] Ошибка переподключения:', error);
res.status(500).json({
success: false,
error: 'Reconnection failed',
details: error.message
});
}
});
// Тестовый эндпоинт для создания сообщения в обход API
router.post('/test-message', async (req, res) => {
const { chat_id, user_id, text } = req.body;
if (!chat_id || !user_id || !text) {
res.status(400).json({ error: 'chat_id, user_id и text обязательны' });
return;
}
try {
// Создаем тестовое событие напрямую
chatHandler.broadcastToChat(chat_id, 'new_message', {
message: {
id: `test_${Date.now()}`,
chat_id,
user_id,
text,
created_at: new Date().toISOString(),
user_profiles: {
id: user_id,
full_name: 'Test User',
avatar_url: null
}
},
timestamp: new Date()
});
res.json({
success: true,
message: 'Test message sent to polling clients'
});
} catch (error) {
console.error('❌ [Polling Server] Ошибка отправки тестового сообщения:', error);
res.status(500).json({
success: false,
error: 'Failed to send test message',
details: error.message
});
}
});
return { router, chatHandler };
}
module.exports = {
ChatPollingHandler,
createChatPollingRouter
};

View File

@@ -1,340 +0,0 @@
const { getSupabaseClient } = require('./supabaseClient');
class ChatSocketHandler {
constructor(io) {
this.io = io;
this.onlineUsers = new Map(); // Хранение онлайн пользователей: socket.id -> user info
this.chatRooms = new Map(); // Хранение участников комнат: chat_id -> Set(socket.id)
this.setupSocketHandlers();
}
setupSocketHandlers() {
this.io.on('connection', (socket) => {
console.log(`User connected: ${socket.id}`);
// Аутентификация пользователя
socket.on('authenticate', async (data) => {
await this.handleAuthentication(socket, data);
});
// Присоединение к чату
socket.on('join_chat', async (data) => {
await this.handleJoinChat(socket, data);
});
// Покидание чата
socket.on('leave_chat', (data) => {
this.handleLeaveChat(socket, data);
});
// Отправка сообщения
socket.on('send_message', async (data) => {
await this.handleSendMessage(socket, data);
});
// Пользователь начал печатать
socket.on('typing_start', (data) => {
this.handleTypingStart(socket, data);
});
// Пользователь закончил печатать
socket.on('typing_stop', (data) => {
this.handleTypingStop(socket, data);
});
// Отключение пользователя
socket.on('disconnect', () => {
this.handleDisconnect(socket);
});
});
}
async handleAuthentication(socket, data) {
try {
const { user_id, token } = data;
if (!user_id) {
socket.emit('auth_error', { message: 'user_id is required' });
return;
}
// Получаем информацию о пользователе из базы данных
const supabase = getSupabaseClient();
const { data: userProfile, error } = await supabase
.from('user_profiles')
.select('*')
.eq('id', user_id)
.single();
if (error) {
socket.emit('auth_error', { message: 'User not found' });
return;
}
// Сохраняем информацию о пользователе
this.onlineUsers.set(socket.id, {
user_id,
socket_id: socket.id,
profile: userProfile,
last_seen: new Date()
});
socket.user_id = user_id;
socket.emit('authenticated', {
message: 'Successfully authenticated',
user: userProfile
});
console.log(`User ${user_id} authenticated with socket ${socket.id}`);
} catch (error) {
console.error('Authentication error:', error);
socket.emit('auth_error', { message: 'Authentication failed' });
}
}
async handleJoinChat(socket, data) {
try {
const { chat_id } = data;
if (!socket.user_id) {
socket.emit('error', { message: 'Not authenticated' });
return;
}
if (!chat_id) {
socket.emit('error', { message: 'chat_id is required' });
return;
}
// Проверяем, что чат существует и пользователь имеет доступ к нему
const supabase = getSupabaseClient();
const { data: chat, error } = await supabase
.from('chats')
.select(`
*,
buildings (
management_company_id,
apartments (
apartment_residents (
user_id
)
)
)
`)
.eq('id', chat_id)
.single();
if (error || !chat) {
socket.emit('error', { message: 'Chat not found' });
return;
}
// Проверяем доступ пользователя к чату через квартиры в доме
const hasAccess = chat.buildings.apartments.some(apartment =>
apartment.apartment_residents.some(resident =>
resident.user_id === socket.user_id
)
);
if (!hasAccess) {
socket.emit('error', { message: 'Access denied to this chat' });
return;
}
// Добавляем сокет в комнату
socket.join(chat_id);
// Обновляем список участников комнаты
if (!this.chatRooms.has(chat_id)) {
this.chatRooms.set(chat_id, new Set());
}
this.chatRooms.get(chat_id).add(socket.id);
socket.emit('joined_chat', {
chat_id,
chat: chat,
message: 'Successfully joined chat'
});
// Уведомляем других участников о подключении
const userInfo = this.onlineUsers.get(socket.id);
socket.to(chat_id).emit('user_joined', {
chat_id,
user: userInfo?.profile,
timestamp: new Date()
});
console.log(`User ${socket.user_id} joined chat ${chat_id}`);
} catch (error) {
console.error('Join chat error:', error);
socket.emit('error', { message: 'Failed to join chat' });
}
}
handleLeaveChat(socket, data) {
const { chat_id } = data;
if (!chat_id) return;
socket.leave(chat_id);
// Удаляем из списка участников
if (this.chatRooms.has(chat_id)) {
this.chatRooms.get(chat_id).delete(socket.id);
// Если комната пуста, удаляем её
if (this.chatRooms.get(chat_id).size === 0) {
this.chatRooms.delete(chat_id);
}
}
// Уведомляем других участников об отключении
const userInfo = this.onlineUsers.get(socket.id);
socket.to(chat_id).emit('user_left', {
chat_id,
user: userInfo?.profile,
timestamp: new Date()
});
console.log(`User ${socket.user_id} left chat ${chat_id}`);
}
async handleSendMessage(socket, data) {
try {
const { chat_id, text } = data;
if (!socket.user_id) {
socket.emit('error', { message: 'Not authenticated' });
return;
}
if (!chat_id || !text) {
socket.emit('error', { message: 'chat_id and text are required' });
return;
}
// Сохраняем сообщение в базу данных
const supabase = getSupabaseClient();
const { data: message, error } = await supabase
.from('messages')
.insert({
chat_id,
user_id: socket.user_id,
text
})
.select(`
*,
user_profiles (
id,
full_name,
avatar_url
)
`)
.single();
if (error) {
socket.emit('error', { message: 'Failed to save message' });
return;
}
// Отправляем сообщение всем участникам чата
this.io.to(chat_id).emit('new_message', {
message,
timestamp: new Date()
});
console.log(`Message sent to chat ${chat_id} by user ${socket.user_id}`);
} catch (error) {
console.error('Send message error:', error);
socket.emit('error', { message: 'Failed to send message' });
}
}
handleTypingStart(socket, data) {
const { chat_id } = data;
if (!socket.user_id || !chat_id) return;
const userInfo = this.onlineUsers.get(socket.id);
socket.to(chat_id).emit('user_typing_start', {
chat_id,
user: userInfo?.profile,
timestamp: new Date()
});
}
handleTypingStop(socket, data) {
const { chat_id } = data;
if (!socket.user_id || !chat_id) return;
const userInfo = this.onlineUsers.get(socket.id);
socket.to(chat_id).emit('user_typing_stop', {
chat_id,
user: userInfo?.profile,
timestamp: new Date()
});
}
handleDisconnect(socket) {
console.log(`User disconnected: ${socket.id}`);
// Удаляем пользователя из всех комнат
this.chatRooms.forEach((participants, chat_id) => {
if (participants.has(socket.id)) {
participants.delete(socket.id);
// Уведомляем других участников об отключении
const userInfo = this.onlineUsers.get(socket.id);
socket.to(chat_id).emit('user_left', {
chat_id,
user: userInfo?.profile,
timestamp: new Date()
});
// Если комната пуста, удаляем её
if (participants.size === 0) {
this.chatRooms.delete(chat_id);
}
}
});
// Удаляем пользователя из списка онлайн
this.onlineUsers.delete(socket.id);
}
// Получение списка онлайн пользователей в чате
getOnlineUsersInChat(chat_id) {
const participants = this.chatRooms.get(chat_id) || new Set();
const onlineUsers = [];
participants.forEach(socketId => {
const userInfo = this.onlineUsers.get(socketId);
if (userInfo) {
onlineUsers.push(userInfo.profile);
}
});
return onlineUsers;
}
// Отправка системного сообщения в чат
async sendSystemMessage(chat_id, text) {
this.io.to(chat_id).emit('system_message', {
chat_id,
text,
timestamp: new Date()
});
}
}
// Функция инициализации Socket.IO для чатов
function initializeChatSocket(io) {
const chatHandler = new ChatSocketHandler(io);
return chatHandler;
}
module.exports = {
ChatSocketHandler,
initializeChatSocket
};

View File

@@ -3,12 +3,30 @@ const { createClient } = require('@supabase/supabase-js');
const { getSupabaseUrl, getSupabaseKey, getSupabaseServiceKey } = require('./get-constants');
let supabase = null;
let initializationPromise = null;
async function initSupabaseClient() {
const supabaseUrl = await getSupabaseUrl();
const supabaseAnonKey = await getSupabaseKey();
const supabaseServiceRoleKey = await getSupabaseServiceKey();
supabase = createClient(supabaseUrl, supabaseServiceRoleKey);
console.log('🔄 [Supabase Client] Начинаем инициализацию...');
try {
console.log('🔄 [Supabase Client] Получаем конфигурацию...');
const supabaseUrl = await getSupabaseUrl();
const supabaseAnonKey = await getSupabaseKey();
const supabaseServiceRoleKey = await getSupabaseServiceKey();
if (!supabaseUrl || !supabaseServiceRoleKey) {
throw new Error('Missing required Supabase configuration');
}
supabase = createClient(supabaseUrl, supabaseServiceRoleKey);
return supabase;
} catch (error) {
console.error('❌ [Supabase Client] Ошибка инициализации:', error);
throw error;
}
}
function getSupabaseClient() {
@@ -20,20 +38,49 @@ function getSupabaseClient() {
// POST /refresh-supabase-client
router.post('/refresh-supabase-client', async (req, res) => {
try {
try {
await initSupabaseClient();
res.json({ success: true, message: 'Supabase client refreshed' });
} catch (error) {
} catch (error) {
console.error('❌ [Supabase Client] Ошибка обновления:', error);
res.status(500).json({ error: error.message });
}
}
});
// GET /supabase-client-status
router.get('/supabase-client-status', (req, res) => {
console.log('🔍 [Supabase Client] Проверяем статус клиента...');
const isInitialized = !!supabase;
res.json({
initialized: isInitialized,
clientExists: !!supabase,
timestamp: new Date().toISOString()
});
});
// Инициализация клиента при старте
(async () => {
initializationPromise = (async () => {
try {
await initSupabaseClient();
} catch (error) {
console.error('❌ [Supabase Client] Ошибка инициализации при старте:', error);
// Планируем повторную попытку через 5 секунд
setTimeout(async () => {
try {
await initSupabaseClient();
} catch (retryError) {
console.error('❌ [Supabase Client] Повторная инициализация неудачна:', retryError);
}
}, 5000);
}
})();
module.exports = {
getSupabaseClient,
supabaseRouter: router
initSupabaseClient,
supabaseRouter: router,
// Экспортируем промис инициализации для возможности ожидания
initializationPromise
};

View File

@@ -0,0 +1,66 @@
import { StructuredTool, ToolRunnableConfig } from '@langchain/core/tools';
import { z } from 'zod';
import { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
import { getSupabaseClient } from '../supabaseClient';
export class CreateTicketTool extends StructuredTool {
name = 'create_ticket';
description = 'Создает заявку в системе. ВАЖНО: используй этот инструмент ТОЛЬКО после получения явного согласия пользователя на создание заявки с конкретным текстом.';
schema = z.object({
title: z.string().describe('Заголовок заявки'),
description: z.string().describe('Подробное описание проблемы'),
category: z.string().describe('Категория заявки (например: ремонт, уборка, техническая_поддержка, жалоба)'),
});
private userId: string;
private apartmentId: string;
constructor(userId: string, apartmentId: string) {
super();
this.userId = userId;
this.apartmentId = apartmentId;
}
protected async _call(
arg: z.infer<typeof this.schema>,
runManager?: CallbackManagerForToolRun,
parentConfig?: ToolRunnableConfig<Record<string, any>>
): Promise<string> {
try {
if (!this.apartmentId) {
return 'Не удалось определить вашу квартиру. Обратитесь к администратору для создания заявки.';
}
const supabase = getSupabaseClient();
const { data: ticket, error } = await supabase
.from('tickets')
.insert({
user_id: this.userId,
apartment_id: this.apartmentId,
title: arg.title,
description: arg.description,
category: arg.category,
status: 'open'
})
.select()
.single();
if (error) {
return 'Произошла ошибка при создании заявки. Попробуйте позже или обратитесь к администратору.';
}
return `Заявка успешно создана!
Номер заявки: ${ticket.id}
Заголовок: ${ticket.title}
Статус: Открыта
Дата создания: ${new Date(ticket.created_at).toLocaleString('ru-RU')}
Ваша заявка принята в работу. Мы свяжемся с вами в ближайшее время.`;
} catch (error) {
return 'Произошла техническая ошибка при создании заявки. Пожалуйста, попробуйте позже.';
}
}
}

View File

@@ -0,0 +1,20 @@
import { Agent } from 'node:https';
import { GigaChat } from 'langchain-gigachat';
import { getGigaAuth } from '../get-constants';
const httpsAgent = new Agent({
rejectUnauthorized: false,
});
// Получаем GIGA_AUTH из переменной окружения (устанавливается в get-constants.js)
export const gigachat = (GIGA_AUTH) =>
new GigaChat({
model: 'GigaChat-2',
temperature: 0.7,
scope: 'GIGACHAT_API_PERS',
streaming: false,
credentials: GIGA_AUTH,
httpsAgent
});
export default gigachat;

View File

@@ -0,0 +1,41 @@
import { StructuredTool, ToolRunnableConfig } from '@langchain/core/tools';
import { z } from 'zod';
import { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
import { getVectorStore } from './vector-store';
export class KnowledgeBaseTool extends StructuredTool {
name = 'search_knowledge_base';
description = 'Ищет информацию в базе знаний компании о процессах, оплатах, подаче заявок, правилах и документах УК. Используй этот инструмент для вопросов, требующих специфических знаний о компании.';
schema = z.object({
query: z.string().describe('Поисковый запрос для поиска в базе знаний'),
});
protected async _call(
arg: z.infer<typeof this.schema>,
runManager?: CallbackManagerForToolRun,
parentConfig?: ToolRunnableConfig<Record<string, any>>
): Promise<string> {
try {
const vectorStore = getVectorStore();
const retriever = vectorStore.asRetriever({
k: 5
});
const relevantDocs = await retriever.getRelevantDocuments(arg.query);
if (!relevantDocs || relevantDocs.length === 0) {
return 'В базе знаний не найдено информации по данному запросу. Возможно, стоит переформулировать вопрос или обратиться к специалисту.';
}
const formattedDocs = relevantDocs.map((doc, index) => {
return `Документ ${index + 1}:\n${doc.pageContent}\n`;
}).join('\n---\n');
return `Найдена следующая информация в базе знаний компании:\n\n${formattedDocs}\n\спользуй эту информацию для ответа на вопрос пользователя.`;
} catch (error) {
return 'Произошла ошибка при поиске в базе знаний. Попробуйте переформулировать запрос.';
}
}
}

View File

@@ -0,0 +1,167 @@
import { HumanMessage, AIMessage, SystemMessage, BaseMessage } from '@langchain/core/messages';
import { ChatPromptTemplate, MessagesPlaceholder } from '@langchain/core/prompts';
import { createReactAgent } from '@langchain/langgraph/prebuilt';
import { MemorySaver } from '@langchain/langgraph';
import gigachat from './gigachat';
import { SupportContextTool } from './support-context-tool';
import { KnowledgeBaseTool } from './knowledge-base-tool';
import { CreateTicketTool } from './create-ticket-tool';
export interface SupportAgentConfig {
temperature?: number;
threadId?: string;
GIGA_AUTH?: string;
}
export interface SupportResponse {
content: string;
success: boolean;
error?: string;
}
export class SupportAgent {
private llm: any;
private memorySaver: MemorySaver;
private agent: any;
private systemPrompt: string;
private threadId: string;
private isFirstMessage: boolean;
private userId: string;
constructor(config: SupportAgentConfig = {}) {
this.systemPrompt = this.getDefaultSystemPrompt();
this.threadId = config.threadId || 'default';
this.userId = this.threadId;
this.memorySaver = new MemorySaver();
this.isFirstMessage = true;
this.llm = gigachat(config.GIGA_AUTH);
if (config.temperature !== undefined) {
this.llm.temperature = config.temperature;
}
const tools = [
new SupportContextTool(this.userId),
new KnowledgeBaseTool()
];
this.agent = createReactAgent({
llm: this.llm,
tools: tools,
checkpointSaver: this.memorySaver
});
}
private getDefaultSystemPrompt(): string {
return `Ты - профессиональный агент службы поддержки управляющей компании.
ОСНОВНЫЕ ПРИНЦИПЫ:
- Помогай только с реальными проблемами и вопросами, связанными с ЖКХ, управляющей компанией и приложением
- Будь вежливым, профессиональным и по существу
- Если вопрос неуместен, не связан с твоими обязанностями или является развлекательным - вежливо откажись и перенаправь к основным темам
ДОСТУПНЫЕ ИНСТРУМЕНТЫ:
1. get_support_context - получает историю сообщений пользователя
ВСЕГДА используй ПЕРВЫМ при каждом новом сообщении
2. search_knowledge_base - поиск в базе знаний компании
Используй ТОЛЬКО для серьезных вопросов о:
- Процессах оплаты ЖКХ и тарифах
- Подаче заявок и документообороте
- Правилах и регламентах УК
- Технических вопросах приложения
- Процедурах и инструкциях компании
3. create_ticket - создание заявки в системе
Используй ТОЛЬКО когда:
- Пользователь сообщает о реальной проблеме (поломка, неисправность, жалоба)
- Проблема требует вмешательства УК или технических служб
- ОБЯЗАТЕЛЬНО сначала покажи пользователю полный текст заявки
- Получи ЯВНОЕ согласие пользователя перед созданием
- НЕ создавай заявки для консультационных вопросов
ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ:
- НЕ используй search_knowledge_base и create_ticket для:
* Общих вопросов и болтовни
* Развлекательных запросов
* Вопросов не по теме ЖКХ/УК
* Простых консультаций, которые можно решить обычным ответом
АЛГОРИТМ РАБОТЫ:
1. Получи контекст истории сообщений
2. Определи, является ли вопрос уместным и серьезным
3. Если нужна специфическая информация - найди в базе знаний
4. Если нужно создать заявку - покажи текст и получи согласие
5. Дай полный и полезный ответ
Всегда отвечай на русском языке и фокусируйся на помощи с реальными проблемами ЖКХ.`;
}
public async processMessage(userMessage: string, apartmentId?: string): Promise<SupportResponse> {
try {
const messages: BaseMessage[] = [];
if (this.isFirstMessage) {
messages.push(new SystemMessage(this.systemPrompt));
this.isFirstMessage = false;
}
messages.push(new HumanMessage(userMessage));
// Создаем инструменты с актуальным apartmentId
const tools = [
new SupportContextTool(this.userId),
new KnowledgeBaseTool(),
new CreateTicketTool(this.userId, apartmentId || '')
];
// Пересоздаем агента с обновленными инструментами
const tempAgent = createReactAgent({
llm: this.llm,
tools: tools,
checkpointSaver: this.memorySaver
});
const response = await tempAgent.invoke({
messages: messages
}, {
configurable: {
thread_id: this.threadId
}
});
const lastMessage = response.messages[response.messages.length - 1];
return {
content: typeof lastMessage.content === 'string' ? lastMessage.content : 'Извините, не удалось сформировать ответ.',
success: true
};
} catch (error) {
console.error('Ошибка при обработке сообщения:', error);
return {
content: 'Извините, произошла ошибка при обработке вашего запроса. Попробуйте позже.',
success: false,
error: error instanceof Error ? error.message : 'Неизвестная ошибка'
};
}
}
public async clearHistory(): Promise<void> {
this.memorySaver = new MemorySaver();
const tools = [
new SupportContextTool(this.userId),
new KnowledgeBaseTool()
];
this.agent = createReactAgent({
llm: this.llm,
tools: tools,
checkpointSaver: this.memorySaver
});
this.isFirstMessage = true;
}
}

View File

@@ -0,0 +1,56 @@
import { StructuredTool, ToolRunnableConfig } from '@langchain/core/tools';
import { z } from 'zod';
import { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
import { getSupabaseClient } from '../supabaseClient';
export class SupportContextTool extends StructuredTool {
name = 'get_support_context';
description = 'Получает последние 10 сообщений из истории поддержки для понимания контекста разговора. Используй этот инструмент в начале разговора.';
schema = z.object({});
private userId: string;
constructor(userId: string) {
super();
this.userId = userId;
}
protected async _call(
arg: z.infer<typeof this.schema>,
runManager?: CallbackManagerForToolRun,
parentConfig?: ToolRunnableConfig<Record<string, any>>
): Promise<string> {
try {
const supabase = getSupabaseClient();
const { data: messages, error } = await supabase
.from('support')
.select('message, is_from_user, created_at')
.eq('user_id', this.userId)
.order('created_at', { ascending: false })
.limit(10);
if (error) {
return 'Не удалось получить историю сообщений.';
}
if (!messages || messages.length === 0) {
return 'История сообщений поддержки пуста. Это первое обращение пользователя.';
}
const chronologicalMessages = messages.reverse();
const contextMessages = chronologicalMessages.map((msg, index) => {
const role = msg.is_from_user ? 'Пользователь' : 'Агент поддержки';
const time = new Date(msg.created_at).toLocaleString('ru-RU');
return `${index + 1}. [${time}] ${role}: ${msg.message}`;
}).join('\n');
return `Последние сообщения из истории поддержки (${messages.length} сообщений):\n\n${contextMessages}\n\спользуй этот контекст для понимания предыдущих обращений пользователя и предоставления более точных ответов.`;
} catch (error) {
return 'Произошла ошибка при получении истории сообщений.';
}
}
}

View File

@@ -0,0 +1,33 @@
import { createClient } from '@supabase/supabase-js';
import { SupabaseVectorStore } from '@langchain/community/vectorstores/supabase';
import { GigaChatEmbeddings } from 'langchain-gigachat';
import { Agent } from 'node:https';
const httpsAgent = new Agent({
rejectUnauthorized: false,
});
let vectorStoreInstance: SupabaseVectorStore | null = null;
export function getVectorStore(): SupabaseVectorStore {
if (!vectorStoreInstance) {
const client = createClient(
process.env.RAG_SUPABASE_URL!,
process.env.RAG_SUPABASE_SERVICE_ROLE_KEY!,
);
vectorStoreInstance = new SupabaseVectorStore(
new GigaChatEmbeddings({
credentials: process.env.GIGA_AUTH,
httpsAgent,
}),
{
client,
tableName: 'slon',
queryName: 'match_slon'
}
);
}
return vectorStoreInstance;
}

View File

@@ -1,16 +1,151 @@
const router = require('express').Router();
const { getSupabaseClient } = require('./supabaseClient');
const { getGigaAuth } = require('./get-constants');
const { SupportAgent } = require('./support-ai-agent/support-agent');
// Хранилище агентов для разных пользователей
const userAgents = new Map();
/**
* Получить или создать агента для пользователя
*/
async function getUserAgent(userId) {
if (!userAgents.has(userId)) {
const GIGA_AUTH = await getGigaAuth();
const config = {
threadId: userId,
temperature: 0.7,
GIGA_AUTH
};
userAgents.set(userId, new SupportAgent(config));
}
return userAgents.get(userId);
}
// GET /api/support - Получить историю сообщений пользователя
router.get('/support', async (req, res) => {
const supabase = getSupabaseClient();
const { user_id } = req.query;
if (!user_id) {
return res.status(400).json({ error: 'user_id обязателен' });
}
try {
// Получаем все сообщения пользователя из базы данных
const { data: messages, error } = await supabase
.from('support')
.select('*')
.eq('user_id', user_id)
.order('created_at', { ascending: true });
if (error) {
return res.status(400).json({ error: error.message });
}
res.json({
messages: messages || [],
success: true
});
} catch (error) {
console.error('Ошибка в GET /support:', error);
res.status(500).json({
error: 'Внутренняя ошибка сервера',
success: false
});
}
});
// POST /api/support
router.post('/support', async (req, res) => {
const supabase = getSupabaseClient();
const { user_id, message } = req.body;
if (!user_id || !message) return res.status(400).json({ error: 'user_id и message обязательны' });
const { error } = await supabase
.from('support')
.insert({ user_id, message, is_from_user: true });
if (error) return res.status(400).json({ error: error.message });
res.json({ reply: 'Спасибо за ваше сообщение! Служба поддержки свяжется с вами в ближайшее время.' });
const { user_id, message, apartment_id } = req.body;
if (!user_id || !message) {
return res.status(400).json({ error: 'user_id и message обязательны' });
}
try {
// Сохраняем сообщение пользователя в базу данных
const { error: insertError } = await supabase
.from('support')
.insert({ user_id, message, is_from_user: true });
if (insertError) {
return res.status(400).json({ error: insertError.message });
}
// Получаем агента для пользователя
const agent = await getUserAgent(user_id);
// Получаем ответ от AI-агента, передавая apartment_id
const aiResponse = await agent.processMessage(message, apartment_id);
if (!aiResponse.success) {
console.error('Ошибка AI-агента:', aiResponse.error);
return res.status(500).json({
error: 'Ошибка при генерации ответа',
reply: 'Извините, произошла ошибка. Попробуйте позже.'
});
}
// Сохраняем ответ агента в базу данных
const { error: responseError } = await supabase
.from('support')
.insert({
user_id,
message: aiResponse.content,
is_from_user: false
});
if (responseError) {
console.error('Ошибка сохранения ответа:', responseError);
// Не возвращаем ошибку пользователю, так как ответ уже сгенерирован
}
// Возвращаем ответ пользователю
res.json({
reply: aiResponse.content,
success: true
});
} catch (error) {
console.error('Ошибка в supportApi:', error);
res.status(500).json({
error: 'Внутренняя ошибка сервера',
reply: 'Извините, произошла ошибка. Попробуйте позже.'
});
}
});
// DELETE /api/support/history/:userId - Очистка истории диалога
router.delete('/support/history/:userId', async (req, res) => {
const { userId } = req.params;
try {
if (userAgents.has(userId)) {
const agent = userAgents.get(userId);
await agent.clearHistory();
res.json({
message: 'История диалога очищена',
success: true
});
} else {
res.json({
message: 'Агент для данного пользователя не найден',
success: true
});
}
} catch (error) {
console.error('Ошибка в /support/history:', error);
res.status(500).json({
error: 'Внутренняя ошибка сервера',
success: false
});
}
});
module.exports = router;

View File

@@ -1,14 +1,31 @@
const router = require('express').Router();
const { getSupabaseClient } = require('./supabaseClient');
// Получить все тикеты по дому
// Получить заявки пользователя по квартире
router.get('/tickets', async (req, res) => {
const supabase = getSupabaseClient();
const { building_id } = req.query;
if (!building_id) return res.status(400).json({ error: 'building_id required' });
const { data, error } = await supabase.from('tickets').select('*').eq('building_id', building_id);
if (error) return res.status(400).json({ error: error.message });
res.json(data);
const { user_id, apartment_id } = req.query;
if (!user_id || !apartment_id) {
return res.status(400).json({ error: 'Требуется user_id и apartment_id' });
}
try {
const { data, error } = await supabase
.from('tickets')
.select('*')
.eq('user_id', user_id)
.eq('apartment_id', apartment_id)
.order('created_at', { ascending: false });
if (error) {
return res.status(400).json({ error: error.message });
}
res.json(data || []);
} catch (err) {
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
module.exports = router;