К документации
Руководство 06

Интеграция виджета

Встраиваемый виджет - это отдельный сервис (packages/widget, порт 3211), который публикует AI-чат на твоём сайте. Архитектура: Express + Preact IIFE в sandboxed iframe, прозрачный SSE-прокси к серверу, анонимные сессии, lead capture, branding для каждого…

4 мин чтения

Встраиваемый виджет - это отдельный сервис (packages/widget, порт 3211), который публикует AI-чат на твоём сайте. Архитектура: Express + Preact IIFE в sandboxed iframe, прозрачный SSE-прокси к серверу, анонимные сессии, lead capture, branding для каждого сайта.

Виджет - это Enterprise-функция (license widget_enabled); в Community по умолчанию 1 виджет, но для создания требуется активный флаг.

Как встроить (quick start)

  1. Создай виджет (admin UI → Settings → Widgets, или API):
http
POST /api/widgets
Authorization: Bearer <admin JWT>
{ "name": "Sito Azienda", "allowedOrigins": ["https://www.azienda.it"],
  "workspaceIds": ["<uuid>"], "primaryColor": "#4c6ef5", "botName": "Assistente" }

Ответ: { widget } с widget.id.

  1. Whitelist базы знаний - PUT /api/widgets/:id/workspaces с UUID тех workspace, чей RAG будет доступен виджету (таблица WidgetWorkspace, составной PK [widgetId, workspaceId]).
  1. Вставь snippet в host-страницу:
html
<div id="simmetric-widget" data-widget-id="<widget.id>" data-position="bottom-right"></div>
<script
  src="https://widget.tuodominio.it/widget/<widget.id>.js"
  integrity="sha384-<HASH>"
  crossorigin="anonymous"
  async
></script>
Безопасность: если отдаёшь loader с стороннего CDN, сгенерируй SHA-384 hash бандла и добавь integrity + crossorigin="anonymous" (Subresource Integrity). Если loader same-origin с твоим widget service, SRI опционален, но рекомендован для защиты от компрометации CDN/asset.

Loader (packages/widget/src/routes/loader.ts) читает атрибуты data-*, создаёт <iframe sandbox="allow-scripts allow-forms"> (без allow-same-origin, без allow-top-navigation), загружает /widget/<id> (HTML), который монтирует Preact-бандл /widget/app.js.

Конфигурация branding и trigger

Поля модели Widget (валидируются widget.schema.ts в shared):

ПолеПо умолчаниюОписание
primaryColor#4c6ef5Акцентный цвет (hex regex)
botNameAI AssistantИмя бота
logoUrl / avatarUrl-URL изображения (http/https)
welcomeMessage / fallbackMessage-Приветственное/fallback-сообщение (per-locale тексты через localizedTexts)
positionbottom-rightbottom-right/bottom-left
allowedOrigins-Массив URL (JSON, максимум 2000 символов) для динамического CORS
autoOpenDelay-Секунды авто-открытия (null = disabled, 1–300)
autoOpenUrlPatterns-Glob patterns (**,*,?,{a,b}) для авто-открытия по URL
exitIntentEnabledfalseОткрытие по exit-intent
exitIntentCooldownMs180000060000–86400000 ms
leadCaptureEnabledfalseLead capture
leadCapturePrompt-Lead prompt (максимум 500)
rateLimitPerMinute / sessionLimitPerDay-Per-widget override лимитов (null = глобальный)
responseProviderId / responseModel-Response model pin (v0.22, см. ниже)
suggestedQuestions / credits-Per-locale подсказки (максимум 10) / кредиты виджета

URL query param (?primaryColor=...&position=...&locale=...) переопределяет на лету. Дополнительные поля v0.22: rateLimitPerMinute / sessionLimitPerDay (per-widget override глобальных лимитов; null = глобальный дефолт) и responseProviderId/responseModel.

Response model pin (v0.22): admin-конфигурация каждого виджета может выбрать provider + LLM-модель, обслуживающую ответы этого виджета (responseProviderId, UUID провайдера; responseModel, имя модели). Pin разрешается на стороне сервера из строки виджета в DB при каждом чат-запросе (никогда не предоставляется клиентом - proxy и посетительский UI не затронуты), имеет приоритет над дефолтом workspace, а виджеты без pin сохраняют существующую цепочку разрешения workspace/глобальный. Чаты виджета записывают фактически обслужившую модель.

Trigger (widget/hooks/useTriggers.ts): слушает postMessage от loader - simmetric:urlChange (проверка autoOpenUrlPatterns через matchUrlPattern/globToRegex: **, *, ?, {a,b}), simmetric:exitIntent (открытие с cooldown). Exit intent детектируется на стороне loader через mouseleave с e.clientY <= 10. autoOpenDelay - в секундах (1–300, null = отключено); exitIntentCooldownMs 60000–86400000 ms.

