feat: AI chat 'Sollinha' — campaign data assistant (v1 read-only)

- Streaming AI endpoint at /campanha/api/ai-chat with DeepSeek V4 Flash
- 8 tools: calculate, getMunicipalityVotes, getTopDeputies, getDobradinhas,
  getMunicipalityOverview, getLeaderships, getOrganizations, searchEntities
- In-memory rate limiter (50 msgs / 15 min per user)
- Desktop: resizable right sidebar via react-resizable-panels
- Mobile: full-screen Drawer opened by FAB (2/3 size of quick-actions FAB)
- Markdown rendering with react-markdown + remark-gfm (tables, bold, lists)
- Session-bound context (clean slate each open)
- Auth via existing campaign-token cookie, tools respect user RBAC
- Architecture doc: docs/plans/ai-chat-sollinha.md
- E2E tests: campaignAiChat.e2e.spec.ts (mocked endpoint)
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-08-04 05:47:35 -03:00
parent b74268714c
commit f9cd1df7b2
35 changed files with 2475 additions and 32 deletions

View file

@ -35,3 +35,7 @@ CAMPAIGN_EMAIL_FROM_NAME=Campanha Jorge Solla
# Note: the automated test suite ignores this file and reads .env.test instead,
# and only ever runs against a database whose name ends with "_test".
# DeepSeek API key for the AI chat feature (Sollinha).
# Get one at https://platform.deepseek.com/api_keys
DEEPSEEK_API_KEY=YOUR_DEEPSEEK_API_KEY_HERE

View file

@ -0,0 +1,170 @@
# AI Chat — "Sollinha" (assistente virtual da campanha)
Status: entregue (2026-08-04 — v1 read-only)
Atualizado em: 2026-08-04
Item do roadmap: feature nova (não numerada em trilha)
Appetite: ~2 dias eng; API route + tools + chat UI com shadcn; sem migration, sem collection, sem Consent
## Design
Âncoras: `PRODUCT.md` (Field Desk; PWA de campo) / `DESIGN.md` · tema `data-theme='campaign'`.
Na implementação: **chat sidebar no desktop, drawer full-screen no mobile** — o assessor mantém a tela principal visível enquanto conversa com a IA.
Brief compacto:
- **Persona / contexto:** Assessor/coordenador navegando em `/campanha` quer fazer perguntas sobre os dados sem sair da tela atual ("Quantos votos tivemos em Ilhéus em 2022?", "Quem é o deputado mais votado em Feira?", "Quais dobradinhas temos em Salvador?").
- **Job principal:** abrir chat, perguntar em linguagem natural, receber resposta com dados reais do banco.
- **Estratégia de cor:** Restrained — botão 🤖 no header (desktop + mobile), ao lado do sino de notificações. Sheet cinza com bubbles padrão shadcn.
- **Edit where you see:** não — chat.
- **Anti-goals:** substituir navegação pela IA; persistir histórico entre sessões (complexidade desnecessária no v1); write tools no v1.
## Dados → decisão → apresentação
Dados: TSE 2014/2018/2022 (`electionCandidateVote`, `electionTally`), municípios, lideranças, dobradinhas, organizações.
## Arquitetura
### Fluxo de dados
```
┌──────────┐ POST /campanha/api/ai-chat ┌──────────────┐
│ Client │ ───────────────────────────────> │ Route (RSC) │
│ useChat │ <─────────────────────────────── │ streamText │
│ (shadcn) │ Streaming (Server-Sent Events) │ + tools │
└──────────┘ └──────┬───────┘
│
┌──────────▼───────┐
│ Payload Local │
│ API (user-scoped│
│ access control) │
└──────────────────┘
```
### Camadas
| Camada | Localização | Responsabilidade |
| ------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| API route | `src/app/(campaign)/campanha/api/ai-chat/route.ts` | Streaming endpoint: auth via `campaign-token`, rate limit, `streamText` com DeepSeek V4 Flash |
| Tools | `src/utilities/ai/tools/*.ts` | 8 funções que o modelo chama: queries Payload com `user` scoped |
| System prompt | `src/utilities/ai/systemPrompt.ts` | Personalidade "Sollinha" + domínio eleitoral da Bahia |
| Rate limiter | `src/utilities/ai/rateLimit.ts` | In-memory token bucket: 50 mensagens por 15 min por usuário |
| Chat UI | `src/components/campaign/shell/ai/*.tsx` | `useChat` hook + shadcn `message`/`bubble`/`input-group` |
| Sheet | `src/components/campaign/shell/ai/CampaignAIChatSheet.tsx` | Sheet (desktop) / Drawer (mobile) — wrapper responsivo |
| Header button | `src/components/campaign/shell/ai/CampaignAIHeaderButton.tsx` | Botão 🤖 no `CampaignDesktopHeader` e `CampaignMobileTopBar` |
| Types | `src/lib/ai/types.ts` | `AIToolContext = { user, payload }` |
### Modelo
- **Modelo:** `deepseek-v4-flash` via `@ai-sdk/deepseek`
- **Tool calling:** nativo (suportado pelo modelo)
- **Multi-step:** `stopWhen: stepCountIs(10)` — permite tool → resposta → tool encadeado
- **Streaming:** `result.toUIMessageStreamResponse()` — Server-Sent Events
### Contexto
- **Sem persistência:** cada abertura do chat = sessão nova. `useChat` sem `id`/`initialMessages`.
- **Sem histórico entre abas/dispositivos:** a complexidade de threads fica para v2.
- **Limpeza implícita:** fechar o Sheet/Drawer descarta o estado React.
### Rate limiting
- **Algoritmo:** in-memory token bucket, keyed by `userId`.
- **Limite:** 50 mensagens por janela de 15 minutos.
- **Resposta no estouro:** HTTP 429 com mensagem em pt-BR.
- **Resiliência:** sobrevive a um servidor; reseta no deploy. Suficiente para v1.
## Ferramentas (v1 read-only)
| # | Tool | Descrição | Fonte de dados |
| --- | ------------------------- | --------------------------------------------------------------- | -------------------------------------------- |
| 1 | `calculate` | Aritmética segura (sem `eval`) — percentuais, somas, taxas | Puro, sem DB |
| 2 | `getMunicipalityVotes` | Votos do Solla por ano + voto válido no município | `electionCandidateVote` + `electionTally` |
| 3 | `getTopDeputies` | Ranking de deputados federais mais votados no município/ano | `electionCandidateVote` |
| 4 | `getDobradinhas` | Parcerias com deputados estaduais, filtráveis por município | `stateDeputy` + `municipality.stateDeputies` |
| 5 | `getMunicipalityOverview` | Nível de engajamento, prioridade, lideranças, compromissos | `municipality` + contagens |
| 6 | `getLeaderships` | Lideranças por município ou nome | `leadership` |
| 7 | `getOrganizations` | Organizações por município, tipo ou nome | `organization` |
| 8 | `searchEntities` | Busca fuzzy cross-collection (fallback quando pergunta ambígua) | 4 coleções em paralelo |
Cada tool recebe `{ user, payload }` e chama a Local API com `overrideAccess: false` + `user` (respeita RBAC) ou `overrideAccess: true` com justificativa (dados eleitorais públicos).
## Decisões travadas
- **Vercel AI SDK (`ai` + `@ai-sdk/deepseek`), não LangChain/Mastra.** O AI SDK é first-party Next.js, tem `streamText` com tools nativo, `useChat` hook no cliente, e o projeto já está em Next.js. **Rejeitado:** LangChain (overhead de abstração sem ganho para tools simples); Mastra (framework de agentes completo, desnecessário para v1 de chat com tools).
- **Modelo `deepseek-v4-flash`, não `deepseek-chat` legado.** O nome legado `deepseek-chat` será descontinuado em 2026-07-24. O V4 Flash suporta tool calling e tem 128K tokens de input. **Rejeitado:** `deepseek-v4-pro` (mais caro, desnecessário para queries de dados); `deepseek-reasoner` (latência maior, sem ganho para tools).
- **8 tools específicas + 1 calculadora, não 1 tool genérica "query".** Tools com propósitos não-sobrepostos melhoram o routing do modelo. **Rejeitado:** uma tool "query anything" faz o modelo alucinar a sintaxe de query; uma tool "sql" é insegura e desnecessária (Payload já é a camada de query).
- **shadcn chat components (`message`, `bubble`, `marker`, `input-group`), não hand-rolled.** shadcn lançou chat components em junho/2026; o projeto já usa shadcn; a qualidade visual e de acessibilidade é superior a hand-rolling. **Rejeitado:** `@chatscope/chat-ui-kit-react` (lib externa, bundle maior, menos integração com Tailwind/shadcn).
- **Sheet (desktop) / Drawer (mobile), não modal Dialog.** O assessor precisa manter a tela principal visível enquanto interage com a IA. **Rejeitado:** Dialog modal (bloqueia a tela principal); página separada (perde o contexto de onde o usuário estava).
- **Header button, não FAB.** Produto pediu FAB inicialmente, mas o header é mais discoverable e consistente com o sino de notificações. **Rejeitado:** FAB flutuante (competiria com o FAB de ações rápidas); ícone no sidebar (menos visível, navegação já tem densidade alta).
- **Contexto limpo a cada abertura.** Complexidade de threads/histórico é desnecessária para o target user (assessor de campanha em campo). **Rejeitado:** persistir conversas no DB (migration, UI de histórico, cleanup — v2); auto-clean por inatividade (surpreende o usuário que volta).
## Dependências
Nenhuma — feature independente. Não depende de migrações, collections novas, Consent, ou outras features.
## Não escopo (v1)
- Write tools (criar/editar lideranças, pledges, atividades via chat)
- Histórico persistente entre sessões
- Web search (grounding externo)
- Speech-to-text (Assembly AI)
- Upload de arquivos/áudio no chat
- Suporte a múltiplos modelos
## Roadmap futuro (arquitetura preparada)
| Feature | Como a arquitetura atual suporta |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Write tools | Adicionar tool em `src/utilities/ai/tools/`, registrar no `index.ts`. Usar `payload.update`/`payload.create` com `user` scoped — mesmo padrão das tools de leitura. |
| Web research | Adicionar tool `webSearch` que chama uma API externa. O `streamText` com tools suporta nativamente. |
| Speech-to-text | `InputGroup` aceita slot de addon. Adicionar botão de microfone que grava → envia para Assembly AI via server action → injeta transcrição como `sendMessage({ text })`. |
| Meeting recording | Pipeline: gravar → Assembly AI transcript → `sendMessage({ text: "Resuma esta reunião e sugira atualizações nos dados: ..." })`. |
| Mudar de modelo | Trocar `deepSeek('deepseek-v4-flash')` por outro provider no `route.ts` — tools e UI não mudam. |
| Histórico persistente | Passar `id` para `useChat` + salvar mensagens em collection Payload. Migração simples (uma collection `aiChatMessage`). |
## Testes
| Camada | Cobertura |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Unit | `campaignMobileTopBar.unit.spec.tsx` cobre o botão AI no header mobile (existente, estendido); `codebaseConventions.unit.spec.ts` cobre a allowlist do route |
| Int | Queries Payload das tools são cobertas indiretamente pelos int tests dos data loaders existentes (`municipalityElectoralBaseline`, `votePledgeViews`, etc.) |
| E2e | `tests/e2e/ai-chat.e2e.spec.ts` — abre o Sheet, digita mensagem, verifica resposta mockada. Mocka `/campanha/api/ai-chat` com `page.route()` para não depender de API key real no CI. |
### E2e mocking strategy
O endpoint de chat é mockado no Playwright com `page.route()`:
```ts
await page.route('**/campanha/api/ai-chat', async (route) => {
// Retorna um stream SSE mínimo que o useChat entende
await route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
body: 'data: {"type":"text","text":"Olá! Como posso ajudar?"}\n\n',
})
})
```
Isso permite testar o fluxo completo de UI (botão → Sheet → digitar → resposta) sem chave de API no CI. **Não é necessário `DEEPSEEK_API_KEY` como repo secret.**
## Configuração de ambiente
| Variável | Onde | Obrigatória |
| ------------------ | --------------------- | ------------------------------ |
| `DEEPSEEK_API_KEY` | Vercel production env | Sim (produção) |
| `DEEPSEEK_API_KEY` | `.env.local` | Sim (desenvolvimento local) |
| `DEEPSEEK_API_KEY` | GitHub repo secrets | **Não** (e2e mocka o endpoint) |
## Referências
- `src/app/(campaign)/campanha/api/ai-chat/route.ts` — endpoint
- `src/utilities/ai/tools/index.ts` — registro de tools
- `src/utilities/ai/systemPrompt.ts` — prompt do sistema
- `src/components/campaign/shell/ai/CampaignAIChat.tsx` — UI do chat
- `src/components/campaign/shell/ai/CampaignAIChatSheet.tsx` — Sheet/Drawer
- `src/components/campaign/shell/ai/CampaignAIHeaderButton.tsx` — botão no header
- `src/components/campaign/shell/CampaignDesktopHeader.tsx` — header desktop
- `src/components/campaign/shell/CampaignMobileTopBar.tsx` — header mobile
- AGENTS.md — Campaign auth, Municípios model
- `PRODUCT.md` / `DESIGN.md`

