From 1a4653298da53bb994c5ead2e31486408d131f21 Mon Sep 17 00:00:00 2001 From: Fedor Date: Tue, 11 Nov 2025 15:16:27 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=20SSE=20+=20Redis=20Pub/Sub=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20AI=20Drawer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлен 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 минут) --- AI_DRAWER_REDIS_SSE.md | 130 ++++++++++++ N8N_REDIS_FIX.md | 155 ++++++++++++++ N8N_REDIS_SETUP.md | 185 ++++++++++++++++ aiassist/check_redis_response.php | 107 ++++++++++ aiassist/n8n_proxy.php | 41 +--- layouts/v7/resources/js/ai-drawer-simple.js | 220 ++++++++++++++++---- 6 files changed, 768 insertions(+), 70 deletions(-) create mode 100644 AI_DRAWER_REDIS_SSE.md create mode 100644 N8N_REDIS_FIX.md create mode 100644 N8N_REDIS_SETUP.md create mode 100644 aiassist/check_redis_response.php diff --git a/AI_DRAWER_REDIS_SSE.md b/AI_DRAWER_REDIS_SSE.md new file mode 100644 index 00000000..30d8ef7e --- /dev/null +++ b/AI_DRAWER_REDIS_SSE.md @@ -0,0 +1,130 @@ +# AI Drawer: Redis Pub/Sub вместо Polling + +## ✅ Что сделано + +### Проблема +Раньше использовался polling - браузер каждые 2 секунды спрашивал сервер "готово ли?". Это создавало: +- Лишние запросы к серверу +- Задержку до 2 секунд перед получением ответа +- Нагрузку на БД +- Дублирование истории (БД + n8n) + +### Решение +Теперь используется **Redis Pub/Sub + SSE (Server-Sent Events)** с прямой публикацией из n8n: +- ✅ Мгновенная доставка ответов (без задержек) +- ✅ Нет лишних запросов (одно SSE соединение) +- ✅ Меньше нагрузка на сервер +- ✅ Нет дублирования - история только в n8n +- ✅ Упрощенная архитектура - без БД и callback +- ✅ Fallback на polling если SSE не работает + +## 📋 Архитектура + +``` +1. AI Drawer → n8n_proxy.php → возвращает task_id +2. n8n обрабатывает → публикует ответ НАПРЯМУЮ в Redis +3. Redis PUBLISH → канал "ai:response:{task_id}" +4. ai_sse.php → подписан на Redis → отправляет через SSE в браузер +5. Браузер → EventSource → получает ответ мгновенно! ⚡ +``` + +**История диалога:** Сохраняется в n8n автоматически (не дублируется в БД) + +## 📁 Измененные файлы + +### 1. `/aiassist/ai_sse.php` (новый) +SSE endpoint для подписки на Redis события + +### 2. `/aiassist/n8n_proxy.php` (упрощен) +- ❌ Убрано сохранение в БД +- ❌ Убран callback URL +- ✅ Добавлены параметры Redis для n8n + +### 3. `/callback_ai_response.php` (больше не используется) +Можно удалить - n8n публикует напрямую в Redis + +### 4. `/layouts/v7/resources/js/ai-drawer-simple.js` (обновлен) +- Заменен `startPolling()` на `startSSEListener()` +- Добавлен fallback на polling если SSE не работает +- Добавлено поле `currentEventSource` для управления SSE соединением + +## 🔧 Как работает + +### Отправка запроса: +```javascript +// Пользователь отправляет сообщение +sendToN8N(message) → получает task_id → startSSEListener(task_id) +``` + +### Получение ответа: +```javascript +// SSE соединение открывается один раз +EventSource('/aiassist/ai_sse.php?task_id=123') + +// n8n обрабатывает и публикует НАПРЯМУЮ в Redis: +Redis PUBLISH "ai:response:123" { + task_id: "123", + response: "...", + status: "completed" +} + +// SSE endpoint получает событие и отправляет в браузер +// Браузер получает ответ мгновенно! +``` + +### Настройка n8n: +См. подробную инструкцию: `N8N_REDIS_SETUP.md` + +## 🛡️ Fallback механизм + +Если SSE не работает (старые браузеры, проблемы с сетью): +1. Через 5 секунд автоматически переключается на polling +2. Использует старый метод `startPollingFallback()` +3. Проверяет БД каждые 2 секунды + +## ⚙️ Настройки Redis + +- **Host**: `crm.clientright.ru` +- **Port**: `6379` +- **Password**: `CRM_Redis_Pass_2025_Secure!` +- **Канал**: `ai:response:{task_id}` + +## 🧪 Тестирование + +1. Откройте AI Drawer в CRM +2. Отправьте сообщение +3. Проверьте консоль браузера: + - `AI Drawer: SSE connection opened` + - `AI Drawer: Received response via SSE` +4. Ответ должен прийти мгновенно после обработки n8n + +## 📊 Преимущества + +| Параметр | Polling (старое) | Redis Pub/Sub (новое) | +|----------|------------------|----------------------| +| Скорость | До 2 сек задержки | Мгновенно ⚡ | +| Запросы | Каждые 2 сек | Одно соединение | +| Нагрузка | Высокая | Низкая | +| Надежность | ✅ | ✅ + fallback | + +## 🔍 Отладка + +### Проверить Redis публикацию: +```bash +redis-cli -h crm.clientright.ru -a 'CRM_Redis_Pass_2025_Secure!' \ + PUBLISH "ai:response:test-task" '{"task_id":"test-task","response":"test"}' +``` + +### Проверить SSE endpoint: +```bash +curl -N "https://crm.clientright.ru/aiassist/ai_sse.php?task_id=test-task" +``` + +### Логи: +- PHP error_log: `/var/log/php/error.log` +- Ищите: `[AI SSE]` и `[Callback]` + +## ✅ Результат + +Теперь AI Drawer получает ответы **мгновенно** через Redis Pub/Sub вместо ожидания polling каждые 2 секунды! + diff --git a/N8N_REDIS_FIX.md b/N8N_REDIS_FIX.md new file mode 100644 index 00000000..6c6a0b67 --- /dev/null +++ b/N8N_REDIS_FIX.md @@ -0,0 +1,155 @@ +# 🔧 Исправление конфигурации n8n для Redis публикации + +## ❌ Проблема в текущей конфигурации + +```json +{ + "channel": "=ai:response:{{ $('Edit Fields').item.json.taskId }}", + "messageData": "={{ JSON.stringify($json.output) }}" +} +``` + +**Проблемы:** +1. ❌ Канал использует `$('Edit Fields').item.json.taskId` - неправильный путь +2. ❌ `messageData` содержит `$json.output` - неправильный формат +3. ❌ Нет сохранения в Redis ключ для fallback + +## ✅ Правильная конфигурация + +### Вариант 1: Если taskId в корне webhook body + +**Channel:** +``` +ai:response:{{ $json.taskId }} +``` + +**Message (JSON объект):** +```json +{ + "task_id": "{{ $json.taskId }}", + "response": "{{ $json.output }}", + "status": "completed" +} +``` + +### Вариант 2: Если taskId в webhook.body + +**Channel:** +``` +ai:response:{{ $json.webhook.body.taskId }} +``` + +**Message (JSON объект):** +```json +{ + "task_id": "{{ $json.webhook.body.taskId }}", + "response": "{{ $json.output }}", + "status": "completed" +} +``` + +### Вариант 3: Если ответ в другой ноде (например, AI Chat) + +**Channel:** +``` +{{ $json.webhook.body.redisChannel }} +``` + +**Message (JSON объект):** +```json +{ + "task_id": "{{ $json.webhook.body.taskId }}", + "response": "{{ $json['AI Chat'].json.response }}", + "status": "completed" +} +``` + +## 📋 Полная настройка n8n workflow + +### Шаг 1: Redis SET (сохранить в ключ для fallback) + +**Operation:** `Set` +**Key:** `ai:response:cache:{{ $json.webhook.body.taskId }}` +**Value:** +```json +{ + "task_id": "{{ $json.webhook.body.taskId }}", + "response": "{{ $json['AI Chat'].json.response }}", + "status": "completed", + "timestamp": "{{ $now.toISO() }}" +} +``` +**TTL:** `300` секунд + +### Шаг 2: Redis PUBLISH (опубликовать в канал для SSE) + +**Operation:** `Publish` +**Channel:** `{{ $json.webhook.body.redisChannel }}` +**Message:** +```json +{ + "task_id": "{{ $json.webhook.body.taskId }}", + "response": "{{ $json['AI Chat'].json.response }}", + "status": "completed" +} +``` + +## 🔍 Как найти правильный путь к данным + +1. **Добавьте ноду "Set" перед Redis:** + - Сохраните все данные из предыдущих нод + - Посмотрите структуру данных в n8n + +2. **Используйте Expression Editor в n8n:** + - Нажмите на поле "Channel" или "Message" + - Выберите "Expression" + - Начните вводить `$json.` - увидите доступные поля + +3. **Проверьте webhook body:** + - В ноде Webhook посмотрите что приходит + - `taskId` и `redisChannel` должны быть в `$json.webhook.body` + +## ✅ Проверка + +После настройки проверьте: + +1. **В n8n:** + - Запустите workflow + - Проверьте что Redis ноды выполнились успешно + - Посмотрите что именно публикуется в канал + +2. **В Redis:** + ```bash + redis-cli -h crm.clientright.ru -p 6379 -a 'CRM_Redis_Pass_2025_Secure!' \ + MONITOR + ``` + Должны видеть: + - `SET ai:response:cache:task-xxx ...` + - `PUBLISH ai:response:task-xxx ...` + +3. **В браузере:** + - Откройте консоль (F12) + - Должны видеть: `AI Drawer: SSE connection opened` + - Должны видеть: `AI Drawer: Received response via SSE` + +## 🐛 Отладка + +Если не работает: + +1. **Проверьте формат сообщения:** + - Должен быть валидный JSON + - Должно быть поле `response` или `task_id` + +2. **Проверьте канал:** + - Должен совпадать с `redisChannel` из `n8n_proxy.php` + - Формат: `ai:response:task-xxx` + +3. **Проверьте логи PHP:** + ```bash + tail -f /var/log/apache2/error.log | grep "AI SSE" + ``` + +4. **Проверьте что SSE endpoint доступен:** + - Откройте в браузере: `/aiassist/ai_sse.php?task_id=test-123` + - Должен открыться поток SSE (не ошибка) + diff --git a/N8N_REDIS_SETUP.md b/N8N_REDIS_SETUP.md new file mode 100644 index 00000000..d52205ca --- /dev/null +++ b/N8N_REDIS_SETUP.md @@ -0,0 +1,185 @@ +# Настройка n8n для прямой публикации в Redis + +## 🎯 Цель +Настроить n8n workflow так, чтобы после обработки AI ответа он публиковал результат **напрямую в Redis**, без промежуточного callback. + +## 📋 Архитектура + +``` +AI Drawer → n8n_proxy.php → n8n webhook + ↓ + [Обработка AI] + ↓ + Redis PUBLISH → ai:response:{taskId} + ↓ + SSE → браузер получает мгновенно! ⚡ +``` + +## 🔧 Настройка n8n Workflow + +### Шаг 1: Добавить Redis ноду после обработки AI + +В вашем n8n workflow после ноды обработки AI добавьте **Redis ноду**: + +1. **Тип ноды**: `Redis` +2. **Operation**: `Publish` + +### Шаг 2: Настройки Redis ноды + +**Connection:** +``` +Host: crm.clientright.ru +Port: 6379 +Password: CRM_Redis_Pass_2025_Secure! +Database: 0 +``` + +**Operation Settings:** +``` +Operation: Publish +Channel: {{ $json.redisChannel }} +``` + +**Message (вариант 1 - JSON объект, рекомендуется):** +```json +{ + "task_id": "{{ $json.taskId }}", + "status": "completed", + "response": "{{ $json.aiResponse }}", + "timestamp": "{{ $now.toISO() }}" +} +``` + +**Message (вариант 2 - просто текст, тоже работает):** +``` +{{ $json.aiResponse }} +``` + +⚠️ **Важно:** SSE endpoint поддерживает оба формата: +- JSON объект с полем `response` - предпочтительно +- Просто текст ответа - тоже работает (автоматически обрабатывается) + +### Шаг 2.5: Сохранение в Redis ключ (ВАЖНО для fallback) + +⚠️ **КРИТИЧНО:** Сохраняйте ответ в Redis ключ **ПЕРЕД** публикацией в канал! + +**Порядок действий в n8n:** +1. Обработка AI → получен ответ +2. **Сначала:** Redis SET → сохранить в ключ `ai:response:cache:{taskId}` (TTL 300 сек) +3. **Потом:** Redis PUBLISH → опубликовать в канал `ai:response:{taskId}` + +**Добавьте Redis ноду для SET (перед PUBLISH):** + +**Operation:** `Set` +**Key:** `ai:response:cache:{{ $json.taskId }}` +**Value:** +```json +{ + "task_id": "{{ $json.taskId }}", + "response": "{{ $json.aiResponse }}", + "status": "completed", + "timestamp": "{{ $now.toISO() }}" +} +``` +**TTL:** `300` секунд (5 минут) + +**Зачем это нужно:** +- Если SSE не подписался вовремя → fallback найдет ответ в ключе +- Если браузер перезагрузился → ответ все еще доступен +- Надежность: двойное сохранение (канал + ключ) + +### Шаг 3: Канал Redis + +Канал формируется автоматически из `taskId`: +``` +ai:response:{{ $json.taskId }} +``` + +Или используйте значение из входящего запроса: +``` +{{ $json.redisChannel }} +``` + +## 📝 Пример workflow + +``` +[Webhook] → [AI обработка] → [Redis SET] → [Redis PUBLISH] → [End] + ↓ ↓ + [Сохранить историю в n8n] [Ответ в ключе + канале] +``` + +**Порядок:** +1. SET в ключ `ai:response:cache:{taskId}` (для fallback) +2. PUBLISH в канал `ai:response:{taskId}` (для SSE) +3. Сохранение истории в n8n + +### Детали Redis ноды: + +**Input:** +- `taskId` - из входящего webhook запроса +- `aiResponse` - результат обработки AI +- `redisChannel` - канал из входящего запроса (`ai:response:{taskId}`) + +**Output:** +- Публикация в Redis канал +- Браузер получает через SSE мгновенно + +## ✅ Проверка + +### Тест публикации из командной строки: +```bash +redis-cli -h crm.clientright.ru -p 6379 -a 'CRM_Redis_Pass_2025_Secure!' \ + PUBLISH "ai:response:test-task" '{"task_id":"test-task","response":"Тест","status":"completed"}' +``` + +### Проверка в n8n: +1. Запустите workflow с тестовым запросом +2. Проверьте логи Redis ноды - должна быть успешная публикация +3. В браузере откройте AI Drawer и отправьте сообщение +4. Ответ должен прийти мгновенно через SSE + +## 🔍 Отладка + +### Если ответ не приходит: + +1. **Проверьте канал Redis:** + ```bash + redis-cli -h crm.clientright.ru -p 6379 -a 'CRM_Redis_Pass_2025_Secure!' \ + MONITOR + ``` + Должны видеть PUBLISH команды + +2. **Проверьте формат сообщения:** + Сообщение должно быть валидным JSON: + ```json + { + "task_id": "task-123", + "response": "Ответ от AI", + "status": "completed" + } + ``` + +3. **Проверьте SSE endpoint:** + ```bash + curl -N "https://crm.clientright.ru/aiassist/ai_sse.php?task_id=test-task" + ``` + +## 📊 Преимущества новой архитектуры + +✅ **Проще** - нет промежуточного callback +✅ **Быстрее** - прямая публикация в Redis +✅ **Надежнее** - меньше точек отказа +✅ **Меньше кода** - убрали БД и callback + +## 🚨 Важно + +- История диалога сохраняется в n8n автоматически (не нужно дублировать в БД) +- Если Redis недоступен, браузер автоматически переключится на fallback (polling) +- Канал Redis уникален для каждого запроса: `ai:response:{taskId}` + +## 📁 Связанные файлы + +- `/aiassist/n8n_proxy.php` - отправляет запрос в n8n с параметрами Redis +- `/aiassist/ai_sse.php` - SSE endpoint для получения ответов из Redis +- `/layouts/v7/resources/js/ai-drawer-simple.js` - JavaScript клиент с SSE + diff --git a/aiassist/check_redis_response.php b/aiassist/check_redis_response.php new file mode 100644 index 00000000..ab73e27c --- /dev/null +++ b/aiassist/check_redis_response.php @@ -0,0 +1,107 @@ +connect('crm.clientright.ru', 6379)) { + throw new Exception('Redis connection failed'); + } + $redis->auth('CRM_Redis_Pass_2025_Secure!'); + + // Пробуем получить ответ из кеша + $cachedResponse = $redis->get($redisKey); + + if ($cachedResponse) { + $responseData = json_decode($cachedResponse, true); + + if ($responseData && isset($responseData['response'])) { + echo json_encode([ + 'found' => true, + 'response' => $responseData['response'], + 'status' => $responseData['status'] ?? 'completed', + 'timestamp' => $responseData['timestamp'] ?? null + ]); + } else { + // Если это просто строка + echo json_encode([ + 'found' => true, + 'response' => $cachedResponse + ]); + } + } else { + echo json_encode([ + 'found' => false, + 'message' => 'Ответ еще не готов или истек TTL' + ]); + } + + $redis->close(); + } else { + require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php'; + + $redis = new Predis\Client([ + 'scheme' => 'tcp', + 'host' => 'crm.clientright.ru', + 'port' => 6379, + 'password' => 'CRM_Redis_Pass_2025_Secure!', + ]); + + $cachedResponse = $redis->get($redisKey); + + if ($cachedResponse) { + $responseData = json_decode($cachedResponse, true); + + if ($responseData && isset($responseData['response'])) { + echo json_encode([ + 'found' => true, + 'response' => $responseData['response'], + 'status' => $responseData['status'] ?? 'completed', + 'timestamp' => $responseData['timestamp'] ?? null + ]); + } else { + echo json_encode([ + 'found' => true, + 'response' => $cachedResponse + ]); + } + } else { + echo json_encode([ + 'found' => false, + 'message' => 'Ответ еще не готов или истек TTL' + ]); + } + } + +} catch (Exception $e) { + http_response_code(500); + echo json_encode([ + 'found' => false, + 'error' => $e->getMessage() + ]); +} +?> + diff --git a/aiassist/n8n_proxy.php b/aiassist/n8n_proxy.php index f005303e..cb59f8ea 100644 --- a/aiassist/n8n_proxy.php +++ b/aiassist/n8n_proxy.php @@ -1,6 +1,7 @@ connect_error) { - throw new Exception("DB connection failed: " . $conn->connect_error); - } - - $conn->set_charset('utf8mb4'); - $input = json_decode(file_get_contents('php://input'), true); if (!$input) { @@ -48,22 +38,8 @@ try { error_log("N8N Proxy: New task {$taskId} for session {$sessionId}"); - // Сохраняем начальный статус в БД - $requestData = json_encode(['message' => $message, 'context' => $context], JSON_UNESCAPED_UNICODE); - $stmt = $conn->prepare("INSERT INTO ai_responses (task_id, session_id, status, request_data) VALUES (?, ?, 'processing', ?)"); - $stmt->bind_param('sss', $taskId, $sessionId, $requestData); - - if (!$stmt->execute()) { - throw new Exception("Failed to save task: " . $stmt->error); - } - - $stmt->close(); - $conn->close(); - - // Формируем callback URL - $callbackUrl = 'https://crm.clientright.ru/callback_ai_response.php'; - // Отправляем запрос в n8n + // n8n обработает и опубликует ответ напрямую в Redis: ai:response:{taskId} $n8nWebhookUrl = 'https://n8n.clientright.pro/webhook/0b20bf1e-7cda-4dc8-899e-a7c3be4096c0'; $payload = [ @@ -71,12 +47,15 @@ try { 'context' => $context, 'sessionId' => $sessionId, 'taskId' => $taskId, - 'callbackUrl' => $callbackUrl, + 'redisChannel' => "ai:response:{$taskId}", // Канал для публикации ответа + 'redisHost' => 'crm.clientright.ru', + 'redisPort' => 6379, + 'redisPassword' => 'CRM_Redis_Pass_2025_Secure!', 'timestamp' => date('Y-m-d H:i:s'), 'source' => 'crm-client' ]; - error_log("N8N Proxy: Sending to n8n - Task: {$taskId}"); + error_log("N8N Proxy: Sending to n8n - Task: {$taskId}, Redis channel: ai:response:{$taskId}"); $ch = curl_init(); curl_setopt_array($ch, [ @@ -105,11 +84,13 @@ try { } // Возвращаем task_id клиенту + // Клиент подпишется на SSE и получит ответ когда n8n опубликует в Redis echo json_encode([ 'success' => true, 'task_id' => $taskId, 'status' => 'accepted', - 'message' => 'Запрос принят в обработку' + 'message' => 'Запрос принят в обработку', + 'redisChannel' => "ai:response:{$taskId}" // Для информации ]); } catch (Exception $e) { diff --git a/layouts/v7/resources/js/ai-drawer-simple.js b/layouts/v7/resources/js/ai-drawer-simple.js index 27df579b..7c559d6d 100644 --- a/layouts/v7/resources/js/ai-drawer-simple.js +++ b/layouts/v7/resources/js/ai-drawer-simple.js @@ -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') || '';