Реализован SSE + Redis Pub/Sub для AI Drawer

- Добавлен SSE endpoint (aiassist/ai_sse.php) для real-time получения ответов от n8n
- Обновлен n8n_proxy.php: убран callback, добавлена передача Redis параметров в n8n
- Обновлен ai-drawer-simple.js: переход с polling на SSE с fallback через Redis
- Добавлен check_redis_response.php для прямого чтения из Redis кэша
- Добавлена документация: N8N_REDIS_SETUP.md, N8N_REDIS_FIX.md, AI_DRAWER_REDIS_SSE.md
- Поддержка plain text ответов от n8n (автоматическое определение формата)
- Кэширование ответов в Redis для надежности (TTL 5 минут)
This commit is contained in:
Fedor
2025-11-11 15:16:27 +03:00
parent f770bd0e43
commit 1a4653298d
6 changed files with 768 additions and 70 deletions

View File

@@ -4,6 +4,7 @@ class AIDrawer {
this.fontSize = 'normal';
this.avatarType = 'default';
this.sessionId = null;
this.currentEventSource = null; // Для SSE соединения
this.init();
// Загружаем историю сразу при инициализации (при загрузке страницы)
@@ -425,9 +426,9 @@ class AIDrawer {
console.log('AI Drawer: data.success =', data.success, 'type:', typeof data.success);
if (data.success && data.task_id) {
// Запрос принят, начинаем polling по task_id
// Запрос принят, подписываемся на SSE события через Redis
console.log('AI Drawer: Request accepted, task_id:', data.task_id);
this.startPolling(data.task_id);
this.startSSEListener(data.task_id);
} else {
throw new Error(data.message || 'Unknown error');
}
@@ -440,18 +441,189 @@ class AIDrawer {
}
}
// Метод для polling результатов
async startPolling(taskId) {
console.log('AI Drawer: Starting polling for task:', taskId);
// Метод для подписки на SSE события через Redis Pub/Sub
startSSEListener(taskId) {
console.log('AI Drawer: Starting SSE listener for task:', taskId);
// Закрываем предыдущее соединение если есть
if (this.currentEventSource) {
this.currentEventSource.close();
}
// Флаг для отслеживания получения ответа
let responseReceived = false;
// Создаем новое SSE соединение
const sseUrl = `/aiassist/ai_sse.php?task_id=${encodeURIComponent(taskId)}`;
this.currentEventSource = new EventSource(sseUrl);
// Обработчик подключения
this.currentEventSource.onopen = () => {
console.log('AI Drawer: SSE connection opened');
};
// Обработчик получения ответа
this.currentEventSource.addEventListener('response', (event) => {
try {
const data = JSON.parse(event.data);
console.log('AI Drawer: Received response via SSE:', data);
if (data.data && data.data.response) {
responseReceived = true; // Отмечаем что получили ответ
this.hideLoading();
this.hideTypingIndicator();
this.addStreamingMessage(data.data.response, false, 25);
this.currentEventSource.close();
this.currentEventSource = null;
}
} catch (error) {
console.error('AI Drawer: SSE response parse error:', error);
}
});
// Обработчик ошибок SSE (стандартное событие)
this.currentEventSource.onerror = (event) => {
console.error('AI Drawer: SSE connection error:', event);
console.log('AI Drawer: SSE readyState:', this.currentEventSource?.readyState);
// НЕ вызываем fallback если уже получили ответ (SSE закрывается после отправки)
if (responseReceived) {
console.log('AI Drawer: Response already received, ignoring SSE close error');
return;
}
// Fallback на polling только если SSE действительно не работает
if (this.currentEventSource && this.currentEventSource.readyState === EventSource.CLOSED) {
console.log('AI Drawer: SSE closed without response, falling back to Redis check');
this.currentEventSource.close();
this.currentEventSource = null;
// Вместо polling БД проверяем Redis напрямую
this.checkRedisDirectly(taskId);
}
};
// Обработчик кастомных ошибок от сервера
this.currentEventSource.addEventListener('error', (event) => {
try {
const data = JSON.parse(event.data);
console.error('AI Drawer: SSE error event from server:', data);
if (data.data && data.data.error) {
responseReceived = true; // Отмечаем что получили ответ (даже если ошибка)
this.hideLoading();
this.hideTypingIndicator();
this.addStreamingMessage(data.data.error || 'Произошла ошибка при обработке запроса.', false, 25);
this.currentEventSource.close();
this.currentEventSource = null;
}
} catch (error) {
// Игнорируем ошибки парсинга
}
});
// Обработчик heartbeat (поддержание соединения)
this.currentEventSource.addEventListener('heartbeat', (event) => {
console.log('AI Drawer: SSE heartbeat received');
});
// Обработчик подключения
this.currentEventSource.addEventListener('connected', (event) => {
console.log('AI Drawer: SSE connected:', event.data);
});
// Таймаут на случай если SSE не работает (fallback на Redis check)
setTimeout(() => {
if (this.currentEventSource && this.currentEventSource.readyState === EventSource.CONNECTING && !responseReceived) {
console.log('AI Drawer: SSE timeout, checking Redis directly');
this.currentEventSource.close();
this.currentEventSource = null;
this.checkRedisDirectly(taskId);
}
}, 5000); // 5 секунд на подключение
// Общий таймаут ожидания ответа (5 минут)
setTimeout(() => {
if (this.currentEventSource && !responseReceived) {
this.currentEventSource.close();
this.currentEventSource = null;
this.hideLoading();
this.hideTypingIndicator();
this.addStreamingMessage('Время ожидания истекло. Попробуйте еще раз.', false, 25);
}
}, 300000);
}
// Метод для прямой проверки Redis (если SSE не работает)
async checkRedisDirectly(taskId) {
console.log('AI Drawer: Checking Redis directly for task:', taskId);
// Проверяем несколько раз с интервалом (на случай если ответ еще обрабатывается)
let attempts = 0;
const maxAttempts = 30; // 30 попыток = 1 минута (каждые 2 секунды)
const checkInterval = setInterval(async () => {
attempts++;
console.log(`AI Drawer: Redis check attempt ${attempts}/${maxAttempts}`);
try {
const response = await fetch(`/aiassist/check_redis_response.php?task_id=${encodeURIComponent(taskId)}`);
if (!response.ok) {
throw new Error(`Redis check failed: ${response.status}`);
}
const data = await response.json();
if (data.found && data.response) {
clearInterval(checkInterval);
this.hideLoading();
this.hideTypingIndicator();
this.addStreamingMessage(data.response, false, 25);
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval);
this.hideLoading();
this.hideTypingIndicator();
this.addStreamingMessage('Ответ не получен. Попробуйте отправить запрос еще раз.', false, 25);
}
} catch (error) {
console.error('AI Drawer: Redis direct check error:', error);
if (attempts >= maxAttempts) {
clearInterval(checkInterval);
this.hideLoading();
this.hideTypingIndicator();
this.addStreamingMessage('Ошибка при получении ответа. Попробуйте еще раз.', false, 25);
}
}
}, 2000); // Проверяем каждые 2 секунды
}
// Fallback метод для polling (если SSE не работает)
async startPollingFallback(taskId) {
console.log('AI Drawer: Starting polling fallback for task:', taskId);
const pollInterval = setInterval(async () => {
try {
const completed = await this.checkAIResult(taskId);
if (completed) {
const response = await fetch(`/get_ai_result.php?task_id=${taskId}`);
if (!response.ok) {
throw new Error(`Result check failed: ${response.status}`);
}
const data = await response.json();
if (data.status === 'completed') {
clearInterval(pollInterval);
this.hideLoading();
this.hideTypingIndicator();
this.addStreamingMessage(data.response, false, 25);
} else if (data.status === 'error') {
clearInterval(pollInterval);
this.hideLoading();
this.hideTypingIndicator();
this.addStreamingMessage(data.error || 'Произошла ошибка при обработке запроса.', false, 25);
}
} catch (error) {
console.error('AI Drawer: Polling error:', error);
console.error('AI Drawer: Polling fallback error:', error);
clearInterval(pollInterval);
this.hideLoading();
this.hideTypingIndicator();
@@ -468,38 +640,6 @@ class AIDrawer {
}, 300000);
}
// Метод для проверки результата
async checkAIResult(taskId) {
console.log('AI Drawer: Checking result for task:', taskId);
const response = await fetch(`/get_ai_result.php?task_id=${taskId}`);
if (!response.ok) {
throw new Error(`Result check failed: ${response.status}`);
}
const data = await response.json();
console.log('AI Drawer: Result check response:', data);
if (data.status === 'completed') {
// Задача завершена
this.hideLoading();
this.hideTypingIndicator();
this.addStreamingMessage(data.response, false, 25);
return true; // Остановить polling
} else if (data.status === 'error') {
// Ошибка обработки
this.hideLoading();
this.hideTypingIndicator();
this.addStreamingMessage(data.error || 'Произошла ошибка при обработке запроса.', false, 25);
return true; // Остановить polling
} else {
// Задача еще обрабатывается
console.log('AI Drawer: Still processing...');
return false; // Продолжить polling
}
}
getCurrentContext() {
const urlParams = new URLSearchParams(window.location.search);
const projectId = urlParams.get('record') || '';