View file

@ -10,6 +10,7 @@ export const legacyComponentFilenameIgnores = [
'src/components/socialIcons.tsx',
'src/components/socialLinks.tsx',
'src/components/ui/breadcrumb.tsx',
'src/components/ui/bubble.tsx',
'src/components/ui/button.tsx',
'src/components/ui/card.tsx',
'src/components/ui/combobox.tsx',
@ -18,6 +19,8 @@ export const legacyComponentFilenameIgnores = [
'src/components/ui/input-group.tsx',
'src/components/ui/input.tsx',
'src/components/ui/label.tsx',
'src/components/ui/marker.tsx',
'src/components/ui/message.tsx',
'src/components/ui/native-select.tsx',
'src/components/ui/separator.tsx',
'src/components/ui/skeleton.tsx',

View file

@ -10,7 +10,11 @@
"tests/helpers/serverOnly.ts"
],
"project": ["src/**/*.{ts,tsx,js}", "scripts/**/*.mjs", "tests/**/*.ts"],
"ignore": [],
"ignore": [
"src/components/ui/bubble.tsx",
"src/components/ui/marker.tsx",
"src/components/ui/message.tsx"
],
"paths": {
"server-only": ["tests/helpers/serverOnly.ts"]
},

View file

@ -75,6 +75,8 @@
"agent:status": "node scripts/agent-status.mjs"
},
"dependencies": {
"@ai-sdk/deepseek": "^3.0.19",
"@ai-sdk/react": "^4.0.50",
"@base-ui/react": "^1.6.0",
"@hookform/resolvers": "^5.2.2",
"@payloadcms/db-postgres": "3.82.0",
@ -89,6 +91,7 @@
"@simplewebauthn/server": "^13.3.2",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"ai": "^7.0.47",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -104,6 +107,9 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.1",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.2",
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1",
"shadcn": "^4.0.3",
"sharp": "0.34.2",

View file

@ -8,6 +8,12 @@ importers:
.:
dependencies:
'@ai-sdk/deepseek':
specifier: ^3.0.19
version: 3.0.19(zod@4.3.6)
'@ai-sdk/react':
specifier: ^4.0.50
version: 4.0.50(react@19.2.4)(zod@4.3.6)
'@base-ui/react':
specifier: ^1.6.0
version: 1.6.0(@date-fns/tz@1.2.0)(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -50,6 +56,9 @@ importers:
'@vercel/speed-insights':
specifier: ^1.3.1
version: 1.3.1(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(react@19.2.4)
ai:
specifier: ^7.0.47
version: 7.0.47(zod@4.3.6)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -95,6 +104,15 @@ importers:
react-hook-form:
specifier: ^7.71.1
version: 7.71.1(react@19.2.4)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
react-resizable-panels:
specifier: ^4.12.2
version: 4.12.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
server-only:
specifier: ^0.0.1
version: 0.0.1
@ -264,6 +282,40 @@ packages:
'@acemir/cssom@0.9.31':
resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
'@ai-sdk/deepseek@3.0.19':
resolution: {integrity: sha512-APzL/IpDSLhVlKQXo1gKCvTDLeli98ITzKnMq/eUNVWZ/UzWKYYG/0mCsmJRwTC37YE0ZzpxwDBw3J8xQT3lxg==}
engines: {node: '>=22'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/gateway@4.0.36':
resolution: {integrity: sha512-N1P6bdW/aC5rxLeuGYgx3X4el3DoZy8UWlky+g+AeIZSmxaEi/AToHJL4cmZ6nCPHk1byqJWwC+PaOZG0hK0dw==}
engines: {node: '>=22'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/mcp@2.0.22':
resolution: {integrity: sha512-j3jPwKFTffZCJzYGpN69yKu/y646uu2gWoEwh1jgVyYVpzfu6ErFU+YAd/+xTPxsUTx8FEIEUHTlEsDuOe+ytQ==}
engines: {node: '>=22'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@5.0.18':
resolution: {integrity: sha512-UBNCrkxS5llgN2/RXLBRkjCFTIuL6YB1Goq5c+yRecnznhtX4XPjTwLHz2hrsTltTVZ0rqVegtnwFXdPBkjDHA==}
engines: {node: '>=22'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider@4.0.4':
resolution: {integrity: sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ==}
engines: {node: '>=22'}
'@ai-sdk/react@4.0.50':
resolution: {integrity: sha512-fbC0V2WdCwZaioSJ0CGT/YnrhzpNbiDFqoBlrJ7HL0OY95sIUQrPDVlGYz489/hj4Lze4tr7RfPc8ghgc/5wcQ==}
engines: {node: '>=22'}
peerDependencies:
react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@ -3420,6 +3472,9 @@ packages:
resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==}
hasBin: true
'@ungap/structured-clone@1.3.3':
resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==}
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
cpu: [arm]
@ -3545,6 +3600,10 @@ packages:
resolution: {integrity: sha512-6f9oWC+DbWxIgBLOdqjjn2/REpFrPDB7y5B5HA1ptYkzZaBgL6E34kWrptJvJ7teApJdbAs3I1a5A7z1y8SDHw==}
engines: {node: '>=20.0.0'}
'@vercel/oidc@3.2.0':
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
engines: {node: '>= 20'}
'@vercel/speed-insights@1.3.1':
resolution: {integrity: sha512-PbEr7FrMkUrGYvlcLHGkXdCkxnylCWePx7lPxxq36DNdfo9mcUjLOmqOyPDHAOgnfqgGGdmE3XI9L/4+5fr+vQ==}
peerDependencies:
@ -3618,6 +3677,9 @@ packages:
'@vue/shared@3.5.40':
resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==}
'@workflow/serde@4.1.0':
resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@ -3645,6 +3707,12 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
ai@7.0.47:
resolution: {integrity: sha512-e0MpNtufu6JmcmwMUTgM1smCRkP5z014iPaKgnCm7kmMsTSIZbOJN2vglhPq0WIj/6x7ewVi6W0FyIR0pjaE3Q==}
engines: {node: '>=22'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
@ -3790,6 +3858,9 @@ packages:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
engines: {node: '>=10', npm: '>=6'}
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@ -3995,6 +4066,9 @@ packages:
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
commander@11.1.0:
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
engines: {node: '>=16'}
@ -4531,6 +4605,10 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
escodegen@2.1.0:
resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==}
engines: {node: '>=6.0'}
@ -4693,6 +4771,10 @@ packages:
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
engines: {node: '>=18.0.0'}
eventsource-parser@3.1.0:
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
engines: {node: '>=18.0.0'}
eventsource@3.0.7:
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
engines: {node: '>=18.0.0'}
@ -4723,6 +4805,9 @@ packages:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
fast-copy@3.0.2:
resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==}
@ -5021,6 +5106,12 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
headers-polyfill@4.0.3:
resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
@ -5042,6 +5133,9 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@ -5112,6 +5206,9 @@ packages:
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@ -5427,6 +5524,9 @@ packages:
json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@ -5621,6 +5721,9 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
marked@14.0.0:
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
engines: {node: '>= 18'}
@ -5633,15 +5736,45 @@ packages:
md5@2.3.0:
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
mdast-util-find-and-replace@3.0.2:
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
mdast-util-from-markdown@2.0.2:
resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==}
mdast-util-gfm-autolink-literal@2.0.1:
resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
mdast-util-gfm-footnote@2.1.0:
resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
mdast-util-gfm-strikethrough@2.0.0:
resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
mdast-util-gfm-table@2.0.0:
resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
mdast-util-gfm-task-list-item@2.0.0:
resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
mdast-util-mdx-jsx@3.1.3:
resolution: {integrity: sha512-bfOjvNt+1AcbPLTFMFWY149nJz0OjmewJs3LQQ5pIyVGxP4CdOqNVJL6kTaM5c68p8q82Xv3nCyFfUnuEcH3UQ==}
mdast-util-mdxjs-esm@2.0.1:
resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
mdast-util-phrasing@4.1.0:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
mdast-util-to-hast@13.2.1:
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
mdast-util-to-markdown@2.1.2:
resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
@ -5678,6 +5811,27 @@ packages:
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
micromark-extension-gfm-autolink-literal@2.1.0:
resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
micromark-extension-gfm-footnote@2.1.0:
resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
micromark-extension-gfm-strikethrough@2.1.0:
resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
micromark-extension-gfm-table@2.1.1:
resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==}
micromark-extension-gfm-tagfilter@2.0.0:
resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
micromark-extension-gfm-task-list-item@2.1.0:
resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
micromark-extension-gfm@3.0.0:
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
micromark-extension-mdx-jsx@3.0.1:
resolution: {integrity: sha512-vNuFb9czP8QCtAQcEJn0UJQJZA8Dk6DXKBqx+bg/w0WGuSxDxNr7hErW89tHUY31dUW4NqEOWwmEUNhjTFmHkg==}
@ -6308,6 +6462,9 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
property-information@7.2.0:
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@ -6406,6 +6563,12 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
'@types/react': '>=18'
react: '>=18'
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@ -6430,6 +6593,12 @@ packages:
'@types/react':
optional: true
react-resizable-panels@4.12.2:
resolution: {integrity: sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
react-select@5.9.0:
resolution: {integrity: sha512-nwRKGanVHGjdccsnzhFte/PULziueZxGD8LL2WojON78Mvnq7LdAMEtu2frrwld1fr3geixg3iiMBIc/LLAZpw==}
peerDependencies:
@ -6483,6 +6652,18 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
remark-parse@11.0.0:
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
remark-rehype@11.1.2:
resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
@ -6751,6 +6932,9 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
@ -6874,6 +7058,12 @@ packages:
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
engines: {node: '>=18'}
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
style-to-object@1.0.14:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@ -6903,6 +7093,11 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
swr@2.4.2:
resolution: {integrity: sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw==}
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@ -7009,6 +7204,12 @@ packages:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
trough@2.2.0:
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
truncate-utf8-bytes@1.0.2:
resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==}
@ -7144,16 +7345,26 @@ packages:
resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==}
engines: {node: '>=20.18.1'}
undici@7.29.0:
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
engines: {node: '>=20.18.1'}
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
unist-util-position-from-estree@2.0.0:
resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==}
unist-util-position@5.0.0:
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
unist-util-stringify-position@4.0.0:
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
@ -7251,6 +7462,9 @@ packages:
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite-tsconfig-paths@6.0.5:
resolution: {integrity: sha512-f/WvY6ekHykUF1rWJUAbCU7iS/5QYDIugwpqJA+ttwKbxSbzNlqlE8vZSrsnxNQciUW+z6lvhlXMaEyZn9MSig==}
peerDependencies:
@ -7530,6 +7744,51 @@ snapshots:
'@acemir/cssom@0.9.31': {}
'@ai-sdk/deepseek@3.0.19(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 4.0.4
'@ai-sdk/provider-utils': 5.0.18(zod@4.3.6)
zod: 4.3.6
'@ai-sdk/gateway@4.0.36(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 4.0.4
'@ai-sdk/provider-utils': 5.0.18(zod@4.3.6)
'@vercel/oidc': 3.2.0
zod: 4.3.6
'@ai-sdk/mcp@2.0.22(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 4.0.4
'@ai-sdk/provider-utils': 5.0.18(zod@4.3.6)
pkce-challenge: 5.0.1
zod: 4.3.6
'@ai-sdk/provider-utils@5.0.18(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 4.0.4
'@standard-schema/spec': 1.1.0
'@workflow/serde': 4.1.0
eventsource-parser: 3.1.0
undici: 7.29.0
zod: 4.3.6
'@ai-sdk/provider@4.0.4':
dependencies:
json-schema: 0.4.0
'@ai-sdk/react@4.0.50(react@19.2.4)(zod@4.3.6)':
dependencies:
'@ai-sdk/mcp': 2.0.22(zod@4.3.6)
'@ai-sdk/provider': 4.0.4
'@ai-sdk/provider-utils': 5.0.18(zod@4.3.6)
ai: 7.0.47(zod@4.3.6)
react: 19.2.4
swr: 2.4.2(react@19.2.4)
throttleit: 2.1.0
transitivePeerDependencies:
- zod
'@alloc/quick-lru@5.2.0': {}
'@antfu/ni@25.0.0':
@ -10708,6 +10967,8 @@ snapshots:
dependencies:
'@typescript/old': typescript@6.0.3
'@ungap/structured-clone@1.3.3': {}
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
optional: true
@ -10780,6 +11041,8 @@ snapshots:
throttleit: 2.1.0
undici: 6.27.0
'@vercel/oidc@3.2.0': {}
'@vercel/speed-insights@1.3.1(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(react@19.2.4)':
optionalDependencies:
next: 15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4)
@ -10869,6 +11132,8 @@ snapshots:
'@vue/shared@3.5.40': {}
'@workflow/serde@4.1.0': {}
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@ -10886,6 +11151,13 @@ snapshots:
agent-base@7.1.4: {}
ai@7.0.47(zod@4.3.6):
dependencies:
'@ai-sdk/gateway': 4.0.36(zod@4.3.6)
'@ai-sdk/provider': 4.0.4
'@ai-sdk/provider-utils': 5.0.18(zod@4.3.6)
zod: 4.3.6
ajv-formats@3.0.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
@ -11063,6 +11335,8 @@ snapshots:
cosmiconfig: 7.1.0
resolve: 1.22.11
bail@2.0.2: {}
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
@ -11272,6 +11546,8 @@ snapshots:
colorette@2.0.20: {}
comma-separated-tokens@2.0.3: {}
commander@11.1.0: {}
commander@12.1.0: {}
@ -11821,6 +12097,8 @@ snapshots:
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
escodegen@2.1.0:
dependencies:
esprima: 4.0.1
@ -12056,6 +12334,8 @@ snapshots:
eventsource-parser@3.0.6: {}
eventsource-parser@3.1.0: {}
eventsource@3.0.7:
dependencies:
eventsource-parser: 3.0.6
@ -12131,6 +12411,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
extend@3.0.2: {}
fast-copy@3.0.2: {}
fast-deep-equal@3.1.3: {}
@ -12453,6 +12735,30 @@ snapshots:
dependencies:
function-bind: 1.1.2
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
'@types/hast': 3.0.4
'@types/unist': 3.0.3
comma-separated-tokens: 2.0.3
devlop: 1.1.0
estree-util-is-identifier-name: 3.0.0
hast-util-whitespace: 3.0.0
mdast-util-mdx-expression: 2.0.1
mdast-util-mdx-jsx: 3.1.3
mdast-util-mdxjs-esm: 2.0.1
property-information: 7.2.0
space-separated-tokens: 2.0.2
style-to-js: 1.1.21
unist-util-position: 5.0.0
vfile-message: 4.0.3
transitivePeerDependencies:
- supports-color
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
headers-polyfill@4.0.3: {}
help-me@5.0.0: {}
@ -12473,6 +12779,8 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
html-url-attributes@3.0.1: {}
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@ -12530,6 +12838,8 @@ snapshots:
ini@1.3.8: {}
inline-style-parser@0.2.7: {}
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@ -12812,6 +13122,8 @@ snapshots:
json-schema-typed@8.0.2: {}
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json5@1.0.2:
@ -12999,6 +13311,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
markdown-table@3.0.4: {}
marked@14.0.0: {}
math-intrinsics@1.1.0: {}
@ -13009,6 +13323,13 @@ snapshots:
crypt: 0.0.2
is-buffer: 1.1.6
mdast-util-find-and-replace@3.0.2:
dependencies:
'@types/mdast': 4.0.4
escape-string-regexp: 5.0.0
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
mdast-util-from-markdown@2.0.2:
dependencies:
'@types/mdast': 4.0.4
@ -13026,6 +13347,74 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-gfm-autolink-literal@2.0.1:
dependencies:
'@types/mdast': 4.0.4
ccount: 2.0.1
devlop: 1.1.0
mdast-util-find-and-replace: 3.0.2
micromark-util-character: 2.1.1
mdast-util-gfm-footnote@2.1.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
micromark-util-normalize-identifier: 2.0.1
transitivePeerDependencies:
- supports-color
mdast-util-gfm-strikethrough@2.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm-table@2.0.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
markdown-table: 3.0.4
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm-task-list-item@2.0.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm@3.1.0:
dependencies:
mdast-util-from-markdown: 2.0.2
mdast-util-gfm-autolink-literal: 2.0.1
mdast-util-gfm-footnote: 2.1.0
mdast-util-gfm-strikethrough: 2.0.0
mdast-util-gfm-table: 2.0.0
mdast-util-gfm-task-list-item: 2.0.0
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-mdx-jsx@3.1.3:
dependencies:
'@types/estree-jsx': 1.0.5
@ -13043,11 +13432,34 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-mdxjs-esm@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-phrasing@4.1.0:
dependencies:
'@types/mdast': 4.0.4
unist-util-is: 6.0.1
mdast-util-to-hast@13.2.1:
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@ungap/structured-clone': 1.3.3
devlop: 1.1.0
micromark-util-sanitize-uri: 2.0.1
trim-lines: 3.0.1
unist-util-position: 5.0.0
unist-util-visit: 5.1.0
vfile: 6.0.3
mdast-util-to-markdown@2.1.2:
dependencies:
'@types/mdast': 4.0.4
@ -13099,6 +13511,64 @@ snapshots:
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-autolink-literal@2.1.0:
dependencies:
micromark-util-character: 2.1.1
micromark-util-sanitize-uri: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-footnote@2.1.0:
dependencies:
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-normalize-identifier: 2.0.1
micromark-util-sanitize-uri: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-strikethrough@2.1.0:
dependencies:
devlop: 1.1.0
micromark-util-chunked: 2.0.1
micromark-util-classify-character: 2.0.1
micromark-util-resolve-all: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-table@2.1.1:
dependencies:
devlop: 1.1.0
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-tagfilter@2.0.0:
dependencies:
micromark-util-types: 2.0.2
micromark-extension-gfm-task-list-item@2.1.0:
dependencies:
devlop: 1.1.0
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm@3.0.0:
dependencies:
micromark-extension-gfm-autolink-literal: 2.1.0
micromark-extension-gfm-footnote: 2.1.0
micromark-extension-gfm-strikethrough: 2.1.0
micromark-extension-gfm-table: 2.1.1
micromark-extension-gfm-tagfilter: 2.0.0
micromark-extension-gfm-task-list-item: 2.1.0
micromark-util-combine-extensions: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-mdx-jsx@3.0.1:
dependencies:
'@types/acorn': 4.0.6
@ -13907,6 +14377,8 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
property-information@7.2.0: {}
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@ -14050,6 +14522,24 @@ snapshots:
react-is@17.0.2: {}
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4):
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/react': 19.2.14
devlop: 1.1.0
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.1
react: 19.2.4
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
unist-util-visit: 5.1.0
vfile: 6.0.3
transitivePeerDependencies:
- supports-color
react-refresh@0.17.0: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4):
@ -14071,6 +14561,11 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
react-resizable-panels@4.12.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-select@5.9.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@babel/runtime': 7.29.7
@ -14149,6 +14644,40 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
mdast-util-gfm: 3.1.0
micromark-extension-gfm: 3.0.0
remark-parse: 11.0.0
remark-stringify: 11.0.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-parse@11.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-from-markdown: 2.0.2
micromark-util-types: 2.0.2
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-rehype@11.1.2:
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
mdast-util-to-hast: 13.2.1
unified: 11.0.5
vfile: 6.0.3
remark-stringify@11.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
@ -14557,6 +15086,8 @@ snapshots:
source-map@0.6.1: {}
space-separated-tokens@2.0.2: {}
split2@4.2.0: {}
ssf@0.11.2:
@ -14695,6 +15226,14 @@ snapshots:
dependencies:
'@tokenizer/token': 0.3.0
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
style-to-object@1.0.14:
dependencies:
inline-style-parser: 0.2.7
styled-jsx@5.1.6(@babel/core@7.29.0)(babel-plugin-macros@3.1.0)(react@19.2.4):
dependencies:
client-only: 0.0.1
@ -14715,6 +15254,12 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
swr@2.4.2(react@19.2.4):
dependencies:
dequal: 2.0.3
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
symbol-tree@3.2.4: {}
tabbable@6.4.0: {}
@ -14806,6 +15351,10 @@ snapshots:
dependencies:
punycode: 2.3.1
trim-lines@3.0.1: {}
trough@2.2.0: {}
truncate-utf8-bytes@1.0.2:
dependencies:
utf8-byte-length: 1.0.5
@ -14961,8 +15510,20 @@ snapshots:
undici@7.24.4: {}
undici@7.29.0: {}
unicorn-magic@0.3.0: {}
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
bail: 2.0.2
devlop: 1.1.0
extend: 3.0.2
is-plain-obj: 4.1.0
trough: 2.2.0
vfile: 6.0.3
unist-util-is@6.0.1:
dependencies:
'@types/unist': 3.0.3
@ -14971,6 +15532,10 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
unist-util-position@5.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-stringify-position@4.0.0:
dependencies:
'@types/unist': 3.0.3
@ -15073,6 +15638,11 @@ snapshots:
'@types/unist': 3.0.3
unist-util-stringify-position: 4.0.0
vfile@6.0.3:
dependencies:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-tsconfig-paths@6.0.5(@typescript/typescript6@6.0.2)(vite@7.3.1(@types/node@22.19.9)(jiti@2.7.0)(lightningcss@1.30.2)(sass@1.77.4)(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
debug: 4.4.3

View file

@ -5,6 +5,7 @@ import { getPayload } from 'payload'
import React from 'react'
import { CampaignListPendingBoundary } from '@/components/campaign/shared/CampaignListPending'
import { CampaignAISidebarShell } from '@/components/campaign/shell/ai/CampaignAISidebarShell'
import {
BiometricEnrollmentToast,
type BiometricEnrollmentOffer,
@ -72,6 +73,7 @@ export default async function CampaignAppLayout({ children }: { children: React.
<CampaignSidebarViewportDefault hasSidebarCookie={hasSidebarCookie} />
<CampaignSidebar user={campaignUserShellView(user)} />
<SidebarInset className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible">
<CampaignAISidebarShell>
<CampaignPageChromeProvider role={user.role}>
<CampaignWizardChromeProvider>
<CampaignHomeSearchChromeProvider>
@ -91,7 +93,11 @@ export default async function CampaignAppLayout({ children }: { children: React.
without a provider and crashed the page (B102).
*/}
<TooltipProvider delayDuration={300}>
<CampaignAppScrollChrome role={user.role}>{children}</CampaignAppScrollChrome>
<div className="flex min-h-0 flex-1 flex-col">
<CampaignAppScrollChrome role={user.role}>
{children}
</CampaignAppScrollChrome>
</div>
</TooltipProvider>
<Toaster position="top-center" />
<InstallPwaToast />
@ -103,6 +109,7 @@ export default async function CampaignAppLayout({ children }: { children: React.
</CampaignHomeSearchChromeProvider>
</CampaignWizardChromeProvider>
</CampaignPageChromeProvider>
</CampaignAISidebarShell>
</SidebarInset>
</SidebarProvider>
)

View file

@ -0,0 +1,51 @@
import { deepSeek } from '@ai-sdk/deepseek'
import configPromise from '@payload-config'
import {
convertToModelMessages,
createUIMessageStreamResponse,
stepCountIs,
streamText,
toUIMessageStream,
} from 'ai'
import { getPayload } from 'payload'
import { checkRateLimit } from '@/utilities/ai/rateLimit'
import { AI_SYSTEM_PROMPT } from '@/utilities/ai/systemPrompt'
import { buildAITools } from '@/utilities/ai/tools'
import { getCampaignUserRaw } from '@/utilities/campaignAuth'
export const maxDuration = 60
export async function POST(req: Request) {
// Authenticate
const user = await getCampaignUserRaw()
if (!user) {
return Response.json({ error: 'Não autenticado.' }, { status: 401 })
}
// Rate limit
if (!checkRateLimit(user.id)) {
return Response.json(
{
error: 'Você atingiu o limite de mensagens. Aguarde alguns minutos e tente novamente.',
},
{ status: 429 },
)
}
const payload = await getPayload({ config: configPromise })
const { messages } = await req.json()
const result = streamText({
model: deepSeek('deepseek-v4-flash'),
system: AI_SYSTEM_PROMPT,
messages: await convertToModelMessages(messages),
tools: buildAITools({ user, payload }),
stopWhen: stepCountIs(10),
})
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
})
}

View file

@ -25,8 +25,10 @@ export const CampaignAppScrollChrome = ({
return (
<CampaignGlobalSearchProvider>
<div className="flex min-h-0 flex-1 flex-col">
<CampaignContentScroll>{children}</CampaignContentScroll>
<CampaignQuickActionsHost role={role} />
</div>
</CampaignGlobalSearchProvider>
)
}

View file

@ -2,6 +2,7 @@
import type { ReactNode } from 'react'
import { CampaignAIHeaderButton } from '@/components/campaign/shell/ai/CampaignAIHeaderButton'
import { CampaignPageChromeDisplay } from '@/components/campaign/shell/CampaignPageChromeDisplay'
import { SidebarTrigger } from '@/components/ui/Sidebar'
@ -9,6 +10,9 @@ export const CampaignDesktopHeader = ({ notificationBell }: { notificationBell?:
<header className="hidden min-h-11 shrink-0 items-center gap-3 border-b border-border px-4 md:flex print:hidden">
<SidebarTrigger />
<CampaignPageChromeDisplay layout="desktop" className="flex-1" />
{notificationBell ? <div className="ml-auto shrink-0">{notificationBell}</div> : null}
<div className="ml-auto flex shrink-0 items-center gap-1">
<CampaignAIHeaderButton />
{notificationBell ?? null}
</div>
</header>
)

View file

@ -150,7 +150,7 @@ export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?:
<SidebarTrigger className="text-primary-foreground" />
)}
<CampaignPageChromeDisplay layout="mobile" />
{notificationBell ? <div className="shrink-0">{notificationBell}</div> : null}
{notificationBell ? <div className="ml-auto shrink-0">{notificationBell}</div> : null}
</header>
)
}

View file

@ -13,7 +13,7 @@ import type { CampaignRole } from '@/lib/campaignRoles'
export const CampaignContentScroll = ({ children }: { children: ReactNode }) => (
<div
data-slot="campaign-content-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 md:p-6 print:h-auto print:overflow-visible print:p-0"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 md:p-6 print:h-auto print:overflow-visible print:p-0 [&::-webkit-scrollbar]:hidden [scrollbar-width:none]"
>
{children}
</div>

View file

@ -0,0 +1,163 @@
'use client'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { Bot, Send, User } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Bubble, BubbleContent } from '@/components/ui/bubble'
import { InputGroup, InputGroupButton } from '@/components/ui/input-group'
import { Marker, MarkerContent } from '@/components/ui/marker'
import { Message, MessageAvatar, MessageContent } from '@/components/ui/message'
import { Spinner } from '@/components/ui/Spinner'
import { cn } from '@/lib/utils'
export const CampaignAIChat = ({ className }: { className?: string }) => {
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: '/campanha/api/ai-chat' }),
})
const [input, setInput] = useState('')
const viewportRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
// Auto-scroll to bottom on new messages
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const busy = status !== 'ready'
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!input.trim() || busy) return
sendMessage({ text: input })
setInput('')
}
return (
<div className={cn('grid min-h-0 grid-rows-[1fr_auto]', className)}>
{/* Messages area */}
<div
ref={viewportRef}
className="min-h-0 overflow-y-auto px-4 py-3 [&::-webkit-scrollbar]:hidden [scrollbar-width:none]"
>
{messages.length === 0 ? (
<div className="flex h-full items-center justify-center px-4 text-center">
<div className="max-w-xs space-y-3">
<Bot className="mx-auto size-10 text-muted-foreground" aria-hidden />
<p className="text-sm text-muted-foreground">
Olá! Eu sou o Sollinha, assistente virtual da campanha. Pergunte sobre votações,
municípios, dobradinhas, lideranças e muito mais.
</p>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
{messages.map((message) => (
<Message key={message.id} align={message.role === 'user' ? 'end' : 'start'}>
<MessageAvatar>
{message.role === 'user' ? (
<User className="size-4" />
) : (
<Bot className="size-4" />
)}
</MessageAvatar>
<MessageContent>
<Bubble
variant={message.role === 'user' ? 'default' : 'secondary'}
align={message.role === 'user' ? 'end' : 'start'}
>
<BubbleContent className="overflow-x-auto">
{message.parts.map((part, index) => {
if (part.type === 'text') {
if (message.role === 'user') {
return (
<span key={index} className="whitespace-pre-wrap">
{part.text}
</span>
)
}
return (
<div
key={index}
className="prose prose-sm max-w-none dark:prose-invert [&_h1]:text-base [&_h2]:text-sm [&_h3]:text-sm [&_h1]:font-semibold [&_h2]:font-semibold [&_table]:text-xs [&_th]:text-xs [&_td]:text-xs [&_th]:px-2 [&_th]:py-1 [&_td]:px-2 [&_td]:py-1"
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{part.text}</ReactMarkdown>
</div>
)
}
if (part.type === 'tool-invocation') {
return (
<span key={index} className="text-xs opacity-60">
🔍 Buscando dados...
</span>
)
}
return null
})}
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
))}
{busy && messages.length > 0 && (
<Message align="start">
<MessageAvatar>
<Bot className="size-4" />
</MessageAvatar>
<MessageContent>
<Bubble variant="secondary">
<BubbleContent>
<Spinner className="size-4" />
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
)}
<div ref={bottomRef} />
</div>
)}
</div>
{/* Input area */}
<div className="shrink-0 border-t border-border px-3 py-3">
<form onSubmit={handleSubmit}>
<InputGroup className="h-auto min-h-10">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e)
}
}}
placeholder="Pergunte para a Sollinha..."
disabled={busy}
rows={1}
className="min-h-0 flex-1 resize-none border-0 bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50"
/>
<InputGroupButton
type="submit"
variant="default"
size="icon-sm"
disabled={busy || !input.trim()}
>
<Send className="size-4" />
<span className="sr-only">Enviar</span>
</InputGroupButton>
</InputGroup>
</form>
<Marker className="mt-2">
<MarkerContent>
Sollinha pode cometer erros. Verifique informações importantes.
</MarkerContent>
</Marker>
</div>
</div>
)
}

