Zurück zur Dokumentation
Guide 06

Widget-Integration

Das einbettbare Widget ist ein separater Service (packages/widget, Port 3211), der einen AI-Chat auf deiner Website bereitstellt. Architektur: Express + Preact IIFE im sandboxed iframe, transparenter SSE-Proxy zum Server, anonyme Sessions, Lead Capture, …

4 Min. Lesezeit

Das einbettbare Widget ist ein separater Service (packages/widget, Port 3211), der einen AI-Chat auf deiner Website bereitstellt. Architektur: Express + Preact IIFE im sandboxed iframe, transparenter SSE-Proxy zum Server, anonyme Sessions, Lead Capture, Branding pro Website.

Das Widget ist eine Enterprise-Funktion (License widget_enabled); Community hat 1
Widget standardmäßig, aber die Erstellung erfordert das aktive Flag.

Einbetten (Quick Start)

  1. Widget erstellen (Admin UI → Settings → Widgets, oder API):
http
POST /api/widgets
Authorization: Bearer <admin JWT>
{ "name": "Sito Azienda", "allowedOrigins": ["https://www.azienda.it"],
  "workspaceIds": ["<uuid>"], "primaryColor": "#4c6ef5", "botName": "Assistente" }

Antwort: { widget } mit widget.id.

  1. Knowledge Base whitelisten - PUT /api/widgets/:id/workspaces mit den UUIDs der Workspaces, deren RAG für das Widget zugänglich sein soll (Tabelle WidgetWorkspace, zusammengesetzter PK [widgetId, workspaceId]).
  1. Snippet einfügen in die Host-Seite:
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>
Sicherheit: Wenn du den Loader von einem Drittanbieter-CDN auslieferst, generiere den
SHA-384-Hash des Bundles und füge integrity + crossorigin="anonymous" hinzu
(Subresource Integrity). Wenn der Loader same-origin mit deinem Widget-Service ist, ist SRI
optional, aber empfohlen, um sich gegen eine Kompromittierung des CDN/Assets zu schützen.

Der Loader (packages/widget/src/routes/loader.ts) liest die data-*-Attribute, erstellt ein <iframe sandbox="allow-scripts allow-forms"> (kein allow-same-origin, kein allow-top-navigation) und lädt /widget/<id> (HTML), das das Preact-Bundle /widget/app.js montiert.

Branding- & Trigger-Konfiguration

Felder des Widget-Modells (validiert durch widget.schema.ts in shared):

FeldStandardBeschreibung
primaryColor#4c6ef5Akzentfarbe (Hex-Regex)
botNameAI AssistantBot-Name
logoUrl / avatarUrl-Bild-URL (http/https)
welcomeMessage / fallbackMessage-Willkommens-/Fallback-Nachrichten (Per-Locale-Texte via localizedTexts)
positionbottom-rightbottom-right/bottom-left
allowedOrigins-URL-Array (JSON, max 2000 Zeichen) für dynamisches CORS
autoOpenDelay-Sekunden Auto-Open (null = disabled, 1–300)
autoOpenUrlPatterns-Glob Patterns (**,*,?,{a,b}) für Auto-Open nach URL
exitIntentEnabledfalseÖffnung bei Exit-Intent
exitIntentCooldownMs180000060000–86400000 ms
leadCaptureEnabledfalseLead Capture
leadCapturePrompt-Lead-Prompt (max 500)
rateLimitPerMinute / sessionLimitPerDay-Per-Widget-Override der Limits (null = global)
responseProviderId / responseModel-Response Model Pin (v0.22, siehe unten)
suggestedQuestions / credits-Per-Locale-Vorschläge (max 10) / Widget-Credits

URL-Query-Parameter (?primaryColor=...&position=...&locale=...) überschreiben zur Runtime. Zusätzliche v0.22-Felder: rateLimitPerMinute / sessionLimitPerDay (Per-Widget-Override der globalen Limits; null = globaler Standard) und responseProviderId/responseModel.

Response Model Pin (v0.22): Die Admin-Konfiguration jedes Widgets kann den Provider + LLM-Modell auswählen, der die Antworten dieses Widgets bedient (responseProviderId, Provider-UUID; responseModel, Modellname). Der Pin wird serverseitig aus der Widget-Zeile der DB bei jeder Chat-Anfrage aufgelöst (nie vom Client geliefert - Proxy und Besucher-UI unberührt), hat Priorität über den Workspace-Default, und Widgets ohne Pin behalten die bestehende Workspace-/Globale-Auflösungskette. Widget-Chats protokollieren das effektiv dienende Modell.

Trigger (widget/hooks/useTriggers.ts): lauscht auf postMessage vom Loader - simmetric:urlChange (prüft autoOpenUrlPatterns via matchUrlPattern/globToRegex: **, *, ?, {a,b}), simmetric:exitIntent (Open mit Cooldown). Exit Intent wird loaderseitig via mouseleave mit e.clientY <= 10 erkannt. Der autoOpenDelay ist in Sekunden (1–300, null = deaktiviert); exitIntentCooldownMs 60000–86400000 ms.

