Integración del widget
El widget embebible es un servicio separado (packages/widget, puerto 3211) que expone un chat AI en tu sitio. Arquitectura: Express + Preact IIFE en iframe sandboxed, proxy SSE transparente hacia el servidor, sesiones anónimas, lead capture, branding por…
El widget embebible es un servicio separado (packages/widget, puerto 3211) que expone un chat AI en tu sitio. Arquitectura: Express + Preact IIFE en iframe sandboxed, proxy SSE transparente hacia el servidor, sesiones anónimas, lead capture, branding por sitio.
El widget es una feature Enterprise (license widget_enabled); Community tiene 1 widgetpor defecto pero la creación requiere el flag activo.
Cómo embeber (quick start)
- Crea el widget (admin UI → Settings → Widgets, o API):
POST /api/widgets
Authorization: Bearer <admin JWT>
{ "name": "Sito Azienda", "allowedOrigins": ["https://www.azienda.it"],
"workspaceIds": ["<uuid>"], "primaryColor": "#4c6ef5", "botName": "Assistente" }Respuesta: { widget } con widget.id.
- Whitelist de la knowledge base -
PUT /api/widgets/:id/workspacescon los UUID de los workspace cuyo RAG será accesible al widget (tablaWidgetWorkspace, PK compuesta[widgetId, workspaceId]).
- Inserta el snippet en la página host:
<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>Seguridad: si sirves el loader desde un CDN de terceros, genera el hash SHA-384 del
bundle y añadeintegrity+crossorigin="anonymous"(Subresource Integrity). Si el
loader es same-origin con tu widget service, SRI es opcional pero recomendado para
defenderse de una compromisión del CDN/asset.
El loader (packages/widget/src/routes/loader.ts) lee los atributos data-*, crea un <iframe sandbox="allow-scripts allow-forms"> (sin allow-same-origin, sin allow-top-navigation), carga /widget/<id> (HTML) que monta el bundle Preact /widget/app.js.
Configuración de branding & trigger
Campos del modelo Widget (validados por widget.schema.ts en shared):
| Campo | Default | Descripción |
|---|---|---|
primaryColor | #4c6ef5 | Color de acento (regex hex) |
botName | AI Assistant | Nombre del bot |
logoUrl / avatarUrl | - | URL imagen (http/https) |
welcomeMessage / fallbackMessage | - | Mensajes inicial/fallback (textos per-locale vía localizedTexts) |
position | bottom-right | bottom-right/bottom-left |
allowedOrigins | - | Array URL (JSON, max 2000 char) para CORS dinámico |
autoOpenDelay | - | Segundos auto-open (null = disabled, 1–300) |
autoOpenUrlPatterns | - | Glob patterns (**,*,?,{a,b}) para auto-open por URL |
exitIntentEnabled | false | Apertura por exit-intent |
exitIntentCooldownMs | 1800000 | 60000–86400000 ms |
leadCaptureEnabled | false | Lead capture |
leadCapturePrompt | - | Prompt lead (max 500) |
rateLimitPerMinute / sessionLimitPerDay | - | Override per-widget de los límites (null = global) |
responseProviderId / responseModel | - | Response model pin (v0.22, ver abajo) |
suggestedQuestions / credits | - | Sugerencias per-locale (max 10) / créditos widget |
Query param URL (?primaryColor=...&position=...&locale=...) override runtime. Campos adicionales v0.22: rateLimitPerMinute / sessionLimitPerDay (override per-widget de los límites globales; null = default global) y responseProviderId/responseModel.
Response model pin (v0.22): la configuración admin de cada widget puede seleccionar el provider + modelo LLM que sirve las respuestas de ese widget (responseProviderId, UUID provider; responseModel, nombre del modelo). El pin se resuelve server-side desde la fila widget de la DB en cada request de chat (nunca provisto por el cliente - proxy y UI visitante intactos), tiene prioridad sobre el default del workspace, y los widgets sin pin mantienen la cadena de resolución workspace/global existente. Los chats widget registran el modelo efectivo que los sirvió.
Trigger (widget/hooks/useTriggers.ts): escucha postMessage desde el loader - simmetric:urlChange (check de autoOpenUrlPatterns vía matchUrlPattern/globToRegex: **, *, ?, {a,b}), simmetric:exitIntent (open con cooldown). Exit intent detectado lado loader vía mouseleave con e.clientY <= 10. El autoOpenDelay está en segundos (1–300, null = deshabilitado); exitIntentCooldownMs 60000–86400000 ms.
Autenticación & API key
- Widget ↔ Server: header
X-Api-Key: <WIDGET_API_KEY>hacia/api/internal/widget/*. La key debe matchar entrepackages/widget/.envypackages/server/.env. - Visitante ↔ Widget service: header
x-session-token(sesión anónima, no JWT). - CORS dinámico (
widgetCors.ts): headers CORS por-origen basados enallowedOrigins. Fail-closed en DB error (sin header = el navegador bloquea).
Sesiones anónimas
POST /api/sessions (widget) → el servidor crea WidgetSession: token 256-bit hex (crypto.randomBytes(32)), expiry 24h, IP logged. sessionMiddleware valida el token en cada request de chat y adjunta req.widgetSession + req.widgetConfig. La sesión se persiste en la sessionStorage del host vía handshake postMessage con el loader (simmetric:storage-*) y se reutiliza en los reloads; al expirar → 401 → el Preact muestra "Session expired" y al refrescar la página el widget recrea la sesión.
Chat flow (SSE proxy)
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 | errorEl proxy es transparente: no parsea/filtra/transforma los eventos SSE. Si el pre-search RAG falla, emite status: rag-degraded pero el chat continúa sin contexto.
Lead capture
POST /api/lead/:widgetId (widget, sessionMiddleware):
- Check
config.leadCaptureEnabled(403 si off). - Body
widgetLeadSubmitSchema:email(req),name(opt),transcript[](role/content/timestamp). submitLead(widgetId, email, name, transcript, session.id)- pasasession.id(UUID FK).- Limiter 3 leads/hour por IP.
- El admin recupera:
GET /api/widgets/:id/leads, export CSVGET /api/widgets/:id/leads/export(con filtro de fecha + selección de columnas; desde la v0.22 siempre disponible - el flag commoditylead_exportfue eliminado).
Rate limit (doble layer)
| Limiter | Scope | Límite (prod) | Window |
|---|---|---|---|
| widgetDailyMessageLimiter | per widget+IP | 5/day (override sessionLimitPerDay) | 24h |
| widgetChatLimiter | per widgetId | 30/min (override rateLimitPerMinute) | 1 min |
| widgetSessionLimiter | per IP | 50/day (creaciones de sesión) | 24h |
| widgetLeadLimiter | per IP | 3/hour | 1h |
| server hourlyRemaining | per sesión | 20/h | 1h |
| server dailyRemaining | per sesión | 5/24h | 24h |
Seguridad
- iframe sandbox
allow-scripts allow-forms(sin same-origin, sin top-navigation). helmetrelajado para el embedding (sin CSP, sin frameguard, sin COOP/COEP/CORP).- Mensajes renderizados vía
markdown-it+dompurify(anti-XSS). - DLP server-side: los matches DLP de cada run viajan en el SSE
done(dlp_matches, solo con DLP activo y ningún rol bypass); el consentimiento PII del visitante lo gestiona elPIIWarningPrompt(gate pre-chat, textospiiConsentpor locale). - Sin cookie, sin localStorage desde el iframe sandboxed (storage vía handshake
postMessagecon el loader, batching en done/error/unmount). - El loader usa
getAttribute()(noinnerHTML) +encodeURIComponent; los mensajessimmetric:*entrantes se validan con el guardevent.source === iframeEl.contentWindow. - El endpoint cache-bust (
POST /api/config/:widgetId/cache-bust) comparaX-Api-Keyen tiempo constante (crypto.timingSafeEqualcon length guard, fail-closed si unset).
MCP (indirecto)
El widget no gestiona MCP: la instalación/unpin ocurre en el server admin. Los workspaces vinculados pueden tener MCP tool instalados → el agente los usa durante el chat widget automáticamente (cero código widget). mcpSources en el SSE done indica qué MCP han contribuido.
Extender
- Nuevo componente →
packages/widget/src/widget/components/(Preact + Tailwind, no shadcn/ui). - Nuevo trigger → extiende
useTriggers.ts+ campoWidget+ routeinternalWidget.ts. - Nuevo rate limit →
src/middleware/rateLimit.ts+ mount enindex.ts. - CSS themable → namespace
--widget-*ensrc/widget/index.css; estáticas con Tailwind utility.
Archivos clave
| Archivo | Rol |
|---|---|
packages/widget/src/index.ts | factory createApp(), mount routes |
src/routes/loader.ts | Loader JS + HTML iframe |
src/routes/chat.ts | SSE proxy + pre-search RAG |
src/routes/session.ts | Sesión anónima |
src/routes/config.ts | Config (cache 5 min) |
src/routes/lead.ts | Lead capture |
src/middleware/session.ts | sessionMiddleware |
src/widget/App.tsx | Root Preact, hook coord |
src/widget/hooks/{useWidgetChat,useWidgetConfig,useTriggers}.ts | State |
Cross-link
- Endpoints widget: 04 - API Reference
- Internals RAG/agent: 05 - Feature Guide