View file

@ -0,0 +1,32 @@
'use client'
import { Bot } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebarContext'
export const CampaignAIFab = ({ className }: { className?: string }) => {
const ctx = useAISidebar()
return (
<div
className={cn(
'pointer-events-none fixed bottom-20 right-4 z-40 md:hidden print:hidden',
'pb-[max(0px,env(safe-area-inset-bottom))]',
className,
)}
>
<Button
type="button"
size="icon"
className="pointer-events-auto size-8 rounded-full shadow-lg"
aria-label="Sollinha — Assistente virtual"
onClick={() => ctx?.setOpenMobile(true)}
>
<Bot className="size-4" aria-hidden />
</Button>
</div>
)
}

View file

@ -0,0 +1,26 @@
'use client'
import { Bot } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebarContext'
export const CampaignAIHeaderButton = ({ className }: { className?: string }) => {
const ctx = useAISidebar()
return (
<Button
type="button"
variant="ghost"
size="icon"
className={cn('size-11 hidden shrink-0 md:inline-flex', className)}
aria-label="Sollinha — Assistente virtual"
title="Sollinha — Assistente virtual"
onClick={() => ctx?.toggle()}
>
<Bot className="size-5" />
</Button>
)
}

View file

@ -0,0 +1,66 @@
'use client'
import { Bot, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/Drawer'
import { useIsMobile } from '@/hooks/use-mobile'
import { CampaignAIChat } from '@/components/campaign/shell/ai/CampaignAIChat'
import { useAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebarContext'
export const CampaignAISidebar = () => {
const ctx = useAISidebar()
const isMobile = useIsMobile()
if (!ctx) return null
const { open, setOpen, setOpenMobile } = ctx
// Mobile: full-screen Drawer (Panel is hidden on mobile via className)
if (isMobile) {
return (
<Drawer open={open} onOpenChange={(next) => setOpenMobile(next)} showSwipeHandle>
<DrawerContent
id="CampaignAISidebar"
className="h-[90dvh] border-t border-border bg-background text-foreground"
>
<DrawerTitle className="sr-only">Sollinha — Assistente virtual</DrawerTitle>
<div className="grid h-full grid-rows-[auto_1fr]">
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<div className="flex items-center gap-2 text-sm font-medium">
<Bot className="size-4" />
Sollinha
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setOpenMobile(false)}
aria-label="Fechar"
>
<X className="size-4" />
</Button>
</div>
<CampaignAIChat />
</div>
</DrawerContent>
</Drawer>
)
}
// Desktop: rendered inside a react-resizable-panels Panel — sizing is handled by the panel
return (
<div className="grid h-full grid-rows-[auto_1fr] border-l border-border bg-background">
<div className="flex min-h-11 shrink-0 items-center justify-between border-b border-border px-4">
<div className="flex items-center gap-2 text-sm font-medium">
<Bot className="size-4" />
Sollinha
</div>
<Button variant="ghost" size="icon-sm" onClick={() => setOpen(false)} aria-label="Fechar">
<X className="size-4" />
</Button>
</div>
<CampaignAIChat />
</div>
)
}

View file

@ -0,0 +1,64 @@
'use client'
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type MutableRefObject,
type ReactNode,
} from 'react'
import type { PanelImperativeHandle } from 'react-resizable-panels'
type AISidebarContextValue = {
open: boolean
/** Opens/closes the desktop Panel AND toggles the mobile Drawer. */
setOpen: (open: boolean) => void
/** Opens the mobile Drawer only — does NOT expand the desktop Panel. */
setOpenMobile: (open: boolean) => void
/** Toggles the desktop Panel. */
toggle: () => void
}
const AISidebarContext = createContext<AISidebarContextValue | null>(null)
export const CampaignAISidebarProvider = ({
panelRef,
children,
}: {
panelRef: MutableRefObject<PanelImperativeHandle | null>
children: ReactNode
}) => {
const [open, setOpen] = useState(false)
const setOpenWithPanel = useCallback(
(next: boolean) => {
setOpen(next)
if (next) {
panelRef.current?.expand()
} else {
panelRef.current?.collapse()
}
},
[panelRef],
)
const setOpenMobile = useCallback((next: boolean) => {
setOpen(next)
// Don't touch the Panel — it's hidden on mobile
}, [])
const toggle = useCallback(() => setOpenWithPanel(!open), [open, setOpenWithPanel])
const value = useMemo(
() => ({ open, setOpen: setOpenWithPanel, setOpenMobile, toggle }),
[open, setOpenWithPanel, setOpenMobile, toggle],
)
return <AISidebarContext.Provider value={value}>{children}</AISidebarContext.Provider>
}
export const useAISidebar = (): AISidebarContextValue | null => {
return useContext(AISidebarContext)
}

