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, …
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 1Widget standardmäßig, aber die Erstellung erfordert das aktive Flag.
Einbetten (Quick Start)
- Widget erstellen (Admin UI → Settings → Widgets, oder API):
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.
- Knowledge Base whitelisten -
PUT /api/widgets/:id/workspacesmit den UUIDs der Workspaces, deren RAG für das Widget zugänglich sein soll (TabelleWidgetWorkspace, zusammengesetzter PK[widgetId, workspaceId]).
- Snippet einfügen in die Host-Seite:
<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ügeintegrity+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):
| Feld | Standard | Beschreibung |
|---|---|---|
primaryColor | #4c6ef5 | Akzentfarbe (Hex-Regex) |
botName | AI Assistant | Bot-Name |
logoUrl / avatarUrl | - | Bild-URL (http/https) |
welcomeMessage / fallbackMessage | - | Willkommens-/Fallback-Nachrichten (Per-Locale-Texte via localizedTexts) |
position | bottom-right | bottom-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 |
exitIntentEnabled | false | Öffnung bei Exit-Intent |
exitIntentCooldownMs | 1800000 | 60000–86400000 ms |
leadCaptureEnabled | false | Lead 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 zwischenpackages/widget/.envundpackages/server/.envübereinstimmen. - Besucher ↔ Widget-Service: Header
x-session-token(anonyme Session, kein JWT). - Dynamisches CORS (
widgetCors.ts): Per-Origin-CORS-Header basierend aufallowedOrigins. 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)
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 | errorDer 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)- übergibtsession.id(UUID FK).- Limiter 3 Leads/Hour pro IP.
- Admin ruft ab:
GET /api/widgets/:id/leads, CSV-ExportGET /api/widgets/:id/leads/export(mit Datumsfilter + Spaltenauswahl; seit v0.22 immer verfügbar - das Commodity-Flaglead_exportwurde entfernt).
Rate Limit (Doppel-Layer)
| Limiter | Scope | Limit (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 (Session-Erstellungen) | 24h |
| widgetLeadLimiter | per IP | 3/hour | 1h |
| server hourlyRemaining | per Session | 20/h | 1h |
| server dailyRemaining | per Session | 5/24h | 24h |
Sicherheit
- iframe sandbox
allow-scripts allow-forms(kein same-origin, kein top-navigation). helmetfü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 vomPIIWarningPromptverwaltet (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()(keininnerHTML) +encodeURIComponent; eingehendesimmetric:*-Nachrichten werden mit dem Guardevent.source === iframeEl.contentWindowvalidiert. - Der Cache-Bust-Endpoint (
POST /api/config/:widgetId/cache-bust) vergleichtX-Api-Keyin konstanter Zeit (crypto.timingSafeEqualmit 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.tserweitern + FeldWidget+ RouteinternalWidget.ts. - Neues Rate Limit →
src/middleware/rateLimit.ts+ Mount inindex.ts. - Themebares CSS → Namespace
--widget-*insrc/widget/index.css; statische Styles mit Tailwind Utility.
Schlüsseldateien
| Datei | Rolle |
|---|---|
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 | Anonyme Session |
src/routes/config.ts | Config (Cache 5 min) |
src/routes/lead.ts | Lead Capture |
src/middleware/session.ts | sessionMiddleware |
src/widget/App.tsx | Preact Root, Hook-Koordination |
src/widget/hooks/{useWidgetChat,useWidgetConfig,useTriggers}.ts | State |
Cross-Link
- Widget-Endpoints: 04 - API Reference
- RAG-/Agent-Internals: 05 - Feature Guide