Intégration du widget
Le widget embarquable est un service séparé (packages/widget, port 3211) qui expose un chat AI sur votre site. Architecture : Express + Preact IIFE en iframe sandboxed, proxy SSE transparent vers le serveur, sessions anonymes, lead capture, branding par-…
Le widget embarquable est un service séparé (packages/widget, port 3211) qui expose un chat AI sur votre site. Architecture : Express + Preact IIFE en iframe sandboxed, proxy SSE transparent vers le serveur, sessions anonymes, lead capture, branding par-site.
Le widget est une feature Enterprise (license widget_enabled) ; Community a 1 widgetpar défaut mais la création exige le flag actif.
Comment embarquement (quick start)
- Créez le widget (admin UI → Settings → Widgets, ou API) :
POST /api/widgets
Authorization: Bearer <admin JWT>
{ "name": "Sito Azienda", "allowedOrigins": ["https://www.azienda.it"],
"workspaceIds": ["<uuid>"], "primaryColor": "#4c6ef5", "botName": "Assistente" }Réponse : { widget } avec widget.id.
- Whitelist de la knowledge base -
PUT /api/widgets/:id/workspacesavec les UUID des workspace dont le RAG sera accessible au widget (tableWidgetWorkspace, PK composite[widgetId, workspaceId]).
- Insérez le snippet dans la page hôte :
<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>Sécurité : si vous servez le loader depuis un CDN tiers, générez le hash SHA-384 du
bundle et ajoutezintegrity+crossorigin="anonymous"(Subresource Integrity). Si
le loader est same-origin avec votre widget service, SRI est optionnel mais recommandé pour
se défendre contre une compromission du CDN/asset.
Le loader (packages/widget/src/routes/loader.ts) lit les attributs data-*, crée un <iframe sandbox="allow-scripts allow-forms"> (pas de allow-same-origin, pas de allow-top-navigation), charge /widget/<id> (HTML) qui monte le bundle Preact /widget/app.js.
Configuration branding & trigger
Champs du modèle Widget (validés par widget.schema.ts dans shared) :
| Champ | Défaut | Description |
|---|---|---|
primaryColor | #4c6ef5 | Couleur d'accent (regex hex) |
botName | AI Assistant | Nom du bot |
logoUrl / avatarUrl | - | URL image (http/https) |
welcomeMessage / fallbackMessage | - | Messages initial/fallback (textes per-locale via localizedTexts) |
position | bottom-right | bottom-right/bottom-left |
allowedOrigins | - | Array URL (JSON, max 2000 char) pour CORS dynamique |
autoOpenDelay | - | Secondes auto-open (null = disabled, 1–300) |
autoOpenUrlPatterns | - | Glob patterns (**,*,?,{a,b}) pour auto-open sur URL |
exitIntentEnabled | false | Ouverture sur exit-intent |
exitIntentCooldownMs | 1800000 | 60000–86400000 ms |
leadCaptureEnabled | false | Lead capture |
leadCapturePrompt | - | Prompt lead (max 500) |
rateLimitPerMinute / sessionLimitPerDay | - | Override per-widget des limites (null = global) |
responseProviderId / responseModel | - | Response model pin (v0.22, voir ci-dessous) |
suggestedQuestions / credits | - | Suggestions per-locale (max 10) / crédits widget |
Query param URL (?primaryColor=...&position=...&locale=...) override runtime. Champs supplémentaires v0.22 : rateLimitPerMinute / sessionLimitPerDay (override per-widget des limites globales ; null = défaut global) et responseProviderId/responseModel.
Response model pin (v0.22) : la configuration admin de chaque widget peut sélectionner le provider + modèle LLM qui sert les réponses de ce widget (responseProviderId, UUID provider ; responseModel, nom du modèle). Le pin est résolu server-side depuis la ligne widget de la DB à chaque requête chat (jamais fourni par le client - proxy et UI visiteur non touchés), il a priorité sur le défaut du workspace, et les widgets sans pin gardent la chaîne de résolution workspace/globale existante. Les chats widget enregistrent le modèle effectif qui les a servies.
Trigger (widget/hooks/useTriggers.ts) : écoute postMessage depuis le loader - simmetric:urlChange (check autoOpenUrlPatterns via matchUrlPattern/globToRegex : **, *, ?, {a,b}), simmetric:exitIntent (open avec cooldown). Exit intent détecté côté loader via mouseleave avec e.clientY <= 10. Le autoOpenDelay est en secondes (1–300, null = désactivé) ; exitIntentCooldownMs 60000–86400000 ms.
Authentification & API key
- Widget ↔ Server : header
X-Api-Key: <WIDGET_API_KEY>vers/api/internal/widget/*. La key doit matcher entrepackages/widget/.envetpackages/server/.env. - Visiteur ↔ Widget service : header
x-session-token(session anonyme, pas JWT). - CORS dynamique (
widgetCors.ts) : headers CORS per-origin basés surallowedOrigins. Fail-closed sur DB error (pas de header = le navigateur bloque).
Sessions anonymes
POST /api/sessions (widget) → le serveur crée WidgetSession : token 256-bit hex (crypto.randomBytes(32)), expiry 24h, IP logged. sessionMiddleware valide le token à chaque requête chat et attache req.widgetSession + req.widgetConfig. La session est persistée dans la sessionStorage de l'hôte via handshake postMessage avec le loader (simmetric:storage-*) et réutilisée aux reloads ; à l'expiration → 401 → le Preact affiche "Session expired" et au refresh de la page le widget recrée la session.
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 | errorLe proxy est transparent : il ne parse/filtre/transforme pas les événements SSE. Si la pre-search RAG échoue, il émet status: rag-degraded mais le chat continue sans contexte.
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)- passesession.id(UUID FK).- Limiter 3 leads/hour par IP.
- L'admin récupère :
GET /api/widgets/:id/leads, export CSVGET /api/widgets/:id/leads/export(avec filtre date + sélection de colonnes ; depuis la v0.22 toujours disponible - le flag commoditylead_exporta été supprimé).
Rate limit (double layer)
| Limiter | Scope | Limite (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 (créations de session) | 24h |
| widgetLeadLimiter | per IP | 3/hour | 1h |
| server hourlyRemaining | per session | 20/h | 1h |
| server dailyRemaining | per session | 5/24h | 24h |
Sécurité
- iframe sandbox
allow-scripts allow-forms(pas de same-origin, pas de top-navigation). helmetrelâché pour l'embedding (pas de CSP, pas de frameguard, pas de COOP/COEP/CORP).- Messages rendus via
markdown-it+dompurify(anti-XSS). - DLP server-side : les matchs DLP de chaque run voyagent dans le SSE
done(dlp_matches, uniquement avec DLP actif et aucun rôle bypass) ; le consentement PII du visiteur est géré par lePIIWarningPrompt(gate pré-chat, textespiiConsentpar locale). - Pas de cookie, pas de localStorage depuis l'iframe sandboxed (storage via handshake
postMessageavec le loader, batching sur done/error/unmount). - Le loader utilise
getAttribute()(pasinnerHTML) +encodeURIComponent; les messagessimmetric:*entrants sont validés avec le guardevent.source === iframeEl.contentWindow. - L'endpoint cache-bust (
POST /api/config/:widgetId/cache-bust) compareX-Api-Keyen temps constant (crypto.timingSafeEqualavec length guard, fail-closed si unset).
MCP (indirect)
Le widget ne gère pas MCP : l'installation/unpin se fait dans le server admin. Les workspaces liés peuvent avoir des MCP tool installés → l'agent les utilise pendant la chat widget automatiquement (zéro code widget). mcpSources dans le SSE done indique quels MCP ont contribué.
Étendre
- Nouveau composant →
packages/widget/src/widget/components/(Preact + Tailwind, pas shadcn/ui). - Nouveau trigger → étends
useTriggers.ts+ champWidget+ routeinternalWidget.ts. - Nouveau rate limit →
src/middleware/rateLimit.ts+ mount dansindex.ts. - CSS themable → namespace
--widget-*danssrc/widget/index.css; statiques avec Tailwind utility.
Fichiers clés
| Fichier | Rôle |
|---|---|
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 | Session anonyme |
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