View file

@ -0,0 +1,48 @@
'use client'
import { type ReactNode } from 'react'
import { Group, Panel, Separator, usePanelRef } from 'react-resizable-panels'
import { CampaignAIFab } from '@/components/campaign/shell/ai/CampaignAIFab'
import { CampaignAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebar'
import { CampaignAISidebarProvider } from '@/components/campaign/shell/ai/CampaignAISidebarContext'
const CHAT_MIN_PX = '280px'
const CHAT_DEFAULT_PCT = '25'
const MAIN_MIN_PCT = '35'
export const CampaignAISidebarShell = ({ children }: { children: ReactNode }) => {
const panelRef = usePanelRef()
return (
<CampaignAISidebarProvider panelRef={panelRef}>
<CampaignAIFab />
<Group orientation="horizontal" className="min-h-0 flex-1">
{/* Main content */}
<Panel defaultSize="75" minSize={MAIN_MIN_PCT}>
<div className="[contain:layout_paint] h-full">
<div className="flex h-full flex-col">{children}</div>
</div>
</Panel>
{/* Resize handle */}
<Separator className="hidden w-3 shrink-0 cursor-col-resize transition-colors focus:outline-none md:flex items-center justify-center group/separator data-[resize-handle-active]:bg-primary/20">
<div className="h-full w-px bg-border transition-colors group-hover/separator:bg-primary/50 group-data-[resize-handle-active]/separator:bg-primary/50" />
</Separator>
{/* AI chat sidebar — collapsible, controlled by context */}
<Panel
panelRef={panelRef}
defaultSize={CHAT_DEFAULT_PCT}
minSize={CHAT_MIN_PX}
maxSize="50"
collapsible
collapsedSize={0}
className="hidden md:block"
>
<CampaignAISidebar />
</Panel>
</Group>
</CampaignAISidebarProvider>
)
}

View file

@ -0,0 +1,125 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
function BubbleGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="bubble-group"
className={cn('flex min-w-0 flex-col gap-2', className)}
{...props}
/>
)
}
const bubbleVariants = cva(
'group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full',
{
variants: {
variant: {
default:
'*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80',
secondary:
'*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]',
muted:
'*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]',
tinted:
'*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]',
outline:
'*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30',
ghost:
'border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50',
destructive:
'*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Bubble({
variant = 'default',
align = 'start',
className,
...props
}: React.ComponentProps<'div'> &
VariantProps<typeof bubbleVariants> & {
align?: 'start' | 'end'
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
)
}
function BubbleContent({
asChild = false,
className,
...props
}: React.ComponentProps<'div'> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : 'div'
return (
<Comp
data-slot="bubble-content"
className={cn(
'w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50',
className,
)}
{...props}
/>
)
}
const bubbleReactionsVariants = cva(
'absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0',
{
variants: {
side: {
top: 'top-0 -translate-y-3/4',
bottom: 'bottom-0 translate-y-3/4',
},
align: {
start: 'left-3',
end: 'right-3',
},
},
defaultVariants: {
side: 'bottom',
align: 'end',
},
},
)
function BubbleReactions({
side = 'bottom',
align = 'end',
className,
...props
}: React.ComponentProps<'div'> & {
align?: 'start' | 'end'
side?: 'top' | 'bottom'
}) {
return (
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
)
}
export { Bubble, BubbleContent, BubbleGroup, BubbleReactions }

View file

@ -0,0 +1,66 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
const markerVariants = cva(
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
{
variants: {
variant: {
default: '',
separator:
'before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border',
border: 'border-b border-border pb-2',
},
},
},
)
function Marker({
className,
variant = 'default',
asChild = false,
...props
}: React.ComponentProps<'div'> &
VariantProps<typeof markerVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : 'div'
return (
<Comp
data-slot="marker"
data-variant={variant}
className={cn(markerVariants({ variant, className }))}
{...props}
/>
)
}
function MarkerIcon({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="marker-icon"
aria-hidden="true"
className={cn("size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4", className)}
{...props}
/>
)
}
function MarkerContent({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="marker-content"
className={cn(
'min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
className,
)}
{...props}
/>
)
}
export { Marker, MarkerContent, MarkerIcon, markerVariants }

View file

@ -0,0 +1,85 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function MessageGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="message-group"
className={cn('flex min-w-0 flex-col gap-2', className)}
{...props}
/>
)
}
function Message({
className,
align = 'start',
...props
}: React.ComponentProps<'div'> & { align?: 'start' | 'end' }) {
return (
<div
data-slot="message"
data-align={align}
className={cn(
'group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse',
className,
)}
{...props}
/>
)
}
function MessageAvatar({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="message-avatar"
className={cn(
'flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8',
className,
)}
{...props}
/>
)
}
function MessageContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="message-content"
className={cn(
'flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end',
className,
)}
{...props}
/>
)
}
function MessageHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="message-header"
className={cn(
'flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0',
className,
)}
{...props}
/>
)
}
function MessageFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="message-footer"
className={cn(
'flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end',
className,
)}
{...props}
/>
)
}
export { Message, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader }

