Веб-встраивание (Embed Channel)
Канал встраивания служит для размещения виджета вопросов по базе знаний на официальном сайте или в справочном центре. После создания канала и привязки агента добавьте сгенерированный скрипт на страницу — посетители смогут общаться без учётной записи aihelpme.ru. Область поиска и модель для посетителя полностью определяются агентом, привязанным к каналу: сервер отбрасывает базы знаний, документы, теги, @упоминания, навыки и переопределения модели из запроса.
Создайте канал в разделе «Настройки → Веб-встраивание», привяжите агента и задайте разрешённые домены, затем скопируйте код подключения. Перед публичным использованием настройте белый список доменов и ограничение частоты, чтобы ограничить источники доступа и объём запросов.
Канал поддерживает подключение со статическим token и в безопасном режиме, а также приём событий диалога через Webhook.
Изображения на стороне посетителя проходят через прокси авторизации уровня канала, а не основного сайта; если изображения не отображаются, см. Доступ к изображениям и файлам извне.
Подключение фронтенд-виджета
SDK виджета — это не имеющий зависимостей скрипт-загрузчик frontend/public/weknora-widget.js (после развёртывания раздаётся из корневого пути сервиса), который отрисовывает плавающую кнопку + панель iframe; iframe указывает на SPA страницы встраивания /embed/{channel_id} (точка входа frontend/src/embed-main.ts).
Способ 1: статический режим Token (token виден посетителю)
<script
src="https://your-weknora.example.com/weknora-widget.js"
data-channel="UUID_вашего_канала"
data-token="em_ваш_publish_token"
data-position="bottom-right"
data-primary-color="#07C05F"
data-title="AI Assistant"
></script>publish token записывается прямо в HTML страницы и виден любому посетителю; при ротации token нужно синхронно обновить все страницы развёртывания. Подходит для внутренних сайтов или сценариев с низкой чувствительностью.
Способ 2: безопасный режим (Secure Mode, рекомендуется)
publish token хранится только в бизнес-бэкенде, а страница через data-token-endpoint указывает на эндпоинт обмена этого бэкенда:
<script
src="https://your-weknora.example.com/weknora-widget.js"
data-channel="UUID_вашего_канала"
data-token-endpoint="https://your-backend.example.com/weknora/embed-token"
data-position="bottom-right"
></script>Бизнес-бэкенд реализует этот endpoint: сервер хранит token em_, вызывает POST /api/v1/embed/{channel_id}/exchange, чтобы получить короткоживущий token ems_, и возвращает { "token": "ems_...", "expiresIn": 1800 }. Виджет автоматически обновляет token примерно при 80% TTL (не раньше чем через 30 секунд) (см. scheduleRefresh в weknora-widget.js). publish token никогда не попадает в браузер.
Эндпоинт обмена должен сначала проверить действительность бизнес-сессии/JWT и право гостя на доступ; одной лишь проверки наличия Cookie или заголовка Authorization недостаточно для аутентификации. При вызове exchange на сервере нужно вручную отправлять бизнес-хост Origin, совпадающий с белым списком канала, например Origin: https://shop.example.com; не раскрывайте административный Token платформы посетителям.
Прочие необязательные атрибуты: data-base-url (по умолчанию выводится из src скрипта), data-width / data-height (размеры панели, по умолчанию 400×600), data-sandbox (политика sandbox для iframe; при кросс-доменном встраивании автоматически добавляется allow-scripts allow-forms allow-popups allow-modals allow-same-origin).
Способ 3: программный API
<script src="https://your-weknora.example.com/weknora-widget.js"></script>
<script>
WeKnora.init({
channel: 'UUID_канала',
tokenEndpoint: 'https://your-backend.example.com/weknora/embed-token', // или token: 'em_...'
position: 'bottom-right',
primaryColor: '#07C05F',
title: 'AI Assistant',
baseUrl: 'https://your-weknora.example.com',
});
WeKnora.setContext({ userId: 'u_123', page: location.pathname }); // контекст подставляется при каждом вопросе
WeKnora.setLocale('en-US');
WeKnora.openWithQuery('Как сбросить пароль?'); // открыть панель и автоматически отправить вопрос
WeKnora.on('ready', () => console.log('widget ready'));
// прочее: WeKnora.open() / close() / toggle() / destroy() / off(event, fn)
</script>Прямое подключение через iframe
Можно обойтись без загрузчика и встроить iframe напрямую (в этом случае token нужно передать через URL/postMessage; обычно рекомендуется использовать загрузчик):
<iframe src="https://your-weknora.example.com/embed/UUID_канала"
width="400" height="600" style="border:none"></iframe>Протокол postMessage Bridge
Хост-страница (загрузчик) и страница встраивания внутри iframe общаются через postMessage, обе стороны строго проверяют Origin (загрузчик отправляет сообщения только в выведенный embedOrigin и никогда не использует *; страница встраивания фиксирует origin по первому доверенному сообщению — см. frontend/src/composables/useEmbedBridge.ts):
- хост → iframe (
source: "weknora-host"):provide_token(передача token),set_context,set_locale,open_with_query; - iframe → хост (
source: "weknora-embed"):ready,bootstrap_request(запрос token),message_sent,message_received.
Авторизация и анонимные сессии
Два вида Token
| Token | Префикс | Срок жизни | Назначение |
|---|---|---|---|
| Publish Token | em_ | длительный (до ротации) | Токен публикации канала; можно встроить прямо в страницу (статический режим) или хранить только в бизнес-бэкенде (безопасный режим) |
| Session Token | ems_ | 30 минут (Redis TTL) | Короткоживущий токен, получаемый из publish token через /exchange, используется на стороне браузера |
Все публичные интерфейсы передают токен через заголовок Authorization: Embed <token> (query string не принимается). Промежуточное ПО EmbedAuth (internal/middleware/embed_auth.go) выполняет по порядку:
- Находит канал по
channel_id, проверяет совпадение token сpublish_tokenлибо находит в Redis (ключembed:session:{token}) session token, принадлежащий этому каналу; - Проверяет
enabledканала; - Разрешает same-origin API-запросы внутри iframe; кросс-доменные API-запросы и
Originсерверного exchange в безопасном режиме должны попадать вallowed_origins. Пустой белый список по-прежнему отклоняет всё; ограничения хоста обеспечивает CSP встроенного HTML; - Ограничивает частоту (Lua-скрипт Redis, скользящее окно):
- один IP не более
RateLimitPerMinuteв минуту; - весь канал не более
max(RateLimitPerMinute × 20, 120)в минуту — защита от атакующего, обходящего лимит на один IP сменой IP; - суммарно по каналу за день не более
RateLimitPerDay.
Источник хоста и развёртывание
Когда сайт A встраивает экземпляр с сайта B, в белый список заносится A. Стандартный Nginx получает политику канала через /api/v1/embed-frame-policy (без token, возвращает только CSP, без конфигурации канала) и устанавливает frame-ancestors в HTML-ответе /embed/:channelId; Lite использует ту же политику. Эта страница не кешируется, а при неудачном получении политики встроенный HTML не возвращается.
При обновлении каналы, где раньше указывался только B, нужно перевести на фактический хост A и обновить одновременно фронтенд и бэкенд. Пользовательский обратный прокси должен сохранять CSP, исходный Host (с портом), протокол и Sec-Fetch-Site. Белый список ограничивает встраивание в браузере, но не заменяет аутентификацию посетителя и не блокирует не-браузерных клиентов с token; для такого контроля доступа используйте безопасный режим и ограничение частоты.
Отдельный поддомен (необязательно)
По умолчанию достаточно общий домен для страницы embed и панели управления. Когда нужен отдельный вход или изоляция Cookie, можно разместить страницу embed на https://embed.example.com, оставив панель управления на https://app.example.com, а бизнес-хостом сделать https://shop.example.com.
Панель управления задаёт origin embed через window.__RUNTIME_CONFIG__.EMBED_BASE_URL в frontend/public/config.js или через переменную времени сборки VITE_EMBED_BASE_URL; если оставить пустым, используется origin текущей страницы. После изменения убедитесь, что сгенерированный код Widget/iframe указывает на новый адрес.
Отдельный server Nginx раздаёт только /embed/*, /weknora-widget.js, /assets/* и необходимые прокси до бэкенда, не отдавая index.html панели управления. Сохраните в стандартном frontend/nginx.conf для /embed/ директиву auth_request, внутренний /_embed-frame-policy и заголовки ответа CSP: при неудачном получении политики страницу возвращать нельзя, а шлюз/CDN не должен кешировать или отбрасывать эту политику.
В белый список по-прежнему заносится фактический бизнес-хост https://shop.example.com, а exchange в безопасном режиме объявляет тот же Origin; origin embed не нужно дополнительно добавлять для обычных same-origin запросов чата. Widget автоматически добавляет sandbox для iframe, когда хост и embed находятся на разных origin. При проверке открывайте страницу с фактического хоста и смотрите на iframe, API, CSP и сгенерированный код — недостаточно проверить только в режиме предпросмотра панели управления.
Обмен Token (ядро безопасного режима)
POST /api/v1/embed/:channel_id/exchange, заголовок Authorization: Embed em_xxx (принимается только publish token, session token отклоняется). Ответ:
{ "success": true, "data": { "session_token": "ems_...", "expires_in": 1800 } }Реализация — IssueSessionToken в internal/application/service/embed_session.go: 32 случайных байта base64 с префиксом ems_, запись в Redis, TTL 30 минут.
Создание анонимной сессии
POST /api/v1/embed/:channel_id/sessions создаёт сессию чата и возвращает:
{ "success": true, "data": { "id": "<session_uuid>", "sig": "<HMAC-SHA256 base64>" } }- Сессия записывается в таблицу
sessions,Descriptionпомечается какembed_channel:{channel_id}, аUserIDиспользует непрозрачный идентификатор посетителя, сгенерированныйEmbedSessionPrincipal(tenantID, channelID, sessionID).StorageID(); sig— это подпись сессии:HMAC-SHA256(channel.PublishToken, "{channel_id}|{session_id}"). В дальнейшем каждое обращение к/sessions/:session_id/*должно нести заголовокX-Embed-Session: <sig>, а сервер выполняет сравнение за константное время (internal/handler/embed_channel.go). Это защищает от выдачи чужой сессии только по session_id; после ротации publish token все подписи одновременно становятся недействительными.
Фронтенд также может передавать X-Embed-Visitor: <uuid> для статистики на уровне посетителя. id сессии и sig кешируются по каналу в localStorage, поэтому после обновления страницы сессия сразу восстанавливается (frontend/src/composables/useEmbedBridge.ts).
Обратные вызовы Webhook
Каналы с настроенным webhook_url при следующих событиях отправляют JSON методом POST в бизнес-бэкенд (internal/application/service/embed_webhook.go):
| Событие | Момент срабатывания | Поля нагрузки |
|---|---|---|
message_sent | Посетитель задал вопрос | type, channel_id, session_id, timestamp, query |
message_received | Ответ ассистента завершён | type, channel_id, session_id, timestamp, content |
Семантика безопасности и доставки:
- при настроенном
webhook_secretдобавляется заголовок подписиX-WeKnora-Signature: sha256=<hex(HMAC-SHA256(secret, raw_body))>; - URL должен быть HTTPS, исходящий запрос идёт через SSRF-безопасный клиент (при каждом редиректе проверка заново, не более 5 переходов), таймаут 5 секунд, User-Agent —
WeKnora-Embed-Webhook/1.0; - асинхронная доставка best-effort: при сбое только запись в лог, без повторов;
- фронтенд также может явно пересылать события через
POST /api/v1/embed/:channel_id/sessions/:session_id/events.
Справочник по конфигурации и интерфейсам
Модель данных
EmbedChannel в internal/types/embed_channel.go — полное определение канала (таблица embed_channels, мягкое удаление, частично уникальный индекс по publish_token):
type EmbedChannel struct {
ID string // первичный ключ UUID
TenantID uint64 // тенант-владелец
AgentID string // привязанный Agent (по умолчанию builtin-quick-answer)
Name string // имя канала
Enabled bool // включён ли
PublishToken string // долгоживущий токен публикации, префикс "em_"
AllowedOrigins JSON // список разрешённых Origin (JSONB)
WelcomeMessage string // приветствие
RateLimitPerMinute int // лимит на один IP в минуту (по умолчанию 30)
RateLimitPerDay int // суточный лимит канала (по умолчанию 10000)
PrimaryColor string // цвет темы
PageTitle string // заголовок страницы
HeaderTitleMode string // "channel" | "session"
ShowSuggestedQuestions bool // показывать предлагаемые вопросы
WidgetPosition string // положение виджета
AllowWebSearch bool // разрешить веб-поиск
AllowFileUpload bool // разрешить загрузку файлов/изображений
DefaultLocale string // язык по умолчанию
WebhookURL string // исходящий webhook (HTTPS)
WebhookSecret string // ключ подписи HMAC-SHA256
...
}Параметры конфигурации канала
При создании/обновлении канала (embedChannelRequest в internal/handler/embed_channel.go) можно настроить:
| Параметр | Тип | Значение по умолчанию | Описание |
|---|---|---|---|
name | string | — | Отображаемое имя канала |
enabled | bool | true | Переключатель канала; после выключения все публичные интерфейсы отклоняют доступ |
agent_id | string | builtin-quick-answer | Привязанный Agent, определяет область базы знаний и возможности диалога |
allowed_origins | string[] | — | Обязательно указать хотя бы один элемент — хост-сайт встраивания A, а не адрес B. Поддерживаются три формы: полный http(s):// Origin (без пути и query-параметров), подстановка поддомена *.example.com (совпадает только с поддоменами, не с самим example.com; при отсутствии порта совпадает с любым портом), полная подстановка * (при GIN_MODE=release отклоняется при сохранении, только для разработки) |
welcome_message | string | пусто | Приветствие при открытии виджета |
rate_limit_per_minute | int | 30 | Лимит запросов на один IP в минуту |
rate_limit_per_day | int | 10000 | Суточный лимит запросов канала |
primary_color | string | — | Цвет темы виджета (значение цвета CSS, например #0052d9) |
page_title | string | пусто | Заголовок браузера страницы встраивания |
header_title_mode | string | channel | Режим заголовка: channel (фиксированное имя канала) / session (генерируется автоматически по сессии) |
show_suggested_questions | bool | true | Показывать ли предлагаемые вопросы |
widget_position | string | bottom-right | bottom-right | bottom-left | top-right | top-left |
allow_web_search | bool | false | Разрешён ли веб-поиск на стороне посетителя |
allow_file_upload | bool | false | Разрешена ли загрузка изображений/файлов на стороне посетителя |
default_locale | string | пусто (следует за браузером) | zh-CN | en-US | ko-KR | ja-JP | ru-RU |
webhook_url | string | пусто | Адрес обратного вызова событий, обязательно HTTPS и проходит проверку SSRF (внутренние/link-local адреса запрещены) |
webhook_secret | string | пусто | Ключ подписи webhook (никогда не возвращается в ответе API) |
Когда изображения включены и в канале, и в привязанном Агенте, посетитель может вставить скриншот в поле ввода чата, отправить его после предпросмотра или удалить из предпросмотра. Вставка и выбор файла используют общие ограничения: JPEG, PNG, GIF, WebP, не более 10 МБ на изображение и не более 5 изображений за раз. Вставка обычного текста сохраняет прежнее поведение; если в буфере обмена одновременно есть изображение и текст, добавляется только изображение — как и в основном чате.
API управления (требуется аутентификация)
Регистрируется через RegisterEmbedChannelRoutes (internal/router/routes_agent.go), поддерживает возможность API Key ManageChannels:
| Метод | Путь | Права | Описание |
|---|---|---|---|
| POST | /api/v1/agents/:id/embed-channels | Admin | Создать канал для Agent |
| GET | /api/v1/agents/:id/embed-channels | Viewer | Список каналов некоторого Agent |
| GET | /api/v1/embed-channels | Viewer | Список всех каналов тенанта |
| GET | /api/v1/embed-channels/:channel_id | Viewer | Детали канала (включая publish_token) |
| PUT | /api/v1/embed-channels/:channel_id | Admin | Обновить конфигурацию канала |
| DELETE | /api/v1/embed-channels/:channel_id | Admin | Удалить канал (мягкое удаление) |
| POST | /api/v1/embed-channels/:channel_id/rotate-token | Admin | Ротация publish_token (старый token и все выданные подписи сессий сразу становятся недействительными) |
| POST | /api/v1/embed-channels/:channel_id/preview-session | Viewer | Выдать короткоживущий session token для предпросмотра (предпросмотр виджета в консоли) |
| GET | /api/v1/embed-channels/:channel_id/stats | Viewer | Статистика сессий канала |
Публичный API (анонимный доступ, авторизация Embed)
Регистрируется через RegisterEmbedPublicRoutes под префиксом /api/v1/embed/:channel_id, все проходят через промежуточное ПО middleware.EmbedAuth (проверка token + проверка Origin + ограничение частоты):
embed := r.Group("/api/v1/embed/:channel_id", middleware.EmbedAuth(embedService, tenantService, redisClient))
{
embed.POST("/exchange", embedHandler.ExchangeEmbedSession)
embed.GET("/config", embedHandler.GetEmbedConfig)
embed.GET("/suggested-questions", embedHandler.GetEmbedSuggestedQuestions)
embed.GET("/chunks/:chunk_id", embedHandler.GetEmbedChunk)
embed.POST("/sessions", embedHandler.CreateEmbedSession)
embed.POST("/knowledge-chat/:session_id", embedHandler.EmbedKnowledgeChat)
embed.POST("/agent-chat/:session_id", embedHandler.EmbedAgentChat)
embed.GET("/messages/:session_id/load", embedHandler.EmbedLoadMessages)
embed.POST("/sessions/:session_id/stop", embedHandler.EmbedStopSession)
embed.POST("/sessions/:session_id/events", embedHandler.EmbedRelayWebhookEvent)
// маршруты для предлагаемых вопросов к сообщениям, MCP OAuth, подтверждения инструментов, файлового сервиса и т. п. опущены
embed.GET("/files", newFileServeHandler(...))
}Выдача публичной конфигурации
GET /api/v1/embed/:channel_id/config возвращает EmbedChannelPublicConfig (internal/types/embed_channel.go) — только информацию об отображении и возможностях, нужную для отрисовки виджета:
- Выдаётся:
channel_id,name,display_title(сервер разбирает по порядкуPageTitle → Name → AgentName → "AI Assistant"),agent_id/agent_name/agent_avatar,knowledge_base_ids,welcome_message,primary_color,header_title_mode,show_suggested_questions,widget_position,allow_web_search,allow_file_upload,agent_web_search_enabled,agent_image_upload_enabled,default_localeи т. д.; - Никогда не выдаётся:
publish_token,webhook_url,webhook_secret.
Сквозная последовательность
Краткая сводка по безопасности
- Белый список хостов: встроенный HTML задаёт канальный
CSP frame-ancestors, ограничивая все родительские страницы; same-origin API внутри iframe пропускается как обычно. Пустойallowed_originsотклоняет все запросы;*доступен только в режиме разработки; поддерживается подстановка поддомена*.example.com. Same-origin предпросмотр в консоли управления разрешён. - Двухтокенная схема: в безопасном режиме publish token не покидает сервер, браузер держит только короткоживущий 30-минутный token
ems_. - Подпись сессии: HMAC-подпись
X-Embed-Sessionпривязывает сессию к тройке (канал, сессия, текущий publish token); ротация token позволяет отозвать всё сразу. - Трёхуровневое ограничение частоты: на IP/минуту, на канал/минуту (20× на IP, минимум 120), на канал/день, атомарно реализовано на Lua в Redis.
- Защита Webhook от SSRF: только HTTPS, внутренние адреса отклоняются, редиректы проверяются пошагово, таймаут 5 секунд.
Справочник по реализации
Все пути ниже указаны относительно корня репозитория:
| Слой | Файл |
|---|---|
| Структуры данных | internal/types/embed_channel.go |
| HTTP Handler | internal/handler/embed_channel.go |
| Сервис каналов | internal/application/service/embed_channel.go |
| Анонимные сессии/Token | internal/application/service/embed_session.go |
| Рассылка Webhook | internal/application/service/embed_webhook.go |
| Промежуточное ПО авторизации | internal/middleware/embed_auth.go |
| Регистрация маршрутов | internal/router/routes_agent.go (RegisterEmbedPublicRoutes / RegisterEmbedChannelRoutes / политика CSP страницы встраивания) |
| Правила источника хоста | internal/embedpolicy/origin.go |
| Загрузчик виджета (SDK) | frontend/public/weknora-widget.js |
| Точка входа SPA страницы встраивания | frontend/src/embed-main.ts, frontend/src/composables/useEmbedBridge.ts, useEmbedChatSession.ts |
| Миграции БД | migrations/versioned/000060_embed_channels.up.sql |
