Wrapper application around upstream Vanna with: - Tenant-aware ChromaDB memory (per program/store) - ClickHouse RLS runner with introspection guards - PT-BR system prompt and chat translations - Custom Plotly chart generator (ranked bar, datetime coercion) - Embed bootstrap (theme pierce + i18n + markdown) shared by demo and React app - Event sink for chat turn observability
14 KiB
Embedar <vanna-chat> (chat flutuante) num app React
Guia para o time frontend embedar o web component <vanna-chat> como botão flutuante (canto inferior direito) numa aplicação React. Cobre auth IDs, theming e gotchas comuns.
1. Pré-requisitos
| Item | Valor |
|---|---|
| Backend Vanna | https://SEU-BACKEND-VANNA (substituir pelas envs prod/staging) |
| Bundle JS | https://SEU-BACKEND-VANNA/static/vanna-components.js (~7.5 MB, ES module) |
| Tema CSS (opcional) | https://SEU-BACKEND-VANNA/vanna-theme.css |
| Endpoints chat | /api/vanna/v2/chat_sse (SSE), /api/vanna/v2/chat_websocket (WS), /api/vanna/v2/chat_poll (long poll) |
| IDs obrigatórios na URL | program_id, store_id, user_id (do usuário autenticado) |
Sobre os IDs: program_id + store_id controlam RLS (o backend filtra dados pela loja desse tenant — sem eles, o chat retorna erro). user_id é usado só para auditoria (log em events.vanna_ai); se ausente, a row é gravada com user_id='' e o chat funciona normal — mas perde rastreabilidade. Mande sempre os 3.
2. Setup em 3 etapas
Etapa 1 — index.html: scripts que rodam ANTES do bundle
Cole antes do <script type="module"> do bundle. Há 2 scripts, na ordem:
- Theme pierce — necessário só se você quer customizar cores/fontes via
vanna-theme.css. O bundle encapsula cada componente interno em Shadow DOM com tokens próprios; sem esse hack, um<link rel="stylesheet">no documento não atinge os filhos. O patch força o stylesheet a ser adoptado em todoattachShadownovo. - i18n PT-BR — traduz strings hardcoded do bundle (
Search...,X rows,Export, etc.) e converte markdown literal nas mensagens (**negrito**→<strong>).
<!-- index.html (Vite/CRA — public/index.html) -->
<head>
<!-- Open Sans (font usado pelo tema) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&display=swap"
/>
<!-- 1. Theme pierce (opcional — só se for customizar cores) -->
<script>
(() => {
const themeSheet = new CSSStyleSheet();
const desc = Object.getOwnPropertyDescriptor(
ShadowRoot.prototype, "adoptedStyleSheets"
);
if (desc && desc.set) {
Object.defineProperty(ShadowRoot.prototype, "adoptedStyleSheets", {
configurable: true, enumerable: true,
get() { return desc.get.call(this); },
set(value) {
const without = (value || []).filter((s) => s !== themeSheet);
desc.set.call(this, [...without, themeSheet]);
},
});
}
fetch("https://SEU-BACKEND-VANNA/vanna-theme.css")
.then((r) => r.text())
.then((css) => themeSheet.replaceSync(css))
.catch((err) => console.error("[vanna-theme] load failed", err));
})();
</script>
<!-- 2. i18n PT-BR + markdown parser — copiar literal do segundo <script>
em static/embed-demo.html (o IIFE que termina em "[vanna-i18n] frontend
translator armed"). Faz MutationObserver + tradução exact-match +
parser markdown (bold/italic/code/links/listas/headers h1-h6).
Não modifique sem entender o porquê — o bundle não tem hooks de tradução. -->
<script>
/* ... cole o IIFE de tradução de embed-demo.html aqui ... */
</script>
</head>
Etapa 2 — Carregar o bundle
<!-- Final do <body>, ou via React effect (ver etapa 3) -->
<script type="module" src="https://SEU-BACKEND-VANNA/static/vanna-components.js"></script>
Etapa 3 — Componente React <VannaChat />
// src/components/VannaChat.tsx
import { useEffect, useRef } from "react";
const BACKEND_URL = "https://SEU-BACKEND-VANNA";
interface VannaChatProps {
programId: string;
storeId: string;
userId: string;
/** Override do título do header. Default: "ClubPetro IA" */
title?: string;
}
export function VannaChat({ programId, storeId, userId, title = "ClubPetro IA" }: VannaChatProps) {
const ref = useRef<HTMLElement>(null);
// Constrói query string com os 3 IDs
const qs = new URLSearchParams({
program_id: programId,
store_id: storeId,
user_id: userId,
}).toString();
// Custom element exige property assignment pra boolean attrs.
// showProgress=true por default — desligamos pra esconder a sidebar de progresso.
useEffect(() => {
customElements.whenDefined("vanna-chat").then(() => {
if (ref.current) {
(ref.current as any).showProgress = false;
}
});
}, []);
return (
// @ts-expect-error — custom element não tem types nativos do React
<vanna-chat
ref={ref}
api-base={BACKEND_URL}
sse-endpoint={`/api/vanna/v2/chat_sse?${qs}`}
ws-endpoint={`/api/vanna/v2/chat_websocket?${qs}`}
poll-endpoint={`/api/vanna/v2/chat_poll?${qs}`}
starting-state="minimized"
theme="light"
title={title}
/>
);
}
Tipagem (apaga o @ts-expect-error) — adicione em src/types/global.d.ts:
declare namespace JSX {
interface IntrinsicElements {
"vanna-chat": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
"api-base"?: string;
"sse-endpoint"?: string;
"ws-endpoint"?: string;
"poll-endpoint"?: string;
"starting-state"?: "minimized" | "expanded";
theme?: "light" | "dark";
title?: string;
},
HTMLElement
>;
}
}
3. Uso com auth context
Renderize uma única vez no layout raiz, depois do login. O componente já é flutuante (starting-state="minimized" mostra só o botão no canto inferior direito).
// src/App.tsx
import { useAuth } from "./auth";
import { VannaChat } from "./components/VannaChat";
export default function App() {
const { user, currentStore } = useAuth();
return (
<>
<Routes>{/* ... suas rotas ... */}</Routes>
{user && currentStore && (
<VannaChat
programId={currentStore.programId}
storeId={currentStore.id}
userId={user.id}
/>
)}
</>
);
}
Importante: se o usuário trocar de loja (sem re-login), force remount adicionando key:
<VannaChat
key={`${currentStore.id}-${user.id}`}
programId={currentStore.programId}
storeId={currentStore.id}
userId={user.id}
/>
Sem key, o React reusa o mesmo elemento DOM com URLs antigas — a SSE em curso continua apontando pro tenant anterior até o usuário fechar o chat.
4. Botão flutuante custom (CTA "Conversar com meus dados")
Por padrão, <vanna-chat starting-state="minimized"> mostra um FAB redondo no canto. Substituímos por um CTA pill horizontal (logo + pill com gradient laranja→roxo).
CSS (cole no CSS global ou módulo)
.vanna-cta {
position: fixed;
right: 24px;
top: 100px; /* ou bottom: 24px se preferir o canto inferior direito */
z-index: 2147483000;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 4px;
background: #fff;
border: 1px solid transparent;
border-radius: 8px;
background-image: linear-gradient(#fff, #fff),
linear-gradient(90deg, #f2672a 5%, #680367 100%);
background-origin: border-box;
background-clip: padding-box, border-box;
box-shadow:
3px 5px 15px rgba(235, 98, 45, 0.19),
13px 24px 27px rgba(235, 98, 45, 0.16),
28px 53px 36px rgba(235, 98, 45, 0.10),
50px 95px 43px rgba(235, 98, 45, 0.03);
cursor: pointer;
font-family: "Open Sans", system-ui, sans-serif;
transition: transform 0.15s ease;
}
.vanna-cta:hover { transform: translateY(-1px); }
.vanna-cta__logo {
flex: none; width: 42px; height: 42px;
display: grid; place-items: center;
}
.vanna-cta__logo img,
.vanna-cta__logo svg { width: 100%; height: 100%; display: block; }
.vanna-cta__pill {
flex: 1; padding: 10px 18px; border-radius: 5px;
background: linear-gradient(90deg, #f2672a 5%, #680367 100%);
color: #fff; font-size: 14px; font-weight: 500; line-height: 1.15;
white-space: nowrap;
}
Esconder o FAB nativo do <vanna-chat>
Adicionar ao vanna-theme.css (servido pelo backend, ou seu próprio override):
:host(.minimized) {
background: transparent !important;
box-shadow: none !important;
width: 0 !important; height: 0 !important;
pointer-events: none !important;
}
:host(.minimized) .minimized-icon { display: none !important; }
/* CTA abre direto maximized — esconder restore e maximize do header
pra não expor o estado intermediário "normal". Sobra só .minimize. */
.window-control-btn.restore,
.window-control-btn.maximize { display: none !important; }
Sem isso, o FAB nativo aparece sobreposto ao CTA, e o usuário consegue cair no estado "normal" (janela pequena flutuante). Requer o theme pierce da etapa 1 — sem ele, essas regras não atingem o shadow DOM.
Componente React <VannaCTA />
// src/components/VannaCTA.tsx
import { useEffect, useState } from "react";
interface VannaCTAProps {
/** Ref opcional para o <vanna-chat>. Se omitido, busca por id="vanna-chat-instance". */
chatId?: string;
label?: string;
}
export function VannaCTA({ chatId = "vanna-chat-instance", label = "Conversar com meus dados" }: VannaCTAProps) {
const [visible, setVisible] = useState(true);
useEffect(() => {
let chat: any = null;
customElements.whenDefined("vanna-chat").then(() => {
chat = document.getElementById(chatId);
if (!chat) return;
const sync = (state: string) => setVisible(state === "minimized");
sync(chat.windowState);
const handler = (e: any) => sync(e.detail?.state);
chat.addEventListener("window-state-changed", handler);
return () => chat.removeEventListener("window-state-changed", handler);
});
}, [chatId]);
if (!visible) return null;
return (
<button
type="button"
className="vanna-cta"
aria-label={label}
onClick={() => {
const chat = document.getElementById(chatId) as any;
if (!chat) return;
// Esconder o CTA explicitamente — o setter de windowState não
// dispara window-state-changed (só os métodos privados do componente
// disparam), então o useEffect só sincroniza quando o user minimiza
// pelo header do chat.
setVisible(false);
chat.windowState = "maximized";
}}
>
<span className="vanna-cta__logo" aria-hidden="true">
{/* Hospede o SVG no seu CDN/public e troque o src.
No backend Vanna, fica em /clubpetro-logo.svg. */}
<img src="https://SEU-BACKEND-VANNA/clubpetro-logo.svg" alt="" />
</span>
<span className="vanna-cta__pill">{label}</span>
</button>
);
}
Renderize junto com o <VannaChat />:
{user && currentStore && (
<>
<VannaChat
key={`${currentStore.id}-${user.id}`}
programId={currentStore.programId}
storeId={currentStore.id}
userId={user.id}
/>
<VannaCTA />
</>
)}
5. Theming (opcional)
O tema é um vanna-theme.css com ~50 CSS custom properties. Pra mudar cores/fontes:
- Garanta que o theme pierce script (etapa 1) está rodando antes do bundle.
- Servir seu próprio
vanna-theme.css(pode ser o do backend Vanna, ou um override próprio). - Atualizar a URL do
fetchno theme pierce.
Tokens disponíveis: ver vanna/frontends/webcomponent/src/styles/vanna-design-tokens.ts no repo do backend. Exemplos:
:host, vanna-chat {
--vanna-primary: #023d60;
--vanna-accent: #15a8a8;
--vanna-radius-md: 8px;
--vanna-font-family: "Open Sans", system-ui, sans-serif;
}
Sem o pierce o :host não atinge filhos (Shadow DOM encapsulado) — vai parecer que o tema "não pegou".
6. Gotchas (cole na cabeça)
| Sintoma | Causa provável | Fix |
|---|---|---|
| Chat aparece mas sem cores customizadas | Theme pierce não rodou antes do bundle | Verifique ordem dos scripts no <head>; o pierce DEVE preceder o <script type="module"> |
showProgress=false ignorado, sidebar de progresso aparece |
Lit boolean attrs não desligam via HTML | Use a property em useEffect + customElements.whenDefined (ver componente acima) |
Mensagens com **foo** literal em vez de foo em negrito |
i18n IIFE não rodou (markdown parser dele que faz a conversão) | Garantir que o segundo script da etapa 1 está presente |
Search..., Export, X rows em inglês |
Mesmo motivo: i18n IIFE ausente | Idem |
| Chat retorna erro / não responde | program_id ou store_id ausentes/inválidos na URL — backend rejeita com PermissionError |
Inspecione a URL do sse-endpoint no DOM (DevTools → Elements); confirme que os 3 IDs aparecem |
Tudo funciona mas events.vanna_ai.user_id vem vazio |
Apenas o user_id ausente (não bloqueante) |
Adicionar &user_id=... na query string |
| Bundle não carrega — CORS error | Backend Vanna sem CORS pro domínio do frontend | Setar VANNA_CORS_ORIGINS no .env do backend pra incluir seu domínio |
| Bundle 7.5 MB → carregamento lento na home | É o tamanho do bundle Lit + Plotly | Usar <link rel="preload" as="script" href="...vanna-components.js" /> no <head> ou lazy-load só quando o usuário faz login |
7. Checklist final pra produção
- Backend
VANNA_CORS_ORIGINSinclui o domínio do frontend - Bundle servido via HTTPS (mixed content bloqueia em prod)
- Auth flow garante
program_id,store_id,user_idválidos antes de montar o componente keyno<VannaChat>muda quando user/loja troca- Open Sans pré-carregado via
<link rel="preconnect"> - (Opcional)
vanna-theme.cssservido com cache-busting (?v=hash) - Testou minimizado e expandido em mobile (o botão é fixo bottom-right)
- Verificou no console:
[vanna-theme] CSS loaded: NNNN bytese[vanna-i18n] frontend translator armed
Referência
- Demo de dev (com tudo wireado):
static/embed-demo.htmlno repo do backend. - Web component upstream:
vanna/frontends/webcomponent/src/components/vanna-chat.ts. - Endpoints:
vanna/src/vanna/servers/fastapi/routes.py.