#Клиентский Chat API
Статус: Доступно. Маршруты
/api/widget/*работают сегодня и используются самим виджетом. Они не оформлены как версионируемый контракт (нет префикса версии, нет OpenAPI): состав полей описан по коду на 11.10.2026. Заголовки CORS есть только уinit; про остальные маршруты читайте раздел «Известные ограничения» до начала работы.
Назначение: построить собственный интерфейс чата («headless»): те же вызовы, что делает кадр виджета, но с вашим дизайном.
#Содержание
- Когда использовать
- Доступ и общие правила
- Схема работы
- Токен сессии
- POST /api/widget/init
- POST /api/widget/messages
- Согласие с офертой
- GET /api/widget/history
- GET /api/widget/poll
- GET /api/widget/events (SSE)
- POST /api/widget/upload
- Пределы
- Сводка ошибок
- Пример: чат на чистом JavaScript
- Пример: компонент React
- Известные ограничения
- Заметки
- Открытые вопросы
#Когда использовать
- Нужен интерфейс чата в собственном дизайне или в приложении.
- Виджет не подходит по внешнему виду или размещению.
Если достаточно стандартного окна, используйте виджет. Если ответ нужен на вашем сервере или в вашем мессенджере — серверный API (Планируется).
#Доступ и общие правила
| Базовый адрес | https://<домен ячейки> — RU https://formula-cream.pro, EN https://wfacademy.org, ID https://wfacademy.id |
| Аутентификация | ключа нет. Канал определяется открытым channel_id, личность посетителя — подписанным токеном сессии |
| Формат | JSON, UTF-8, Content-Type: application/json (кроме upload) |
| Условие | канал типа web_chat, активный; арендатор активен |
| CORS | только init (Access-Control-Allow-Origin: *). Остальные маршруты ответов для чужого origin не отдают (ограничения) |
| Идентификатор канала в примерах | 00000000-0000-4000-8000-000000000000 |
| Деньги | ответ ИИ списывается с кошелька арендатора (Обзор) |
| Версионирование | нет. Состав полей может расшириться: игнорируйте неизвестные поля |
Соглашения серверного API (Соглашения: problem+json, каталог кодов, Idempotency-Key, курсоры, operation) на эти маршруты не распространяются: ответы клиентского API — простые объекты {"error": "…"}, а не application/problem+json. Тот же случай исчерпанного суточного предела серверный API сообщает полем ai_reply: {status: "skipped", reason: "daily_limit"}.
#Схема работы
Ваш интерфейс Ячейка
│ POST /init {channel_id} ───────────────► название, цвет, приветствие,
│ ◄───────────────────────────────────────── форма, согласие
│
│ POST /messages {channel_id, content, первое сообщение: сессии ещё нет
│ [session_token]} ────────►
│ ◄── {session_token, response, queued?} ─── сохраните session_token
│
│ GET /events?st=<token> (SSE) ◄────────── ответы оператора, набор текста
│ либо GET /poll?st=<token> каждые 2,5 с
│ GET /history?st=<token> при открытии история разговора
│ POST /upload (multipart) файл → url
#Токен сессии
Токен выдаётся в ответе на первое сообщение (POST /messages) и обновляется в каждом ответе. Сохраните последний.
| Свойство | Значение |
|---|---|
| Формат | w1.<полезная нагрузка base64url>.<подпись HMAC>; не более 1024 знаков |
| Полезная нагрузка | идентификаторы разговора и канала, версия, срок; подписана, но не зашифрована — не помещайте в токен ничего, что вы не готовы показать пользователю |
| Срок | 30 суток от выдачи |
| Что даёт | доступ к своему разговору: history, poll, events, upload и продолжение через messages |
| Где хранить | у кадра виджета — sessionStorage (sapport_session_v2_<channel_id>), то есть одна вкладка. Выбор за вами: localStorage сохранит разговор между вкладками |
| Передача | в теле (session_token) для messages и upload; в query (st) для history, poll, events |
| Негодный токен | messages не считает его ошибкой: заводится новый разговор и новый токен. history, poll, events, upload отвечают 401 |
Токен — это ключ к разговору. Не пишите его в журналы и адреса, которые видят третьи лица. Токен в query (
st) попадает в журнал ваших прокси и сервера.
Идентификатор разговора в ответах не возвращается: он нужен только внутри токена.
#POST /api/widget/init
Статус: Доступно.
Получить настройки канала для отрисовки интерфейса. Единственный маршрут с CORS.
#Запрос
POST /api/widget/init
| Поле | Тип | Обязательный | Ограничения | Пример |
|---|---|---|---|---|
channel_id | строка (UUID) | да | идентификатор канала web_chat | 00000000-0000-4000-8000-000000000000 |
curl -X POST https://formula-cream.pro/api/widget/init \
-H 'Content-Type: application/json' \
-d '{"channel_id":"00000000-0000-4000-8000-000000000000"}'
#Ответ 200
| Поле | Тип | Описание |
|---|---|---|
channel_id | строка | идентификатор канала |
name | строка | название канала |
greeting | строка | приветствие. Если не задано, подставляется приветствие на языке канала или рынка |
color | строка | цвет акцента, #rrggbb; по умолчанию #6366f1 |
avatar_url | строка или null | URL аватара |
pre_chat_form | объект или null | настройка формы перед чатом: поля name, email, phone и признак обязательности |
locale | строка или null | язык канала |
consent | объект, необязательно | есть только если арендатор задал согласие: {text, url, version} |
{
"channel_id": "00000000-0000-4000-8000-000000000000",
"name": "Формула крема",
"greeting": "Здравствуйте! Чем можем помочь?",
"color": "#6366f1",
"avatar_url": null,
"pre_chat_form": null,
"locale": "ru",
"consent": {
"text": "Я согласен с условиями оферты",
"url": "https://formula-cream.pro/offer",
"version": "3a7bd3e2360a3d29"
}
}
Приветствие greeting — локальное: вам нужно показать его самим. Оно не приходит из истории и не хранится как сообщение.
#Ошибки
| Статус | Тело | Когда |
|---|---|---|
400 | {"error":"Missing channel_id"} | нет channel_id |
404 | {"error":"Channel not found or inactive"} | канал не найден, выключен, не web_chat, либо арендатор неактивен |
500 | {"error":"Internal server error"} | сбой сервера |
OPTIONS возвращает 204 с заголовками CORS.
initне проверяет, разрешён ли ваш домен для окна чата (frame-ancestors). Это касается только встроенного кадра; собственному интерфейсу не мешает.
#POST /api/widget/messages
Статус: Доступно.
Отправить сообщение посетителя и получить ответ ИИ. Платная операция.
#Запрос
POST /api/widget/messages, Content-Type: application/json
| Поле | Тип | Обязательный | Ограничения | Пример |
|---|---|---|---|---|
channel_id | строка (UUID) | да | 00000000-0000-4000-8000-000000000000 | |
content | строка | да | не более 4000 знаков | Сколько стоит курс? |
session_token | строка | нет | токен предыдущего ответа. Без него — новый посетитель и новый разговор | w1.eyJ….Zm9v |
sender_name | строка | нет | имя посетителя | Анна |
language | строка | нет | принимается; по перечню расхождений проекта на ответ не влияет | ru |
page_url | строка | нет | до 300 знаков; адрес страницы | https://shop.example.com/courses/hydrolat-basics |
page_title | строка | нет | до 200 знаков; в промпт идёт до 150 | Основы гидролатов |
referrer | строка | нет | до 300 знаков | https://yandex.ru/ |
first_touch | объект | нет | первое касание, до 1200 знаков в JSON (поля) | {"source":"yandex","medium":"cpc"} |
consent_version | строка | нет | версия согласия с офертой; обязателен, если арендатор задал согласие | 3a7bd3e2360a3d29 |
Поля sender_email и sender_phone, которые отправляет кадр виджета, сервер не читает — не рассчитывайте на них (ограничения).
curl -X POST https://formula-cream.pro/api/widget/messages \
-H 'Content-Type: application/json' \
-d '{
"channel_id": "00000000-0000-4000-8000-000000000000",
"content": "Сколько стоит курс?",
"sender_name": "Анна",
"page_url": "https://shop.example.com/courses/hydrolat-basics",
"page_title": "Основы гидролатов",
"consent_version": "3a7bd3e2360a3d29"
}'
#Ответ 200
| Поле | Тип | Описание |
|---|---|---|
session_token | строка | токен сессии; сохраните новый |
response | строка или null | ответ ИИ. null, если ИИ не отвечал (диалог ведёт оператор и т. п.) |
queued | boolean, необязательно | true, если суточный предел ИИ исчерпан (daily_limit): сообщение сохранено, диалог получил состояние handoff_pending и передан оператору, ответа ИИ не будет |
{
"session_token": "w1.eyJjIjoiMDAwMDAwMDAifQ.c2lnbmF0dXJl",
"response": "Курс «Основы гидролатов» стоит 9 900 ₽. Хотите, расскажу, что входит?"
}
{
"session_token": "w1.eyJjIjoiMDAwMDAwMDAifQ.c2lnbmF0dXJl",
"response": null,
"queued": true
}
Форма поля
responseпри отсутствии ответа ИИ (пустая строка илиnull) в коде не зафиксирована: проверяйте оба варианта. Точные причины отсутствия ответа в клиентском API не раскрываются; они есть только в серверном API (Планируется).
#Ошибки
| Статус | Тело | Когда | Что делать |
|---|---|---|---|
400 | {"error":"Missing required fields: channel_id, content"} | нет channel_id или content | Исправить запрос |
400 | {"error":"consent_required","consent":{"text","url","version"}} | арендатор требует согласия, а версия не передана или устарела | Показать текст, получить согласие, повторить с consent_version (ниже) |
404 | {"error":"Channel not found or inactive"} | канал не найден или выключен | Проверить channel_id |
413 | {"error":"message_too_long","max_length":4000} | текст длиннее 4000 знаков | Сократить; вернуть текст в поле ввода |
429 | {"error":"Too many requests"} | превышен предел частоты | Подождать и повторить |
500 | {"error":"Internal server error"} | сбой сервера | Повторить с паузой |
503 | {"error":"widget_not_configured"} | на сервере не настроена подпись сессий | Обратиться в поддержку ячейки |
Повтор после 429, 500, 503 может привести к повторной записи сообщения: идемпотентности у этого маршрута нет. Если сомневаетесь, прочитайте history и повторите отправку, только если сообщения там нет.
#Что происходит на сервере
- Идентичность посетителя определяется только токеном сессии. Без него или с негодным — новый посетитель.
- В метаданные разговора записываются хост сайта, путь (до 200 знаков),
page_key, query (до 200 знаков) иsource: "embedded_widget"(подробности). - Модель получает адрес как хост и путь, без query.
- Атрибуция записывается только для арендаторов; её сбой не отменяет ответ.
- Если разговор ведёт оператор, ИИ не отвечает.
- Суточный предел ИИ:
ai_daily_limitканала (по умолчанию 200, максимум 10 000). После пределаqueued: true.
#Согласие с офертой
Если арендатор задал consent_text, сервер не принимает сообщения без согласия.
Порядок:
initвозвращаетconsent: {text, url, version}. Если ключа нет — согласие не требуется.- Покажите
textи (если есть) ссылкуurlс флажком. Не отправляйте сообщение, пока флажок не отмечен. - Передавайте
consent_version=consent.versionв каждомPOST /messages. Достаточно в первом: согласие записывается в разговор. - Если арендатор изменил текст или ссылку, версия меняется. Сервер ответит
400 consent_requiredс актуальнымconsent: покажите его снова и повторите отправку с новой версией.
Версия — первые 16 шестнадцатеричных знаков sha256(text + "\n" + url). Не вычисляйте её сами: берите готовую из init или из ответа 400.
Запись согласия: kind: "offer", version, accepted_at, text_sha256, url, ip (исходный IP посетителя — ограничение).
{
"error": "consent_required",
"consent": {
"text": "Я согласен с условиями оферты",
"url": "https://formula-cream.pro/offer",
"version": "3a7bd3e2360a3d29"
}
}
#GET /api/widget/history
Статус: Доступно.
История разговора для восстановления окна при перезагрузке страницы.
#Запрос
GET /api/widget/history?st=<session_token>
| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
st | строка | да | токен сессии |
#Ответ 200
{
"messages": [
{ "id": "11111111-1111-4111-8111-111111111111", "sender": "user", "content": "Сколько стоит курс?", "created_at": "2026-10-11T08:15:30Z" },
{ "id": "22222222-2222-4222-8222-222222222222", "sender": "ai", "content": "Курс стоит 9 900 ₽.", "created_at": "2026-10-11T08:15:33Z" }
]
}
| Поле | Тип | Описание |
|---|---|---|
messages[].id | строка | идентификатор сообщения в базе |
messages[].sender | строка | user — посетитель, ai, operator |
messages[].content | строка | текст |
messages[].created_at | строка | время создания |
Возвращается не более 500 сообщений в порядке возрастания времени. Внутренние заметки сотрудников (is_internal) не включаются.
#Ошибки
| Статус | Тело | Когда |
|---|---|---|
401 | {"error":"Session not available"} | нет токена, негодный токен |
404 | {"error":"Session not available"} | канал или разговор не найден либо неактивен |
500 | {"error":"Internal server error"} | сбой сервера или базы |
#GET /api/widget/poll
Статус: Доступно.
Запасной способ получать новые сообщения, если поток событий (SSE) недоступен. Кадр виджета опрашивает каждые 2,5 секунды. Без after каждый ответ содержит все сообщения ИИ и оператора разговора, поэтому на стороне клиента ведите учёт показанных id или передавайте after со временем последнего сообщения.
#Запрос
GET /api/widget/poll?st=<session_token>[&after=<время>]
| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
st | строка | да | токен сессии |
after | строка (время ISO 8601) | нет | отфильтровывает сообщения с created_at не позже этого значения. Кадр виджета параметр не передаёт (ограничения) |
#Ответ 200
{
"new_messages": [
{
"id": "33333333-3333-4333-8333-333333333333",
"sender": "operator",
"content": "Здравствуйте! Меня зовут Анна, расскажу подробнее.",
"created_at": "2026-10-11T08:17:02Z",
"operator_name": "Анна",
"operator_avatar": null
}
],
"operator": { "name": "Анна", "avatar": null },
"typing": false,
"typing_name": ""
}
| Поле | Тип | Описание |
|---|---|---|
new_messages[] | массив | сообщения ИИ и оператора |
new_messages[].sender | строка | operator или ai |
new_messages[].operator_name, operator_avatar | строка, необязательно | имя и аватар оператора |
operator | объект или null | назначенный оператор |
typing | boolean | печатает ли оператор. Признак активен 5 секунд после последнего нажатия |
typing_name | строка | имя печатающего |
Ошибки — как у history.
#GET /api/widget/events (SSE)
Статус: Доступно.
Поток серверных событий (Server-Sent Events): ответы оператора и ИИ приходят без опроса.
#Запрос
GET /api/widget/events?st=<session_token> — через EventSource.
Заголовки ответа: Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, X-Accel-Buffering: no. Первая строка потока — : connected.
#События
| Событие | Данные | Когда |
|---|---|---|
message | {id, sender, content, created_at} (sender: operator или ai) | в разговоре появилось сообщение не от посетителя (внутренние пропускаются) |
conversation:update | {status, assigned_operator_id} | изменился статус разговора или назначенный оператор |
typing | {active, name} | оператор печатает (active=true в течение 5 с) |
status | {status} | состояние подписки (например, SUBSCRIBED) |
heartbeat | {ts} | каждые 25 секунд |
closed | {reason: "session_unavailable"} | доступ к разговору закрыт (канал выключен, арендатор отключён, сессия недействительна). Поток закрывается |
: connected
event: status
data: {"status":"SUBSCRIBED"}
event: message
data: {"id":"33333333-3333-4333-8333-333333333333","sender":"operator","content":"Здравствуйте!","created_at":"2026-10-11T08:17:02Z"}
event: heartbeat
data: {"ts":1760170000000}
Свойства:
- Доступ перепроверяется раз в 60 секунд; при отказе приходит
closed. - Ответ ИИ на ваше сообщение, по коду, может прийти дважды: в теле
POST /messages(полеresponse) и событиемmessageс идентификатором из базы (в базу ответ записывается, а поток отдаёт любые сообщения не от посетителя). На живых данных не проверялось. Защититесь: сравнивайтеsenderиcontentс недавними сообщениями и хранитеidпоказанных. - Сообщения самого посетителя в поток не попадают.
- Автоматическое переподключение
EventSourceработает; после обрыва прочитайтеhistory, чтобы не потерять сообщения. - Ошибки до начала потока:
401,404,500с текстовым теломSession not available.
#POST /api/widget/upload
Статус: Доступно. Работает с ограничением: загруженный файл не создаёт сообщение (ограничения).
Загрузить файл и получить публичный адрес.
#Запрос
POST /api/widget/upload, Content-Type: multipart/form-data
| Поле формы | Тип | Обязательный | Ограничения | Пример |
|---|---|---|---|---|
file | файл | да | не более 10 МБ | scheme.pdf |
channel_id | строка | да | 00000000-0000-4000-8000-000000000000 | |
session_token | строка | да | токен действующей сессии | w1.… |
Допустимые типы: изображения JPEG, PNG, GIF, WebP; PDF; DOC, DOCX; XLS, XLSX; TXT, CSV, MD. Проверяются расширение, MIME-тип и сигнатура файла; опасные расширения блокируются.
curl -X POST https://formula-cream.pro/api/widget/upload \
-F '[email protected]' \
-F 'channel_id=00000000-0000-4000-8000-000000000000' \
-F 'session_token=w1.…'
#Ответ 200
{
"url": "https://formula-cream.pro/storage/…/scheme.pdf",
"filename": "scheme.pdf",
"size": 84213,
"content_type": "application/pdf"
}
Адрес url публичный: любой, кто его знает, откроет файл.
#Ошибки
| Статус | Тело | Когда |
|---|---|---|
400 | {"error":"file and channel_id required"} | нет файла или канала |
401 | {"error":"Session not available"} | нет токена или он негоден (без пояснений) |
400 / 413 | {"error":"<причина>"} | файл не прошёл проверку типа, размера или сигнатуры: текст причины возвращает проверка файла |
404 | {"error":"Session not available"} | канал или разговор не найден |
413 | {"error":"File too large (max 10MB)"} | файл больше 10 МБ |
429 | {"error":"Too many requests"} | более 5 загрузок в минуту с IP или 30 в минуту на канал |
500 | {"error":"Upload failed"} | сбой загрузки |
Проверка частоты по IP выполняется до разбора тела.
Чтобы оператор увидел файл, отправьте сообщение с адресом url в тексте (POST /messages, content: «Файл: https://…»).
#Пределы
| Что | Предел | Область | Хранение счётчика |
|---|---|---|---|
| Длина сообщения | 4000 знаков | сообщение | — |
| Частота сообщений | 20 в минуту | канал + IP | память процесса |
| Частота сообщений | 240 в минуту | канал | память процесса |
| Суточный предел ИИ | 200 по умолчанию, максимум 10 000 (ai_daily_limit) | канал арендатора | база |
| Размер файла | 10 МБ | файл | — |
| Загрузка файлов | 5 в минуту | IP | память процесса |
| Загрузка файлов | 30 в минуту | канал | память процесса |
| История | 500 сообщений | запрос history | — |
| Срок токена | 30 суток | токен | — |
| Размер токена | 1024 знака | токен | — |
Счётчики в памяти процесса не общие для нескольких процессов и сбрасываются при перезапуске. Заголовков Retry-After и остатка предела у клиентских маршрутов нет: при 429 ждите минуту.
#Сводка ошибок
| Код | Маршруты | Смысл |
|---|---|---|
400 | init, messages, upload | неверный запрос; consent_required |
401 | history, poll, events, upload | нет или негодный токен |
404 | все | канал, разговор или сессия не найдены или неактивны |
413 | messages, upload | слишком длинное сообщение или большой файл |
429 | messages, upload | предел частоты |
500 | все | сбой сервера |
503 | messages | подпись сессий не настроена |
Тело ошибки — {"error": "<код или текст>"}; для events — текст. Ветвите логику по статусу и по строке error там, где она описана выше; тексты Session not available и Too many requests не предназначены для разбора.
#Пример: чат на чистом JavaScript
Рабочий файл —
examples/custom-chat-vanilla.html, с настраиваемымAPI_BASE(см. CORS). Ниже — основные фрагменты.
const API_BASE = 'https://formula-cream.pro'; // ваша ячейка
const CHANNEL_ID = '00000000-0000-4000-8000-000000000000';
const TOKEN_KEY = 'my_chat_token';
let token = localStorage.getItem(TOKEN_KEY) || '';
let consentVersion = '';
const shown = new Set(); // id уже показанных сообщений
async function api(path, options) {
const res = await fetch(API_BASE + path, options);
let body = null;
try { body = await res.json(); } catch { /* не JSON */ }
return { ok: res.ok, status: res.status, body };
}
// 1. Настройки канала и согласие
async function init() {
const { ok, body } = await api('/api/widget/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: CHANNEL_ID })
});
if (!ok) throw new Error('Канал недоступен');
addMessage('ai', body.greeting); // приветствие показываем сами
if (body.consent) consentVersion = body.consent.version;
return body;
}
// 2. Отправка сообщения
async function send(text) {
const payload = {
channel_id: CHANNEL_ID,
content: text,
session_token: token || undefined,
page_url: location.href,
page_title: document.title,
consent_version: consentVersion || undefined
};
const { ok, status, body } = await api('/api/widget/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (status === 400 && body && body.error === 'consent_required') {
consentVersion = body.consent.version; // показать согласие и повторить
throw new Error('consent_required');
}
if (status === 413) throw new Error('Сообщение слишком длинное');
if (status === 429) throw new Error('Слишком часто, подождите минуту');
if (!ok) throw new Error('Не удалось отправить');
token = body.session_token;
localStorage.setItem(TOKEN_KEY, token);
if (body.queued) addMessage('system', 'Передано оператору, он ответит позже');
else if (body.response) addMessage('ai', body.response);
}
// 3. Подписка на ответы оператора: SSE с запасным опросом
function listen() {
if (!token) return;
const es = new EventSource(API_BASE + '/api/widget/events?st=' + encodeURIComponent(token));
es.addEventListener('message', (e) => {
const m = JSON.parse(e.data);
addMessage(m.sender, m.content, m.id);
});
es.addEventListener('closed', () => es.close());
es.onerror = () => { es.close(); setTimeout(poll, 2500); };
}
async function poll() {
if (!token) return;
const { ok, body } = await api('/api/widget/poll?st=' + encodeURIComponent(token));
if (ok) body.new_messages.forEach((m) => addMessage(m.sender, m.content, m.id));
setTimeout(poll, 2500);
}
// 4. Показ без дублей
function addMessage(sender, content, id) {
if (id) {
if (shown.has(id)) return;
shown.add(id);
}
// ответ ИИ из POST и тот же ответ из SSE: сравнить по sender+content с последним сообщением
const last = document.querySelector('#log > :last-child');
if (last && last.dataset.sender === sender && last.textContent === content) return;
const div = document.createElement('div');
div.dataset.sender = sender;
div.textContent = content; // не innerHTML: текст чужой
document.querySelector('#log').appendChild(div);
}
Важное:
- Показывайте текст через
textContent, а неinnerHTML: ответы приходят от модели и оператора. - Токен сохраняйте после каждого ответа.
- После перезагрузки страницы восстановите окно из
history, затем подключайте поток.
#Пример: компонент React
Рабочий файл —
examples/custom-chat-react.jsx.
import { useEffect, useRef, useState } from 'react';
const API_BASE = 'https://formula-cream.pro';
const CHANNEL_ID = '00000000-0000-4000-8000-000000000000';
export function ChatBox() {
const [messages, setMessages] = useState([]);
const [text, setText] = useState('');
const [busy, setBusy] = useState(false);
const token = useRef(localStorage.getItem('my_chat_token') || '');
const consent = useRef('');
const add = (m) =>
setMessages((list) => {
if (m.id && list.some((x) => x.id === m.id)) return list;
const last = list[list.length - 1];
if (last && last.sender === m.sender && last.content === m.content) return list;
return [...list, m];
});
useEffect(() => {
let es;
(async () => {
const init = await fetch(`${API_BASE}/api/widget/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: CHANNEL_ID })
}).then((r) => r.json());
if (init.consent) consent.current = init.consent.version;
add({ sender: 'ai', content: init.greeting });
if (token.current) {
es = new EventSource(`${API_BASE}/api/widget/events?st=${encodeURIComponent(token.current)}`);
es.addEventListener('message', (e) => add(JSON.parse(e.data)));
}
})();
return () => es && es.close();
}, []);
async function submit(e) {
e.preventDefault();
if (!text.trim() || busy) return;
setBusy(true);
add({ sender: 'user', content: text });
const res = await fetch(`${API_BASE}/api/widget/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channel_id: CHANNEL_ID,
content: text,
session_token: token.current || undefined,
consent_version: consent.current || undefined
})
});
const body = await res.json().catch(() => ({}));
setBusy(false);
if (!res.ok) return add({ sender: 'system', content: `Ошибка ${res.status}` });
token.current = body.session_token;
localStorage.setItem('my_chat_token', token.current);
if (body.response) add({ sender: 'ai', content: body.response });
setText('');
}
return (
<form onSubmit={submit}>
<ul>{messages.map((m, i) => <li key={m.id || i}>{m.content}</li>)}</ul>
<input value={text} onChange={(e) => setText(e.target.value)} maxLength={4000} />
<button disabled={busy}>Отправить</button>
</form>
);
}
#Известные ограничения
Формулировки нейтральны: по коду на 11.10.2026.
| № | Ограничение | Последствия и обходы |
|---|---|---|
| — | CORS: заголовков нет у messages, history, poll, events, upload. CORS (*) и OPTIONS есть только у init | Запрос с другого origin не пройдёт (для application/json браузер сначала отправит предварительный запрос OPTIONS, который этот маршрут не обслужит), а ответ не будет доступен коду страницы. Работает: (а) страница на домене самой ячейки; (б) ваш серверный прокси (запросы браузер→ваш сервер→ячейка), с пробросом реального IP посетителя и отключённой буферизацией для SSE; (в) мобильное приложение (CORS браузера нет). Разрешение CORS на стороне платформы — Планируется |
| 1 | Форма перед чатом: sender_email и sender_phone отправляются кадром, но сервер их не читает | Поле sender_name работает. Собирайте контакты текстом сообщения или своей формой |
| 2 | Загрузка файла не создаёт сообщение; оператор файла не видит. До первого сообщения (нет токена) — 401 без пояснений; атрибут accept поля в кадре шире списка сервера | Загрузите файл, затем отправьте сообщение с url. Загружайте только после первого сообщения |
| 13 | Кадр виджета не передаёт after в poll (сервер фильтр поддерживает); language в messages на ответ не влияет; атрибут data-consent в widget.js не читается | В своём клиенте передавайте after или дедуплицируйте по id. Язык ответа определяется настройками канала |
Дополнительно, не из перечня:
- Возможное дублирование ответа ИИ (тело
POST /messagesи SSE). Дедуплицируйте. - Нет идемпотентности у
POST /messages: повтор после сетевого сбоя может создать второе сообщение. - Нет версионирования. Контракт может измениться; для устойчивой интеграции используйте серверный API, когда он выйдет.
#Заметки
- Если вы строите интерфейс на домене ячейки (например, своя страница в ячейке), CORS не требуется.
- Для SSE за вашим прокси отключите буферизацию (
proxy_buffering off) и сохранитеX-Accel-Buffering: no. - Хостинговые прокси часто режут соединения SSE через 30–60 секунд: полагайтесь на запасной опрос.
- Файл
urlпубличен — не загружайте ничего секретного. - Журнал изменений: 90-changelog.
#Открытые вопросы
- Будут ли добавлены заголовки CORS ко всем клиентским маршрутам или выпущен версионируемый контракт (
/api/public/v1/chat/*): в проекте описан лишь раздел «Клиентский Chat API», сроков нет. - Форма поля
response, когда ИИ не отвечал (nullили пустая строка), и причины отсутствия ответа в клиентском API в коде не зафиксированы. Техническое. - Структура
pre_chat_form(имена полей, обязательность) описана здесь по поведению кадра; точная схема в проекте не опубликована. - Работоспособность SSE (Realtime) на каждой из трёх ячеек в рамках этой документации не проверялась.