vanna-clubpetro/docs/embed-react.md
leonardosalazar-cp 1d152c0dce Initial commit: Vanna 2.0 deployment for ClubPetro
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
2026-04-29 17:22:05 -03:00

402 lines
14 KiB
Markdown

# 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:
1. **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 todo `attachShadow` novo.
2. **i18n PT-BR** — traduz strings hardcoded do bundle (`Search...`, `X rows`, `Export`, etc.) e converte markdown literal nas mensagens (`**negrito**` → `<strong>`).
```html
<!-- 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
```html
<!-- 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 />`
```tsx
// 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`:
```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).
```tsx
// 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`:
```tsx
<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)
```css
.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):
```css
: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 />`
```tsx
// 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 />`:
```tsx
{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:
1. Garanta que o **theme pierce script** (etapa 1) está rodando antes do bundle.
2. Servir seu próprio `vanna-theme.css` (pode ser o do backend Vanna, ou um override próprio).
3. Atualizar a URL do `fetch` no theme pierce.
Tokens disponíveis: ver `vanna/frontends/webcomponent/src/styles/vanna-design-tokens.ts` no repo do backend. Exemplos:
```css
: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_ORIGINS` inclui o domínio do frontend
- [ ] Bundle servido via HTTPS (mixed content bloqueia em prod)
- [ ] Auth flow garante `program_id`, `store_id`, `user_id` válidos antes de montar o componente
- [ ] `key` no `<VannaChat>` muda quando user/loja troca
- [ ] Open Sans pré-carregado via `<link rel="preconnect">`
- [ ] (Opcional) `vanna-theme.css` servido 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 bytes` e `[vanna-i18n] frontend translator armed`
---
## Referência
- Demo de dev (com tudo wireado): `static/embed-demo.html` no repo do backend.
- Web component upstream: `vanna/frontends/webcomponent/src/components/vanna-chat.ts`.
- Endpoints: `vanna/src/vanna/servers/fastapi/routes.py`.