Integração do widget
O widget embutível é um serviço separado (packages/widget, porta 3211) que expõe um chat AI no seu site. Arquitetura: Express + Preact IIFE em iframe sandboxed, proxy SSE transparente para o servidor, sessões anônimas, lead capture, branding por site.
O widget embutível é um serviço separado (packages/widget, porta 3211) que expõe um chat AI no seu site. Arquitetura: Express + Preact IIFE em iframe sandboxed, proxy SSE transparente para o servidor, sessões anônimas, lead capture, branding por site.
O widget é uma feature Enterprise (license widget_enabled); o Community tem 1 widget por padrão mas a criação exige o flag ativo.Como embutir (quick start)
- Crie o 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" }Resposta: { widget } com widget.id.
- Whitelist da knowledge base -
PUT /api/widgets/:id/workspacescom os UUID dos workspace cujo RAG será acessível ao widget (tabelaWidgetWorkspace, PK composta[widgetId, workspaceId]).
- Insira o snippet na 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>Segurança: se servir o loader de um CDN de terceiros, gere o hash SHA-384 do bundle e adicioneintegrity+crossorigin="anonymous"(Subresource Integrity). Se o loader for same-origin com o seu widget service, o SRI é opcional mas recomendado para se defender de comprometimento do CDN/asset.
O loader (packages/widget/src/routes/loader.ts) lê os atributos data-*, cria um <iframe sandbox="allow-scripts allow-forms"> (sem allow-same-origin, sem allow-top-navigation), carrega /widget/<id> (HTML) que monta o bundle Preact /widget/app.js.
Configuração de branding & trigger
Campos do modelo Widget (validados por widget.schema.ts no shared):
| Campo | Padrão | Descrição |
|---|---|---|
primaryColor | #4c6ef5 | Cor de destaque (regex hex) |
botName | AI Assistant | Nome do bot |
logoUrl / avatarUrl | - | URL imagem (http/https) |
welcomeMessage / fallbackMessage | - | Mensagens inicial/fallback (textos per-locale via localizedTexts) |
position | bottom-right | bottom-right/bottom-left |
allowedOrigins | - | Array URL (JSON, máx 2000 chars) para CORS dinâmico |
autoOpenDelay | - | Segundos auto-open (null = disabled, 1–300) |
autoOpenUrlPatterns | - | Glob patterns (**,*,?,{a,b}) para auto-open por URL |
exitIntentEnabled | false | Abertura por exit-intent |
exitIntentCooldownMs | 1800000 | 60000–86400000 ms |
leadCaptureEnabled | false | Lead capture |
leadCapturePrompt | - | Prompt lead (máx 500) |
rateLimitPerMinute / sessionLimitPerDay | - | Override per-widget dos limites (null = global) |
responseProviderId / responseModel | - | Response model pin (v0.22, ver abaixo) |
suggestedQuestions / credits | - | Sugestões per-locale (máx 10) / créditos widget |
Query param URL (?primaryColor=...&position=...&locale=...) override runtime. Campos adicionais v0.22: rateLimitPerMinute / sessionLimitPerDay (override per-widget dos limites globais; null = default global) e responseProviderId/responseModel.
Response model pin (v0.22): a configuração admin de cada widget pode selecionar o provider + modelo LLM que serve as respostas desse widget (responseProviderId, UUID provider; responseModel, nome do modelo). O pin é resolvido server-side a partir da linha widget do DB em cada request de chat (nunca fornecido pelo cliente - proxy e UI visitante intactos), tem prioridade sobre o default do workspace, e widgets sem pin mantêm a cadeia de resolução workspace/global existente. Os chats widget registram o modelo efetivo que os serviu.
Trigger (widget/hooks/useTriggers.ts): escuta postMessage do loader - simmetric:urlChange (check de autoOpenUrlPatterns via matchUrlPattern/globToRegex: **, *, ?, {a,b}), simmetric:exitIntent (open com cooldown). Exit intent detectado no lado loader via mouseleave com e.clientY <= 10. O autoOpenDelay está em segundos (1–300, null = desabilitado); exitIntentCooldownMs 60000–86400000 ms.
Autenticação & API key
- Widget ↔ Server: header
X-Api-Key: <WIDGET_API_KEY>para/api/internal/widget/*. A key deve casar entrepackages/widget/.envepackages/server/.env. - Visitante ↔ Widget service: header
x-session-token(sessão anônima, não JWT). - CORS dinâmico (
widgetCors.ts): headers CORS por-origem baseados emallowedOrigins. Fail-closed em DB error (sem header = o navegador bloqueia).
Sessões anônimas
POST /api/sessions (widget) → o servidor cria WidgetSession: token 256-bit hex (crypto.randomBytes(32)), expiry 24h, IP logado. sessionMiddleware valida o token em cada request de chat e anexa req.widgetSession + req.widgetConfig. A sessão é persistida na sessionStorage do host via handshake postMessage com o loader (simmetric:storage-*) e reutilizada nos reloads; ao expirar → 401 → o Preact mostra "Session expired" e no refresh da página o widget recria a sessão.
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 | errorO proxy é transparente: não parseia/filtra/transforma os eventos SSE. Se o pre-search RAG falhar, emite status: rag-degraded mas o chat continua sem contexto.
Lead capture
POST /api/lead/:widgetId (widget, sessionMiddleware):
- Check
config.leadCaptureEnabled(403 se off). - Body
widgetLeadSubmitSchema:email(req),name(opt),transcript[](role/content/timestamp). submitLead(widgetId, email, name, transcript, session.id)- passasession.id(UUID FK).- Limiter 3 leads/hour por IP.
- O admin recupera:
GET /api/widgets/:id/leads, export CSVGET /api/widgets/:id/leads/export(com filtro de data + seleção de colunas; desde a v0.22 sempre disponível - o flag commoditylead_exportfoi removido).
Rate limit (layer duplo)
| 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 (criações de sessão) | 24h |
| widgetLeadLimiter | per IP | 3/hour | 1h |
| server hourlyRemaining | per sessão | 20/h | 1h |
| server dailyRemaining | per sessão | 5/24h | 24h |
Segurança
- iframe sandbox
allow-scripts allow-forms(sem same-origin, sem top-navigation). helmetrelaxado para embedding (sem CSP, sem frameguard, sem COOP/COEP/CORP).- Mensagens renderizadas via
markdown-it+dompurify(anti-XSS). - DLP server-side: os matches DLP de cada run viajam no SSE
done(dlp_matches, apenas com DLP ativo e nenhum role bypass); o consentimento PII do visitante é gerido peloPIIWarningPrompt(gate pre-chat, textospiiConsentpor locale). - Sem cookie, sem localStorage a partir do iframe sandboxed (storage via handshake
postMessagecom o loader, batching em done/error/unmount). - O loader usa
getAttribute()(nãoinnerHTML) +encodeURIComponent; as mensagenssimmetric:*entrantes são validadas com o guardevent.source === iframeEl.contentWindow. - O endpoint cache-bust (
POST /api/config/:widgetId/cache-bust) comparaX-Api-Keyem tempo constante (crypto.timingSafeEqualcom length guard, fail-closed se unset).
MCP (indireto)
O widget não gere MCP: a instalação/unpin ocorre no server admin. Os workspaces vinculados podem ter MCP tool instalados → o agente usa-os durante o chat widget automaticamente (zero código widget). mcpSources no SSE done indica quais MCP contribuíram.
Estender
- Novo componente →
packages/widget/src/widget/components/(Preact + Tailwind, não shadcn/ui). - Novo trigger → estenda
useTriggers.ts+ campoWidget+ routeinternalWidget.ts. - Novo rate limit →
src/middleware/rateLimit.ts+ mount emindex.ts. - CSS themable → namespace
--widget-*emsrc/widget/index.css; estáticas com Tailwind utility.
Arquivos-chave
| Arquivo | Papel |
|---|---|
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 | Sessão 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