Authentifizierung & API Key

  • Widget ↔ Server: Header X-Api-Key: <WIDGET_API_KEY> an /api/internal/widget/*. Der Key muss zwischen packages/widget/.env und packages/server/.env übereinstimmen.
  • Besucher ↔ Widget-Service: Header x-session-token (anonyme Session, kein JWT).
  • Dynamisches CORS (widgetCors.ts): Per-Origin-CORS-Header basierend auf allowedOrigins. Fail-Closed bei DB-Fehler (kein Header = Browser blockiert).

Anonyme Sessions

POST /api/sessions (Widget) → Der Server erstellt WidgetSession: 256-bit-Hex-Token (crypto.randomBytes(32)), 24h Expiry, IP geloggt. sessionMiddleware validiert den Token bei jeder Chat-Anfrage und hängt req.widgetSession + req.widgetConfig an. Die Session wird in der sessionStorage des Hosts via postMessage-Handshake mit dem Loader persistiert (simmetric:storage-*) und bei Reloads wiederverwendet; bei Ablauf → 401 → das Preact zeigt "Session expired" und beim Seiten-Refresh erstellt das Widget die Session neu.

Chat-Flow (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

Der Proxy ist transparent: Er parst/filtert/transformiert die SSE-Events nicht. Wenn die RAG-Pre-Search fehlschlägt, sendet er status: rag-degraded, aber der Chat läuft ohne Kontext weiter.

Lead Capture

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

  • Check config.leadCaptureEnabled (403 wenn aus).
  • Body widgetLeadSubmitSchema: email (req), name (opt), transcript[] (role/content/timestamp).
  • submitLead(widgetId, email, name, transcript, session.id) - übergibt session.id (UUID FK).
  • Limiter 3 Leads/Hour pro IP.
  • Admin ruft ab: GET /api/widgets/:id/leads, CSV-Export GET /api/widgets/:id/leads/export (mit Datumsfilter + Spaltenauswahl; seit v0.22 immer verfügbar - das Commodity-Flag lead_export wurde entfernt).

Rate Limit (Doppel-Layer)

LimiterScopeLimit (prod)Window
widgetDailyMessageLimiterper widget+IP5/day (override sessionLimitPerDay)24h
widgetChatLimiterper widgetId30/min (override rateLimitPerMinute)1 min
widgetSessionLimiterper IP50/day (Session-Erstellungen)24h
widgetLeadLimiterper IP3/hour1h
server hourlyRemainingper Session20/h1h
server dailyRemainingper Session5/24h24h

Sicherheit

  • iframe sandbox allow-scripts allow-forms (kein same-origin, kein top-navigation).
  • helmet für das Embedding gelockert (kein CSP, kein Frameguard, kein COOP/COEP/CORP).
  • Nachrichten gerendert via markdown-it + dompurify (Anti-XSS).
  • Serverseitiges DLP: Die DLP-Matches jedes Runs reisen im SSE done (dlp_matches, nur bei aktivem DLP und ohne Bypass-Rolle); die PII-Einwilligung des Besuchers wird vom PIIWarningPrompt verwaltet (Pre-Chat-Gate, piiConsent-Texte pro Locale).
  • Keine Cookies, kein localStorage aus dem sandboxed iframe (Storage via postMessage-Handshake mit dem Loader, Batching bei done/error/unmount).
  • Der Loader nutzt getAttribute() (kein innerHTML) + encodeURIComponent; eingehende simmetric:*-Nachrichten werden mit dem Guard event.source === iframeEl.contentWindow validiert.
  • Der Cache-Bust-Endpoint (POST /api/config/:widgetId/cache-bust) vergleicht X-Api-Key in konstanter Zeit (crypto.timingSafeEqual mit Length Guard, Fail-Closed wenn unset).

MCP (indirekt)

Das Widget verwaltet kein MCP: Installation/Unpin erfolgt im Server-Admin. Verlinkte Workspaces können installierte MCP-Tools haben → der Agent nutzt sie während des Widget-Chats automatisch (null Widget-Code). mcpSources im SSE done zeigt, welche MCPs beigetragen haben.

Erweitern

  • Neue Komponente → packages/widget/src/widget/components/ (Preact + Tailwind, kein shadcn/ui).
  • Neuer Trigger → useTriggers.ts erweitern + Feld Widget + Route internalWidget.ts.
  • Neues Rate Limit → src/middleware/rateLimit.ts + Mount in index.ts.
  • Themebares CSS → Namespace --widget-* in src/widget/index.css; statische Styles mit Tailwind Utility.

Schlüsseldateien

DateiRolle
packages/widget/src/index.tsFactory createApp(), Mount Routes
src/routes/loader.tsLoader JS + HTML iframe
src/routes/chat.tsSSE Proxy + Pre-Search RAG
src/routes/session.tsAnonyme Session
src/routes/config.tsConfig (Cache 5 min)
src/routes/lead.tsLead Capture
src/middleware/session.tssessionMiddleware
src/widget/App.tsxPreact Root, Hook-Koordination
src/widget/hooks/{useWidgetChat,useWidgetConfig,useTriggers}.tsState