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
402 lines
14 KiB
Markdown
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`.
|