Запрошено владельцем: форма-диалог с ИИ-ботом на сайте delritm.ru — клиент описывает потребность своими словами, ИИ объясняет что можно предложить, попутно собирает контакт как лид.
Архитектура
Клиент (браузер, delritm.ru)
│ плавающая кнопка-баббл в углу, все страницы
▼
Виджет (vanilla JS/CSS, один файл, встроен в Tilda через блок «Вставка кода»)
│ POST {session_id, message, history}
▼
n8n webhook /webhook/delritm-chat (новый отдельный workflow "delritm-chat-widget",
НЕ завязан на существующую agent-harness — та сейчас
в процессе редизайна, 3 конфликтующих MAX-роутера)
│
├─► OmniRoute (omniroute.skud24.ru/v1) — LLM, статичный system-промпт
│ (каталог услуг delritm.ru + инструкция естественно запросить имя+контакт)
│ ответ — structured JSON: {reply: string, lead: {name, contact} | null}
│
├─► reply → назад клиенту в виджет
│
└─ если lead != null:
├─► Twenty CRM: create_one_person (name, phone/email) [company tag: delritm.ru]
│ + create_one_note (полный транскрипт диалога, привязан к person)
└─► MAX Sender API (/webhook/max-sender-api) → push в рабочую группу
«Деловой Ритм рабочий чат» (chat_id -75540425318449):
"Новый лид с сайта: <имя>, <контакт>, суть запроса: <кратко>"
Компоненты
1. Frontend-виджет. Плавающая кнопка в углу экрана → раскрывает окно чата. Без localStorage/sessionStorage — история диалога только в памяти вкладки (session_id генерируется на клиенте при открытии). CORS webhook ограничен доменом delritm.ru.
2. Backend n8n workflow delritm-chat-widget. Webhook trigger → HTTP-запрос к OmniRoute с system-промптом + историей диалога → парсинг structured-ответа → ветвление по наличию lead.
3. System-промпт (статичный, каталог услуг из wiki delritm-ru.md): - ИП Шувалов И.А., IT-консалтинг, СПб. Услуги: IT-автоматизация (CRM, «умные таблицы», интеграции с мессенджерами/ИИ-агентами), оптимизация закупок, внешняя модерация совещаний, комплексный пакет. AI-трансформация: обработка заявок 24/7, техподдержка, ИИ-агент в рабочих чатах (Telegram/МАКС/Slack/Discord), мониторинг склада, анализ звонков, контроль рабочего времени. - Инструкция: отвечать по существу вопроса клиента, предлагать релевантную услугу, не выдумывать цены/сроки (их нет в открытых данных — предлагать «обсудим на бесплатной диагностике»), естественно спросить имя и телефон/email после того как понятен запрос клиента, не раньше и не навязчиво. - Формат ответа — JSON с полями reply/lead, чтобы backend мог детерминированно вытащить лид без regex-угадывания.
4. Twenty CRM. create_one_person + create_one_note с транскриптом. Явно помечается принадлежность к delritm.ru (в отличие от остальных данных workspace — они принадлежат проекту skud24).
5. MAX-уведомление. Переиспользует уже работающий канал (MAX Sender API, тот же, что рекламные отчёты) — push в ту же рабочую группу.
Обработка ошибок
- OmniRoute 502/таймаут (известный прецедент в agent-harness) → retry ×1 → fallback-ответ клиенту: «Не могу сейчас ответить развёрнуто — оставьте, пожалуйста, телефон или email, свяжемся» + сохранить сырой текст клиента как лид без структурированного имени.
- Пустое/некорректное сообщение — отбрасывается на уровне виджета до отправки.
- Грубый rate-limit по IP на уровне n8n (Code-нода, in-memory счётчик на воркер) — не панацея, но отсечёт примитивный скрипт-спам.
- CRM/MAX-пуш — если один из двух шагов упал, второй всё равно выполняется (не блокируют друг друга); ошибка логируется, не показывается клиенту (диалог уже прошёл успешно).
Безопасность/приватность
- Никаких паролей/токенов на фронтенде — виджет знает только публичный URL webhook.
- OmniRoute/Twenty/MAX-ключи — только на стороне n8n.
- Транскрипт диалога хранится только в CRM (доступ — рабочая команда), не логируется во внешние сервисы сверх этого.
Тестирование
1. Ручной прогон 3-4 тестовых диалогов с разными запросами → проверка, что лид корректно долетает до Twenty CRM (person+note) и до MAX-группы. 2. Проверка виджета на живой Tilda-странице — desktop и mobile, все текущие разделы одностраничника. 3. curl-проверка webhook напрямую на edge-кейсы: пустое сообщение, очень длинная история, отсутствие session_id. 4. Проверка, что диалог без переданного контакта не создаёт мусорных записей в CRM (lead=null не триггерит запись).
Вне рамок текущей итерации
- Персистентность истории диалога между визитами (без localStorage — сознательный выбор для первой версии).
- Переиспользование существующей agent-harness/Heartbeat v2 — отдельный workflow, чтобы не зависеть от идущего сейчас редизайна той системы.
- Разбивка сайта на отдельные лендинги под услуги (Фаза 3 SEO-плана) — не блокирует этот виджет, но синхронизировать текст System-промпта при появлении отдельных страниц услуг.
Реализация — готово и проверено на проде (2026-09-04)
Собрано и опубликовано по дизайну выше, с отличиями по ходу сборки:
n8n workflow delritm-chat-widget (id geYWviL4b9TYTxwN, https://n8n.skud24.ru/workflow/geYWviL4b9TYTxwN, опубликован/активен). Webhook trigger → format input → AI Agent (OmniRoute, structured output через outputParserStructured) → Respond to Webhook ({reply}); параллельно AI Agent → IF lead != null → Twenty CRM (create person → create note → link note to person) → push в MAX; onError на AI Agent → fallback-ответ клиенту + пуш в MAX о сбое. 15 нод.
Две особенности SDK, не очевидные из документации, ушли на отладку: .onError(handler) вызывается на самой ноде отдельной строкой ДО композиции workflow(...), не чейнится в цепочке .add(); outputParserStructured с schemaType: 'manual' требует inputSchema строго JSON-строкой (не объектом) — иначе падение "[object Object]" is not valid JSON ещё до вызова модели. Заодно добавлены autoFix: true + отдельная нода-модель для парсера (устойчивость к чуть кривому JSON от LLM).
Фронтенд. Один файл (баббл + панель чата + fetch на вебхук, без localStorage, session_id через crypto.randomUUID() при открытии) встроен в Tilda через «Настройки сайта → Вставка кода → HTML-код для вставки внутрь HEAD» — единственная доступная зона инъекции на сайте (варианта «перед </body>» в Tilda нет), поэтому вся сборка DOM отложена до DOMContentLoaded/наличия document.body. Опубликовано на живом сайте, кнопка и диалог подтверждены визуально.
Инцидент при первом живом тесте — CORS блокировал delritm.ru, разобрано и исправлено. Прод-вебхук сначала не был опубликован (тестировался только /webhook-test/...) — исправлено publish_workflow. После этого реальный запрос с delritm.ru всё равно падал в браузере (TypeError: Failed to fetch, preflight OPTIONS проходил 204, сам POST — нет) — причина оказалась не в самом воркфлоу, а в реверс-прокси перед n8n: /opt/skud24-traefik/nginx-n8n.conf на 198.51.100.1 (см. traefik-ha) жёстко прописывал Access-Control-Allow-Origin: https://n8n-agent-admin.skud24.ru на две строки (add_header в preflight-блоке и в основном ответе /webhook/), полностью игнорируя allowedOrigins самого webhook-триггера. Исправлено через SSH-доступ n8n-агента «Senior DevOps инженер» (HGEAZyo3aNFEuy7i, credential root/*** на 198.51.100.1, тот же путь, что раньше чинил pgadmin-proxy, см. traefik-ha): в файл добавлен map $http_origin $cors_origin { default ""; "~https://n8n-agent-admin.skud24.ru" $http_origin; "~https://delritm.ru" $http_origin; } перед первым server{}, оба add_header Access-Control-Allow-Origin заменены на $cors_origin. Бэкап файла сделан до правки. nginx -t + nginx -s reload в контейнере n8n-proxy (не трогая Traefik и остальные ~25 контейнеров) — прошли успешно. Проверено: OPTIONS и реальный POST с Origin: https://delritm.ru → 200 + корректный access-control-allow-origin, calls.skud24.ru (второй домен в том же файле) не затронут (302, жив).
Twenty CRM — credential не подключён (осталось владельцу). 3 HTTP Request-ноды (Create Person In Twenty, Create Note In Twenty, Link Note To Person) настроены на httpBearerAuth/generic credential, но сам credential с токеном Twenty не создан — я не завожу секреты сам. Владельцу нужно вручную создать Bearer Auth credential в n8n UI (https://n8n.skud24.ru/workflow/geYWviL4b9TYTxwN) и привязать к этим трём нодам — до этого лиды в CRM не улетают (реплай клиенту и MAX-пуш работают уже сейчас).
Открытая заметка на будущее (не блокер, TODO). Вебхук публичный по конструкции (URL в JS фронтенда — это нормально, CORS не защита от прямых curl-вызовов, он ограничивает только браузер). Сейчас нет rate-limit и серверной проверки длины сообщения (только клиентские 2000 символов). Стоит добавить при появлении спама/аномального трафика: rate limit по IP/session на вебхуке, серверный cap длины сообщения, опционально — Header Auth как доп. слой (не как основную защиту, токен в JS всё равно виден).
Связано с (доп. к списку выше)
- traefik-ha — источник и фикс CORS-инцидента (2026-09-04), правка
nginx-n8n.conf
Доработка — активнее собирать контакты (2026-09-06)
Запрос владельца: ИИ слишком мягко относился к сбору контактов, часть диалогов уходила без лида. Изменён system-промпт ноды Delritm Chat Agent (workflow geYWviL4b9TYTxwN, activeVersionId aa34e9d3-0710-45ee-ae81-bef09c468e2d):
- к первому запросу контакта добавлен лёгкий стимул: «оставьте контакт — проведём бесплатную диагностику и подберём решение под вашу задачу»;
- добавлено правило: если клиент проигнорировал вопрос о контакте или ушёл от темы — переспросить ещё один раз (не более одного повтора за диалог), с упоминанием бесплатной диагностики;
- если клиент явно отказался (мягко: «попозже», «подумаю») — бот не давит, уважает отказ, продолжает отвечать по существу без повторных попыток.
Логика различения строится на истории диалога, которую агент видит целиком (historyText из Format Chat Input) — отдельного state/counter не заводили, агент сам определяет по прошлым репликам, спрашивал ли он уже контакт.
Проверено прямыми curl-запросами на прод-вебхук (/webhook/delritm-chat) с 3 сценариями: (1) клиент мягко отказывается после первого запроса контакта → бот принимает отказ, не давит; (2) клиент уходит от темы (задаёт другой вопрос, игнорируя запрос контакта) → бот переспрашивает контакт во втором ответе; (3) первое сообщение сразу по делу → контакт не запрашивается преждевременно (как и раньше). Публикация (publish_workflow) выполнена сразу после правки — прод подхватил новый промпт.
Инцидент — OmniRoute 502 из-за мёртвого шлюза на хосте 198.51.100.2 (2026-09-06)
Симптом: виджет постоянно отвечал фоллбеком («не могу ответить развёрнуто, оставьте контакт») — клиент словил это 3 раза подряд в реальном диалоге, лид (номер телефона) при этом улетел в MAX сырым текстом через fallback-пуш, но не создался структурированно в Twenty (fallback-ветка лид не формирует).
Диагностика через n8n (execution-логи workflow delritm-chat-widget + временный прогон существующего workflow OmniRoute Configure Auto-Failover 198.51.100.2, id UWzmFGMfndSDuuBz, через который уже были известны SSH-креды для 198.51.100.2):
- Причина НЕ в контейнере OmniRoute (
docker ps: healthy, uptime 3 дня, restarts=0) и не в промптеDelritm Chat Agent. - Причина — дефолтный маршрут хоста 198.51.100.2 указывал на шлюз 198.51.100.3, который перестал отвечать на ARP (
INCOMPLETE, физически недоступен/выключен). Из-за этого весь исходящий трафик к провайдерам моделей (Cloudflare AI и др.) уходил в таймаут → все ~38 моделей в комбоauto/claude-sonnetфейлились по 504 → локальный/v1/modelsтоже отдавал 500/502. - Другие LAN-хосты (198.51.100.1, .43, .26) и реальный роутер сети 198.51.100.4 были доступны нормально — проблема изолирована именно на маршруте хоста .48.
Фикс: ip route replace default via 198.51.100.4 dev eth0 на 198.51.100.2 (живой роутер, был доступен и по ARP, и по пингу). После правки: пинг 198.51.100.5 — 100% success (~65ms), curl localhost:8787/v1/models внутри контейнера — 200, живой POST на /webhook/delritm-chat — нормальный содержательный ответ.
Не решено — персистентность. Проверка /etc/network, /etc/systemd/network, /etc/netplan, /etc/dhcpcd.conf на хосте не нашла файла со старым шлюзом 198.51.100.3 — правка ip route replace применена только в текущей runtime-таблице маршрутизации и переживёт до следующей перезагрузки/переподнятия сетевого интерфейса хоста (LXC на Proxmox, судя по resolv.conf с меткой PVE), после чего маршрут, скорее всего, вернётся к .15 (источник конфигурации не найден — возможно, задаётся Proxmox-хостом контейнера снаружи, не изнутри гостя). Требуется отдельно найти, что такое 198.51.100.3 (упавший шлюз/VPN-gateway VM?) и либо поднять его обратно, либо закрепить статический маршрут на 198.51.100.4 на уровне конфигурации LXC/Proxmox.
Комментарии