Реализован 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
|
<?php
|
||||||
// aiassist/n8n_proxy.php
|
// aiassist/n8n_proxy.php
|
||||||
// Proxy между AI Drawer и n8n с асинхронной обработкой
|
// Proxy между AI Drawer и n8n
|
||||||
|
// n8n обрабатывает запрос и публикует ответ напрямую в Redis
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
header('Access-Control-Allow-Origin: *');
|
header('Access-Control-Allow-Origin: *');
|
||||||
@@ -22,17 +23,6 @@ if (!function_exists('curl_init')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
if (!$input) {
|
if (!$input) {
|
||||||
@@ -48,22 +38,8 @@ try {
|
|||||||
|
|
||||||
error_log("N8N Proxy: New task {$taskId} for session {$sessionId}");
|
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
|
||||||
|
// n8n обработает и опубликует ответ напрямую в Redis: ai:response:{taskId}
|
||||||
$n8nWebhookUrl = 'https://n8n.clientright.pro/webhook/0b20bf1e-7cda-4dc8-899e-a7c3be4096c0';
|
$n8nWebhookUrl = 'https://n8n.clientright.pro/webhook/0b20bf1e-7cda-4dc8-899e-a7c3be4096c0';
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
@@ -71,12 +47,15 @@ try {
|
|||||||
'context' => $context,
|
'context' => $context,
|
||||||
'sessionId' => $sessionId,
|
'sessionId' => $sessionId,
|
||||||
'taskId' => $taskId,
|
'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'),
|
'timestamp' => date('Y-m-d H:i:s'),
|
||||||
'source' => 'crm-client'
|
'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();
|
$ch = curl_init();
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
@@ -105,11 +84,13 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Возвращаем task_id клиенту
|
// Возвращаем task_id клиенту
|
||||||
|
// Клиент подпишется на SSE и получит ответ когда n8n опубликует в Redis
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'task_id' => $taskId,
|
'task_id' => $taskId,
|
||||||
'status' => 'accepted',
|
'status' => 'accepted',
|
||||||
'message' => 'Запрос принят в обработку'
|
'message' => 'Запрос принят в обработку',
|
||||||
|
'redisChannel' => "ai:response:{$taskId}" // Для информации
|
||||||
]);
|
]);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ class AIDrawer {
|
|||||||
this.fontSize = 'normal';
|
this.fontSize = 'normal';
|
||||||
this.avatarType = 'default';
|
this.avatarType = 'default';
|
||||||
this.sessionId = null;
|
this.sessionId = null;
|
||||||
|
this.currentEventSource = null; // Для SSE соединения
|
||||||
this.init();
|
this.init();
|
||||||
|
|
||||||
// Загружаем историю сразу при инициализации (при загрузке страницы)
|
// Загружаем историю сразу при инициализации (при загрузке страницы)
|
||||||
@@ -425,9 +426,9 @@ class AIDrawer {
|
|||||||
console.log('AI Drawer: data.success =', data.success, 'type:', typeof data.success);
|
console.log('AI Drawer: data.success =', data.success, 'type:', typeof data.success);
|
||||||
|
|
||||||
if (data.success && data.task_id) {
|
if (data.success && data.task_id) {
|
||||||
// Запрос принят, начинаем polling по task_id
|
// Запрос принят, подписываемся на SSE события через Redis
|
||||||
console.log('AI Drawer: Request accepted, task_id:', data.task_id);
|
console.log('AI Drawer: Request accepted, task_id:', data.task_id);
|
||||||
this.startPolling(data.task_id);
|
this.startSSEListener(data.task_id);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.message || 'Unknown error');
|
throw new Error(data.message || 'Unknown error');
|
||||||
}
|
}
|
||||||
@@ -440,18 +441,189 @@ class AIDrawer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Метод для polling результатов
|
// Метод для подписки на SSE события через Redis Pub/Sub
|
||||||
async startPolling(taskId) {
|
startSSEListener(taskId) {
|
||||||
console.log('AI Drawer: Starting polling for task:', 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 () => {
|
const pollInterval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const completed = await this.checkAIResult(taskId);
|
const response = await fetch(`/get_ai_result.php?task_id=${taskId}`);
|
||||||
if (completed) {
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Result check failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'completed') {
|
||||||
clearInterval(pollInterval);
|
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) {
|
} catch (error) {
|
||||||
console.error('AI Drawer: Polling error:', error);
|
console.error('AI Drawer: Polling fallback error:', error);
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
this.hideLoading();
|
this.hideLoading();
|
||||||
this.hideTypingIndicator();
|
this.hideTypingIndicator();
|
||||||
@@ -468,38 +640,6 @@ class AIDrawer {
|
|||||||
}, 300000);
|
}, 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() {
|
getCurrentContext() {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const projectId = urlParams.get('record') || '';
|
const projectId = urlParams.get('record') || '';
|
||||||
|
|||||||
Reference in New Issue
Block a user