Реализован 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:
130
AI_DRAWER_REDIS_SSE.md
Normal file
130
AI_DRAWER_REDIS_SSE.md
Normal file
@@ -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 секунды!
|
||||
|
||||
155
N8N_REDIS_FIX.md
Normal file
155
N8N_REDIS_FIX.md
Normal file
@@ -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 (не ошибка)
|
||||
|
||||
185
N8N_REDIS_SETUP.md
Normal file
185
N8N_REDIS_SETUP.md
Normal file
@@ -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
|
||||
|
||||
107
aiassist/check_redis_response.php
Normal file
107
aiassist/check_redis_response.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
/**
|
||||
* check_redis_response.php
|
||||
* Проверка ответа в Redis (fallback если SSE не работает)
|
||||
* Проверяет ключ Redis где хранится последний ответ
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit();
|
||||
}
|
||||
|
||||
try {
|
||||
$taskId = $_GET['task_id'] ?? null;
|
||||
|
||||
if (!$taskId) {
|
||||
throw new Exception('Missing task_id parameter');
|
||||
}
|
||||
|
||||
$redisKey = "ai:response:cache:{$taskId}";
|
||||
|
||||
// Подключаемся к Redis
|
||||
if (class_exists('Redis')) {
|
||||
$redis = new Redis();
|
||||
if (!$redis->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()
|
||||
]);
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
// aiassist/n8n_proxy.php
|
||||
// Proxy между AI Drawer и n8n с асинхронной обработкой
|
||||
// Proxy между AI Drawer и n8n
|
||||
// n8n обрабатывает запрос и публикует ответ напрямую в Redis
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
@@ -22,17 +23,6 @@ if (!function_exists('curl_init')) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Подключаемся к БД
|
||||
include_once('../config.inc.php');
|
||||
|
||||
$conn = new mysqli($dbconfig['db_server'], $dbconfig['db_username'], $dbconfig['db_password'], $dbconfig['db_name']);
|
||||
|
||||
if ($conn->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) {
|
||||
|
||||
@@ -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') || '';
|
||||
|
||||
Reference in New Issue
Block a user