Аутентификация и API key

  • Widget ↔ Server: header X-Api-Key: <WIDGET_API_KEY> к /api/internal/widget/*. Key должен совпадать между packages/widget/.env и packages/server/.env.
  • Посетитель ↔ Widget service: header x-session-token (анонимная сессия, не JWT).
  • Динамический CORS (widgetCors.ts): per-origin CORS headers на основе allowedOrigins. Fail-closed при ошибке DB (нет header = браузер блокирует).

Анонимные сессии

POST /api/sessions (widget) → сервер создаёт WidgetSession: 256-bit hex token (crypto.randomBytes(32)), expiry 24h, IP логируется. sessionMiddleware валидирует token при каждом чат-запросе и прикрепляет req.widgetSession + req.widgetConfig. Сессия сохраняется в sessionStorage хоста через postMessage-handshake с loader (simmetric:storage-*) и переиспользуется при reload; по истечении → 401 → Preact показывает "Session expired", и при обновлении страницы виджет создаёт сессию заново.

Поток чата (SSE proxy)

text
Visitatore → POST /api/chat/:widgetId/stream  (x-session-token, body {message, chatId?})
→ widgetDailyMessageLimiter (per widget+IP, 5/24h prod, override sessionLimitPerDay)
→ widgetChatLimiter (per widgetId, 30/min prod, override rateLimitPerMinute)
→ sessionMiddleware valida sessione
→ check hourlyRemaining (DB-tracked, 20/h) → 429 se esaurito
→ increment messageCount (fire-and-forget)
→ pre-search RAG: POST /api/internal/widget/search {query, widgetId}
   server risolve workspaceIds dalla whitelist WidgetWorkspace (IDOR prevention)
→ proxy a SERVER_URL/api/internal/widget/chat/stream
   headers: X-Api-Key, X-Widget-Id, X-Widget-Session-Id, X-Accel-Buffering: no
→ relay byte SSE verbatim (responseType: "stream", filtra event: thinking)
→ Preact useWidgetChat parse: token | citations | done | error

Proxy прозрачен: не парсит/фильтрует/трансформирует SSE-события. Если RAG pre-search падает, он emits status: rag-degraded, но чат продолжается без контекста.

Lead capture

POST /api/lead/:widgetId (widget, sessionMiddleware):

  • Проверка config.leadCaptureEnabled (403 если выключено).
  • Body widgetLeadSubmitSchema: email (обяз.), name (опц.), transcript[] (role/content/timestamp).
  • submitLead(widgetId, email, name, transcript, session.id) - передаёт session.id (UUID FK).
  • Limiter 3 leads/hour на IP.
  • Admin получает: GET /api/widgets/:id/leads, CSV-экспорт GET /api/widgets/:id/leads/export (с фильтром по дате + выбором колонок; с v0.22 всегда доступен - commodity-флаг lead_export удалён).

Rate limit (двойной слой)

LimiterScopeЛимит (prod)Window
widgetDailyMessageLimiterper widget+IP5/day (override sessionLimitPerDay)24h
widgetChatLimiterper widgetId30/min (override rateLimitPerMinute)1 min
widgetSessionLimiterper IP50/day (создания сессий)24h
widgetLeadLimiterper IP3/hour1h
server hourlyRemainingper session20/h1h
server dailyRemainingper session5/24h24h

Безопасность

  • iframe sandbox allow-scripts allow-forms (без same-origin, без top-navigation).
  • helmet ослаблен для embedding (без CSP, без frameguard, без COOP/COEP/CORP).
  • Сообщения рендерятся через markdown-it + dompurify (anti-XSS).
  • Серверный DLP: DLP-совпадения каждого прогона путешествуют в SSE done (dlp_matches, только при активном DLP и без bypass-роли); согласие на PII посетителя управляется PIIWarningPrompt (pre-chat gate, тексты piiConsent по locale).
  • Без cookie, без localStorage из sandboxed iframe (storage через postMessage-handshake с loader, batching на done/error/unmount).
  • Loader использует getAttribute() (не innerHTML) + encodeURIComponent; входящие сообщения simmetric:* валидируются guard'ом event.source === iframeEl.contentWindow.
  • Endpoint cache-bust (POST /api/config/:widgetId/cache-bust) сравнивает X-Api-Key в постоянном времени (crypto.timingSafeEqual с length guard, fail-closed при unset).

MCP (косвенно)

Виджет не управляет MCP: установка/unpin происходит в server admin. Связанные workspace могут иметь установленные MCP tool → агент использует их во время чата виджета автоматически (ноль кода виджета). mcpSources в SSE done показывает, какие MCP внесли вклад.

Расширение

  • Новый компонент → packages/widget/src/widget/components/ (Preact + Tailwind, не shadcn/ui).
  • Новый trigger → расширь useTriggers.ts + поле Widget + route internalWidget.ts.
  • Новый rate limit → src/middleware/rateLimit.ts + mount в index.ts.
  • Темизируемый CSS → namespace --widget-* в src/widget/index.css; статические стили через Tailwind utility.

Ключевые файлы

ФайлРоль
packages/widget/src/index.tscreateApp() factory, mount routes
src/routes/loader.tsLoader JS + HTML iframe
src/routes/chat.tsSSE proxy + pre-search RAG
src/routes/session.tsАнонимная сессия
src/routes/config.tsConfig (cache 5 min)
src/routes/lead.tsLead capture
src/middleware/session.tssessionMiddleware
src/widget/App.tsxPreact root, hook coord
src/widget/hooks/{useWidgetChat,useWidgetConfig,useTriggers}.tsState