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

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:

  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>).
<!-- 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:

  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:

: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.