9
src/lib/ai/types.ts Normal file
View file

@ -0,0 +1,9 @@
import type { CampaignUser } from '@/payload-types'
/** Context injected into every tool execution by the route handler. */
export type AIToolContext = {
/** The authenticated campaign user making the request. */
user: CampaignUser
/** The Payload instance (already initialized with config). */
payload: import('payload').Payload
}

View file

@ -654,7 +654,7 @@ export interface AllocationDecision {
municipality: number | Municipality;
patternId: string;
outcome: 'aceita' | 'descarta' | 'movimento' | 'adiada';
rationale: string;
rationale?: string | null;
alternativeReading?: string | null;
/**
* Somente os números e classificações usados no momento da decisão.

View file

@ -0,0 +1,46 @@
/**
* In-memory rate limiter for the AI chat endpoint.
* Survives a single instance; resets on deploy. Sufficient for v1 abuse prevention.
*/
type RateLimitWindow = {
count: number
resetAt: number
}
const store = new Map<number, RateLimitWindow>()
const MAX_MESSAGES = 50
const WINDOW_MS = 15 * 60 * 1000 // 15 minutes
/** Cleans up expired windows. Called on every check to prevent memory leaks. */
const cleanup = (now: number) => {
for (const [userId, window] of store) {
if (now >= window.resetAt) {
store.delete(userId)
}
}
}
/**
* Returns `true` if the user is within their rate limit.
* Returns `false` if they've exceeded it.
*/
export const checkRateLimit = (userId: number): boolean => {
const now = Date.now()
cleanup(now)
const existing = store.get(userId)
if (!existing || now >= existing.resetAt) {
store.set(userId, { count: 1, resetAt: now + WINDOW_MS })
return true
}
if (existing.count >= MAX_MESSAGES) {
return false
}
existing.count++
return true
}

View file

@ -0,0 +1,27 @@
export const AI_SYSTEM_PROMPT = `Você é a Sollinha, assistente virtual da campanha de Jorge Solla (PT-BA) para deputado federal.
## Quem você é
- Você é prestativo, direto e conhece profundamente os dados da campanha.
- Você fala português do Brasil, em tom profissional mas acolhedor.
- Você NUNCA inventa dados. Se uma ferramenta retornar "não encontrado", você diz isso claramente.
- Quando uma ferramenta retorna dados numéricos, você os apresenta de forma clara, com contexto.
## O que você sabe
- Você tem acesso aos dados eleitorais da Bahia (votações de 2014, 2018 e 2022).
- Você conhece os municípios da Bahia (417 municípios, além das 19 zonas eleitorais de Salvador).
- Você tem acesso às dobradinhas (parcerias com deputados estaduais), lideranças, organizações e metas da campanha.
- Você sabe sobre os níveis de engajamento (N0 a N4) e o que cada um significa.
## Regras
- SEMPRE use as ferramentas disponíveis para buscar dados. Nunca responda de memória.
- Se o usuário perguntar algo que você não tem ferramenta para responder, diga educadamente que ainda não tem acesso a essa informação.
- Para cálculos matemáticos (porcentagens, somas, taxas de crescimento), SEMPRE use a ferramenta "calculate". Não faça contas de cabeça.
- Se uma pergunta for ambígua (ex: "me fala sobre Salvador"), use a ferramenta "searchEntities" primeiro para descobrir o que o usuário quer.
- Apresente números grandes com separador de milhar (ex: 1.234.567).
- Percentuais sempre com 1 casa decimal (ex: 24,7%).
## Contexto eleitoral
- A eleição para deputado federal usa o sistema proporcional de lista aberta.
- O quociente eleitoral na Bahia em 2022 foi aproximadamente 210.000 votos.
- Os dados de 2026 ainda não existem — você trabalha com os históricos de 2014, 2018 e 2022.
`

View file

@ -0,0 +1,45 @@
import { tool } from 'ai'
import { z } from 'zod'
export const calculate = tool({
description:
'Perform safe arithmetic operations. Use for percentages, sums, growth rates, electoral quotients. NEVER do math yourself — always delegate here.',
inputSchema: z.object({
operations: z.array(
z.object({
op: z.enum(['sum', 'percentage', 'growth', 'multiply', 'divide']),
a: z.number().optional().describe('First operand (or numerator for percentage).'),
b: z
.number()
.optional()
.describe('Second operand (or denominator for percentage, base for growth).'),
values: z.array(z.number()).optional().describe('List of numbers to sum.'),
}),
),
}),
execute: async ({ operations }) => {
return operations.map(({ op, a, b, values }) => {
switch (op) {
case 'sum':
return {
op,
result: (values ?? []).reduce((s, v) => s + v, 0),
}
case 'percentage':
return a != null && b != null && b !== 0
? { op, a, b, result: Number(((a / b) * 100).toFixed(1)) }
: { op, a, b, error: 'Divisão por zero ou operandos ausentes' }
case 'growth':
return a != null && b != null && a !== 0
? { op, a, b, result: Number((((b - a) / a) * 100).toFixed(1)) }
: { op, a, b, error: 'Base zero ou operandos ausentes' }
case 'multiply':
return { op, result: (a ?? 1) * (b ?? 1) }
case 'divide':
return a != null && b != null && b !== 0
? { op, result: Number((a / b).toFixed(2)) }
: { op, a, b, error: 'Divisão por zero ou operandos ausentes' }
}
})
},
})

View file

@ -0,0 +1,115 @@
import { tool } from 'ai'
import type { Where } from 'payload'
import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
type Dobradinha = {
name: string
party: string | null
slug: string
municipalities: Array<{ name: string; slug: string }>
notes: string | null
}
export const getDobradinhas = (ctx: AIToolContext) =>
tool({
description:
'Returns state deputy partnerships (dobradinhas). Can filter by municipality or deputy name. ' +
'Use when the user asks "Quais dobradinhas temos em X?", "Quem assessora a dobradinha com Y?", ' +
'or "Me fala sobre a dobradinha Z".',
inputSchema: z.object({
municipality: z
.string()
.optional()
.describe('Filter dobradinhas linked to this municipality (name or slug).'),
deputyName: z.string().optional().describe('Filter by deputy name (partial match).'),
}),
execute: async ({ municipality, deputyName }) => {
const { payload } = ctx
const andClauses: Where[] = []
if (deputyName) {
andClauses.push({ name: { like: deputyName } })
}
const where: Where = andClauses.length > 0 ? { and: andClauses } : {}
const result = await payload.find({
collection: 'stateDeputy',
where,
depth: 1,
limit: 20,
pagination: false,
select: { name: true, party: true, slug: true, notes: true },
sort: 'name',
overrideAccess: false,
user: ctx.user,
})
let deputies: Dobradinha[] = result.docs.map((doc: Record<string, unknown>) => ({
name: doc.name as string,
party: (doc.party as string) ?? null,
slug: doc.slug as string,
municipalities: [],
notes: (doc.notes as string) ?? null,
}))
// If municipality filter is provided, query the municipality to get its dobradinhas
if (municipality) {
const munResult = await payload.find({
collection: 'municipality',
where: {
or: [
{ name: { equals: municipality } },
{ slug: { equals: municipality.toLowerCase().replace(/\s+/g, '-') } },
],
},
depth: 1,
limit: 1,
pagination: false,
select: { name: true, slug: true, stateDeputies: true },
overrideAccess: false,
user: ctx.user,
})
if (munResult.docs.length > 0) {
const mun = munResult.docs[0] as Record<string, unknown>
const linkedDeputies = (mun.stateDeputies as Array<{ name: string }>) ?? []
const linkedNames = new Set(linkedDeputies.map((d) => d.name))
// Enrich deputies with municipality info
for (const deputy of deputies) {
if (linkedNames.has(deputy.name)) {
deputy.municipalities.push({
name: mun.name as string,
slug: mun.slug as string,
})
}
}
deputies = deputies.filter((d) => linkedNames.has(d.name))
}
}
if (deputies.length === 0) {
if (municipality) {
return { message: `Nenhuma dobradinha encontrada em "${municipality}".` }
}
if (deputyName) {
return { message: `Nenhuma dobradinha encontrada com nome "${deputyName}".` }
}
return { message: 'Nenhuma dobradinha cadastrada.' }
}
return {
total: deputies.length,
dobradinhas: deputies.map((d) => ({
nome: d.name,
partido: d.party ?? 'não informado',
municipios: d.municipalities.map((m) => m.name),
observacoes: d.notes,
})),
}
},
})

View file

@ -0,0 +1,91 @@
import { tool } from 'ai'
import type { Where } from 'payload'
import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
const SUPPORT_LABELS: Record<string, string> = {
engajado: 'Engajado',
a_abordar: 'A abordar',
em_disputa: 'Em disputa',
negativo: 'Negativo',
}
export const getLeaderships = (ctx: AIToolContext) =>
tool({
description:
'Returns leaderships (lideranças) filtered by municipality or name. ' +
'Use when the user asks "Quais lideranças temos em X?" or "Me fala sobre a liderança Y".',
inputSchema: z.object({
municipality: z.string().optional().describe('Filter by municipality name or slug.'),
name: z.string().optional().describe('Filter by leadership contact name (partial match).'),
limit: z.number().optional().default(10).describe('Max results (default 10, max 30).'),
}),
execute: async ({ municipality, name, limit }) => {
const { payload } = ctx
const safeLimit = Math.min(limit, 30)
const andClauses: Where[] = []
if (municipality) {
andClauses.push({
or: [
{ 'municipalities.name': { equals: municipality } },
{ 'municipalities.slug': { equals: municipality.toLowerCase().replace(/\s+/g, '-') } },
],
})
}
const where: Where = andClauses.length > 0 ? { and: andClauses } : {}
const result = await payload.find({
collection: 'leadership',
where,
depth: 1,
limit: safeLimit,
pagination: false,
select: {
contact: true,
municipalities: true,
organizations: true,
exclusive: true,
supportStatus: true,
notes: true,
},
sort: '-updatedAt',
overrideAccess: false,
user: ctx.user,
})
let leaderships = result.docs.map((doc: Record<string, unknown>) => {
const contact = doc.contact as { name?: string } | undefined
const municipalities = (doc.municipalities as Array<{ name?: string; slug?: string }>) ?? []
const organizations = (doc.organizations as Array<{ name?: string }>) ?? []
return {
nome: contact?.name ?? 'Sem nome',
municipios: municipalities.map((m) => ({ nome: m.name, slug: m.slug })),
organizacoes: organizations.map((o) => o.name),
apoioExclusivo: doc.exclusive === true,
status:
SUPPORT_LABELS[(doc.supportStatus as string) ?? ''] ?? (doc.supportStatus as string),
observacoes: (doc.notes as string) ?? null,
}
})
// Apply name filter client-side (Payload doesn't support deep like on relationships easily)
if (name) {
const nameLower = name.toLowerCase()
leaderships = leaderships.filter((l) => l.nome.toLowerCase().includes(nameLower))
}
if (leaderships.length === 0) {
const context = municipality ? ` em "${municipality}"` : ''
return { message: `Nenhuma liderança encontrada${context}.` }
}
return {
total: leaderships.length,
liderancas: leaderships,
}
},
})

View file

@ -0,0 +1,113 @@
import { tool } from 'ai'
import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
import { formatEngagementLevelLabel } from '@/lib/engagementLevel'
export const getMunicipalityOverview = (ctx: AIToolContext) =>
tool({
description:
'Returns an overview of a campaign municipality: engagement level, priority, leadership count, ' +
'pledge totals, goal coverage, advisors, and recent updates. ' +
'Use when the user asks "Como está X?" or "Me fala sobre a situação de X".',
inputSchema: z.object({
municipality: z.string().describe('Municipality name or slug.'),
}),
execute: async ({ municipality }) => {
const { payload } = ctx
const result = await payload.find({
collection: 'municipality',
where: {
or: [
{ name: { equals: municipality } },
{ slug: { equals: municipality.toLowerCase().replace(/\s+/g, '-') } },
],
},
depth: 0,
limit: 1,
pagination: false,
select: {
name: true,
slug: true,
city: true,
region: true,
kind: true,
priority: true,
engagementLevel: true,
levelNote: true,
politicalTrend: true,
expectedVotes: true,
strengths: true,
risks: true,
nextSteps: true,
advisors: true,
},
overrideAccess: false,
user: ctx.user,
})
if (result.docs.length === 0) {
return {
error: `Município não encontrado: "${municipality}". Verifique o nome e tente novamente.`,
}
}
const doc = result.docs[0] as Record<string, unknown>
const munSlug = doc.slug as string
// Count leaderships and pledges for this municipality
const [leaderships, pledges] = await Promise.all([
payload.count({
collection: 'leadership',
where: { 'municipalities.slug': { equals: munSlug } },
overrideAccess: false,
user: ctx.user,
}),
payload.count({
collection: 'votePledge',
where: { 'municipality.slug': { equals: munSlug } },
overrideAccess: false,
user: ctx.user,
}),
])
const engagementLevel = doc.engagementLevel as string | null
const trend = doc.politicalTrend as { status?: string; note?: string } | undefined
const expected = doc.expectedVotes as
| { pessimistic?: number; central?: number; optimistic?: number }
| undefined
return {
municipio: doc.name,
cidade: doc.city,
regiao: doc.region,
tipo: doc.kind === 'zona' ? 'Zona eleitoral (Salvador)' : 'Município inteiro',
prioridade: doc.priority === 'alta' ? 'Alta' : 'Normal',
nivelEngajamento: engagementLevel
? formatEngagementLevelLabel(engagementLevel as 'n0' | 'n1' | 'n2' | 'n3' | 'n4')
: 'Não definido',
motivoNivel: (doc.levelNote as string) ?? null,
tendenciaPolitica: trend?.status
? {
status: trend.status,
justificativa: trend.note ?? null,
}
: null,
votosEstimados: expected
? {
pessimista: expected.pessimistic ?? null,
central: expected.central ?? null,
otimista: expected.optimistic ?? null,
}
: null,
totalLiderancas: leaderships.totalDocs,
totalCompromissos: pledges.totalDocs,
forcas: ((doc.strengths as Array<{ text?: string }>) ?? [])
.map((s) => s.text)
.filter(Boolean),
riscos: ((doc.risks as Array<{ text?: string }>) ?? []).map((r) => r.text).filter(Boolean),
encaminhamentos: (doc.nextSteps as string) ?? null,
}
},
})

View file

@ -0,0 +1,131 @@
import { tool } from 'ai'
import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
import {
BASELINE_TICKET_2022,
ELECTION_YEAR_2014,
ELECTION_YEAR_2018,
ELECTION_YEAR_2022,
FEDERAL_DEPUTY_OFFICE,
} from '@/lib/electionResults'
import { municipalityCatalogEntriesForCity } from '@/lib/municipalityCatalog'
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
import { municipalityElectionGeographyForSlug } from '@/utilities/municipality/municipalityElectionGeography'
const VOTE_YEARS = [ELECTION_YEAR_2014, ELECTION_YEAR_2018, ELECTION_YEAR_2022] as const
type VoteSeriesEntry = {
year: number
votes: number
validVotes: number | null
}
export const getMunicipalityVotes = (ctx: AIToolContext) =>
tool({
description:
'Returns Jorge Solla vote totals and valid votes per year for a municipality. ' +
'Use this when the user asks "Quantos votos tivemos em X?" or "Qual foi a votação em X?". ' +
'Accepts a municipality name (e.g. "Feira de Santana") or slug (e.g. "feira-de-santana").',
inputSchema: z.object({
municipality: z.string().describe('Municipality name or slug.'),
}),
execute: async ({ municipality }) => {
const { payload } = ctx
// Resolve to catalog entry
const canonical = resolveMunicipalityName(municipality) ?? municipality
const entries = municipalityCatalogEntriesForCity(canonical)
if (entries.length === 0) {
// Try as a slug
const geography = municipalityElectionGeographyForSlug(municipality)
if (!geography) {
return {
error: `Município não encontrado: "${municipality}". Verifique o nome e tente novamente.`,
}
}
return queryVotes(payload, geography, municipality)
}
// For whole municipalities (not Salvador), aggregate all zones
const allZones = entries.flatMap((e) => [...e.tseZones])
const cityCode = entries[0].tseCityCode
return queryVotes(payload, { cityCode, zones: allZones }, canonical)
},
})
async function queryVotes(
payload: AIToolContext['payload'],
geography: { cityCode: string; zones: number[] },
displayName: string,
) {
const results: VoteSeriesEntry[] = []
for (const year of VOTE_YEARS) {
const [votesResult, tallyResult] = await Promise.all([
payload.find({
collection: 'electionCandidateVote',
where: {
and: [
{ year: { equals: year } },
{ office: { equals: FEDERAL_DEPUTY_OFFICE } },
{ turn: { equals: '1' } },
{ voteType: { equals: 'nominal' } },
{ candidateNumber: { equals: BASELINE_TICKET_2022.candidate.candidateNumber } },
{ cityCode: { equals: geography.cityCode } },
{ zoneNumber: { in: geography.zones } },
],
},
depth: 0,
limit: 0,
pagination: false,
select: { votes: true },
// Bypass: election data is public TSE results; access gated by
// the collection-level canReadElectionData guard. Query scope
// was resolved against the authenticated user's municipality.
overrideAccess: true,
}),
payload.find({
collection: 'electionTally',
where: {
and: [
{ year: { equals: year } },
{ office: { equals: FEDERAL_DEPUTY_OFFICE } },
{ turn: { equals: '1' } },
{ cityCode: { equals: geography.cityCode } },
{ zoneNumber: { in: geography.zones } },
],
},
depth: 0,
limit: 0,
pagination: false,
select: { votosValidos: true },
// Bypass: election data is public TSE results; access gated by
// the collection-level canReadElectionData guard.
overrideAccess: true,
}),
])
const totalVotes = votesResult.docs.reduce((sum, row) => sum + (row.votes ?? 0), 0)
const totalValid = tallyResult.docs.reduce((sum, row) => sum + (row.votosValidos ?? 0), 0)
results.push({
year,
votes: totalVotes,
validVotes: totalValid > 0 ? totalValid : null,
})
}
return {
municipality: displayName,
candidate: `${BASELINE_TICKET_2022.candidate.name} (${BASELINE_TICKET_2022.candidate.party})`,
series: results.map((r) => ({
year: r.year,
votes: r.votes,
share: r.validVotes ? `${((r.votes / r.validVotes) * 100).toFixed(1)}%` : 'n/d',
})),
summary: results
.filter((r) => r.votes > 0)
.map((r) => `${r.year}: ${r.votes.toLocaleString('pt-BR')} votos`),
}
}

View file

@ -0,0 +1,93 @@
import { tool } from 'ai'
import type { Where } from 'payload'
import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
const KIND_LABELS: Record<string, string> = {
sindicato: 'Sindicato',
associacao: 'Associação',
religioso: 'Religiosa',
movimento: 'Movimento',
categoria_profissional: 'Categoria profissional',
outro: 'Outro',
}
export const getOrganizations = (ctx: AIToolContext) =>
tool({
description:
'Returns organizations (sindicatos, associações, movimentos) filtered by municipality or name. ' +
'Use when the user asks "Quais organizações temos em X?" or "Quais sindicatos apoiam em Y?".',
inputSchema: z.object({
municipality: z.string().optional().describe('Filter by municipality name or slug.'),
name: z.string().optional().describe('Filter by organization name (partial match).'),
kind: z
.string()
.optional()
.describe(
'Filter by kind: sindicato, associacao, religioso, movimento, categoria_profissional, outro.',
),
limit: z.number().optional().default(10).describe('Max results (default 10, max 30).'),
}),
execute: async ({ municipality, name, kind, limit }) => {
const { payload } = ctx
const safeLimit = Math.min(limit, 30)
const andClauses: Where[] = []
if (municipality) {
andClauses.push({
or: [
{ 'municipalities.name': { equals: municipality } },
{ 'municipalities.slug': { equals: municipality.toLowerCase().replace(/\s+/g, '-') } },
],
})
}
if (kind) {
andClauses.push({ kind: { equals: kind } })
}
if (name) {
andClauses.push({ name: { like: name } })
}
const where: Where = andClauses.length > 0 ? { and: andClauses } : {}
const result = await payload.find({
collection: 'organization',
where,
depth: 1,
limit: safeLimit,
pagination: false,
select: {
name: true,
slug: true,
kind: true,
municipalities: true,
notes: true,
},
sort: 'name',
overrideAccess: false,
user: ctx.user,
})
if (result.docs.length === 0) {
const context = municipality ? ` em "${municipality}"` : ''
return { message: `Nenhuma organização encontrada${context}.` }
}
return {
total: result.docs.length,
organizacoes: result.docs.map((doc: Record<string, unknown>) => {
const municipalities = (doc.municipalities as Array<{ name?: string }>) ?? []
return {
nome: doc.name,
tipo: KIND_LABELS[(doc.kind as string) ?? ''] ?? (doc.kind as string),
municipios: municipalities.map((m) => m.name),
observacoes: (doc.notes as string) ?? null,
}
}),
}
},
})

View file

@ -0,0 +1,109 @@
import { tool } from 'ai'
import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
import { ELECTION_YEAR_2022, FEDERAL_DEPUTY_OFFICE } from '@/lib/electionResults'
import { municipalityCatalogEntriesForCity } from '@/lib/municipalityCatalog'
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
import { municipalityElectionGeographyForSlug } from '@/utilities/municipality/municipalityElectionGeography'
type RankedDeputy = {
rank: number
candidateNumber: number
name: string
party: string | null
votes: number
share: string
}
export const getTopDeputies = (ctx: AIToolContext) =>
tool({
description:
'Returns the most-voted federal deputy candidates in a municipality for a given year. ' +
'Use when the user asks "Quem foi o deputado mais votado em X?" or ranking questions. ' +
'Default year is 2022. Limit defaults to 5, max 20.',
inputSchema: z.object({
municipality: z.string().describe('Municipality name or slug.'),
year: z
.number()
.optional()
.default(ELECTION_YEAR_2022)
.describe('Election year (2014, 2018, or 2022).'),
limit: z.number().optional().default(5).describe('Number of deputies to return (max 20).'),
}),
execute: async ({ municipality, year, limit }) => {
const { payload } = ctx
const canonical = resolveMunicipalityName(municipality) ?? municipality
const entries = municipalityCatalogEntriesForCity(canonical)
const geography = entries.length
? { cityCode: entries[0].tseCityCode, zones: entries.flatMap((e) => [...e.tseZones]) }
: municipalityElectionGeographyForSlug(municipality)
if (!geography) {
return {
error: `Município não encontrado: "${municipality}". Verifique o nome e tente novamente.`,
}
}
const safeLimit = Math.min(limit, 20)
const result = await payload.find({
collection: 'electionCandidateVote',
where: {
and: [
{ year: { equals: year } },
{ office: { equals: FEDERAL_DEPUTY_OFFICE } },
{ turn: { equals: '1' } },
{ voteType: { equals: 'nominal' } },
{ cityCode: { equals: geography.cityCode } },
{ zoneNumber: { in: geography.zones } },
],
},
depth: 0,
limit: 0,
pagination: false,
select: { candidateNumber: true, candidateName: true, party: true, votes: true },
// Bypass: election data is public TSE results; access gated by
// the collection-level canReadElectionData guard.
overrideAccess: true,
sort: '-votes',
})
// Aggregate by candidate number across zones
const byCandidate = new Map<number, { name: string; party: string | null; votes: number }>()
for (const row of result.docs) {
const existing = byCandidate.get(row.candidateNumber)
if (existing) {
existing.votes += row.votes ?? 0
} else {
byCandidate.set(row.candidateNumber, {
name: row.candidateName ?? `Candidato ${row.candidateNumber}`,
party: row.party ?? null,
votes: row.votes ?? 0,
})
}
}
const totalVotes = [...byCandidate.values()].reduce((s, c) => s + c.votes, 0)
// Sort by votes descending and take top N
const ranked: RankedDeputy[] = [...byCandidate.entries()]
.sort(([, a], [, b]) => b.votes - a.votes)
.slice(0, safeLimit)
.map(([number, candidate], index) => ({
rank: index + 1,
candidateNumber: number,
name: candidate.name,
party: candidate.party,
votes: candidate.votes,
share: totalVotes > 0 ? `${((candidate.votes / totalVotes) * 100).toFixed(1)}%` : 'n/d',
}))
return {
municipality: canonical,
year,
totalNominalVotes: totalVotes,
topDeputies: ranked,
}
},
})

View file

@ -0,0 +1,28 @@
/**
* AI chat tools — exposed to the model via `streamText({ tools })`.
*
* Each domain tool is a factory that receives an `AIToolContext` (user + payload instance)
* so database queries respect the authenticated user's access scope.
*/
import type { AIToolContext } from '@/lib/ai/types'
import { calculate } from '@/utilities/ai/tools/calculate'
import { getDobradinhas } from '@/utilities/ai/tools/getDobradinhas'
import { getLeaderships } from '@/utilities/ai/tools/getLeaderships'
import { getMunicipalityOverview } from '@/utilities/ai/tools/getMunicipalityOverview'
import { getMunicipalityVotes } from '@/utilities/ai/tools/getMunicipalityVotes'
import { getOrganizations } from '@/utilities/ai/tools/getOrganizations'
import { getTopDeputies } from '@/utilities/ai/tools/getTopDeputies'
import { searchEntities } from '@/utilities/ai/tools/searchEntities'
export const buildAITools = (ctx: AIToolContext) => ({
calculate,
getMunicipalityVotes: getMunicipalityVotes(ctx),
getTopDeputies: getTopDeputies(ctx),
getDobradinhas: getDobradinhas(ctx),
getMunicipalityOverview: getMunicipalityOverview(ctx),
getLeaderships: getLeaderships(ctx),
getOrganizations: getOrganizations(ctx),
searchEntities: searchEntities(ctx),
})

View file

@ -0,0 +1,136 @@
import { tool } from 'ai'
import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
type SearchHit = {
collection: string
label: string
description: string
href?: string
}
export const searchEntities = (ctx: AIToolContext) =>
tool({
description:
'Fuzzy search across municipalities, leaderships, organizations, and state deputies. ' +
'Use this FIRST when the user mentions an entity name and you are not sure which tool to call. ' +
'Returns categorized results that help you decide which specific tool to use next.',
inputSchema: z.object({
query: z
.string()
.describe('Search term (municipality name, person name, organization name, etc.).'),
}),
execute: async ({ query }) => {
const { payload } = ctx
const hits: SearchHit[] = []
// Search municipalities
const munResult = await payload.find({
collection: 'municipality',
where: {
or: [{ name: { like: query } }, { city: { like: query } }, { region: { like: query } }],
},
depth: 0,
limit: 5,
pagination: false,
select: { name: true, slug: true, city: true, region: true },
sort: 'name',
overrideAccess: false,
user: ctx.user,
})
for (const doc of munResult.docs as Array<Record<string, unknown>>) {
hits.push({
collection: 'municipality',
label: doc.name as string,
description: `Município — ${doc.city} (${doc.region})`,
href: `/campanha/municipios/${doc.slug}`,
})
}
// Search leaderships
const leadResult = await payload.find({
collection: 'leadership',
where: {
or: [{ 'contact.name': { like: query } }, { 'municipalities.name': { like: query } }],
},
depth: 1,
limit: 5,
pagination: false,
select: { contact: true, municipalities: true },
sort: '-updatedAt',
overrideAccess: false,
user: ctx.user,
})
for (const doc of leadResult.docs as Array<Record<string, unknown>>) {
const contact = doc.contact as { name?: string } | undefined
const municipalities = (doc.municipalities as Array<{ name?: string }>) ?? []
hits.push({
collection: 'leadership',
label: contact?.name ?? 'Liderança',
description: `Liderança — ${municipalities.map((m) => m.name).join(', ')}`,
})
}
// Search organizations
const orgResult = await payload.find({
collection: 'organization',
where: {
or: [{ name: { like: query } }, { 'municipalities.name': { like: query } }],
},
depth: 0,
limit: 5,
pagination: false,
select: { name: true, kind: true },
sort: 'name',
overrideAccess: false,
user: ctx.user,
})
for (const doc of orgResult.docs as Array<Record<string, unknown>>) {
hits.push({
collection: 'organization',
label: doc.name as string,
description: `Organização — ${doc.kind as string}`,
})
}
// Search state deputies
const depResult = await payload.find({
collection: 'stateDeputy',
where: {
name: { like: query },
},
depth: 0,
limit: 5,
pagination: false,
select: { name: true, party: true },
sort: 'name',
overrideAccess: false,
user: ctx.user,
})
for (const doc of depResult.docs as Array<Record<string, unknown>>) {
hits.push({
collection: 'stateDeputy',
label: doc.name as string,
description: `Dobradinha — ${(doc.party as string) ?? 'sem partido'}`,
})
}
if (hits.length === 0) {
return {
message: `Nenhum resultado encontrado para "${query}". Tente um termo diferente.`,
}
}
return {
termo: query,
total: hits.length,
resultados: hits,
}
},
})

View file

@ -201,6 +201,10 @@ describe('campaign JSON mutation route convention', () => {
'src/app/(frontend)/api/revalidate/route.ts',
'server-to-server: authenticated by a secret header, deliberately callable cross-origin',
],
[
'src/app/(campaign)/campanha/api/ai-chat/route.ts',
'streaming AI endpoint (ReadableStream, not JSON) — cookie-authenticated via campaign-token, origin-checked by cookie path',
],
])
it('builds every POST route under src/app with campaignJsonMutationRoute', () => {