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:
parent
b74268714c
commit
f9cd1df7b2
35 changed files with 2475 additions and 32 deletions
|
|
@ -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
|
||||
|
|
|
|||
170
docs/plans/ai-chat-sollinha.md
Normal file
170
docs/plans/ai-chat-sollinha.md
Normal 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`
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
570
pnpm-lock.yaml
570
pnpm-lock.yaml
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,37 +73,43 @@ 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">
|
||||
<CampaignPageChromeProvider role={user.role}>
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignHomeSearchChromeProvider>
|
||||
<CampaignQuickActionContextProvider>
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignMobileTopBar
|
||||
notificationBell={<CampaignNotificationBellSlot user={user} />}
|
||||
/>
|
||||
<CampaignDesktopHeader
|
||||
notificationBell={<CampaignNotificationBellSlot user={user} />}
|
||||
/>
|
||||
{/*
|
||||
<CampaignAISidebarShell>
|
||||
<CampaignPageChromeProvider role={user.role}>
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignHomeSearchChromeProvider>
|
||||
<CampaignQuickActionContextProvider>
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignMobileTopBar
|
||||
notificationBell={<CampaignNotificationBellSlot user={user} />}
|
||||
/>
|
||||
<CampaignDesktopHeader
|
||||
notificationBell={<CampaignNotificationBellSlot user={user} />}
|
||||
/>
|
||||
{/*
|
||||
Provider must wrap CampaignAppScrollChrome, not only page children:
|
||||
the quick-actions FAB overlay (B126) mounts as a sibling of
|
||||
the scrollport and renders search hits with CampaignHoverTooltip
|
||||
(priority flag). Nested only around {children} left focus→suggest
|
||||
without a provider and crashed the page (B102).
|
||||
*/}
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<CampaignAppScrollChrome role={user.role}>{children}</CampaignAppScrollChrome>
|
||||
</TooltipProvider>
|
||||
<Toaster position="top-center" />
|
||||
<InstallPwaToast />
|
||||
{biometricEnrollment ? (
|
||||
<BiometricEnrollmentToast {...biometricEnrollment} />
|
||||
) : null}
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignQuickActionContextProvider>
|
||||
</CampaignHomeSearchChromeProvider>
|
||||
</CampaignWizardChromeProvider>
|
||||
</CampaignPageChromeProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<CampaignAppScrollChrome role={user.role}>
|
||||
{children}
|
||||
</CampaignAppScrollChrome>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<Toaster position="top-center" />
|
||||
<InstallPwaToast />
|
||||
{biometricEnrollment ? (
|
||||
<BiometricEnrollmentToast {...biometricEnrollment} />
|
||||
) : null}
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignQuickActionContextProvider>
|
||||
</CampaignHomeSearchChromeProvider>
|
||||
</CampaignWizardChromeProvider>
|
||||
</CampaignPageChromeProvider>
|
||||
</CampaignAISidebarShell>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
|
|
|
|||
51
src/app/(campaign)/campanha/api/ai-chat/route.ts
Normal file
51
src/app/(campaign)/campanha/api/ai-chat/route.ts
Normal 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 }),
|
||||
})
|
||||
}
|
||||
|
|
@ -25,8 +25,10 @@ export const CampaignAppScrollChrome = ({
|
|||
|
||||
return (
|
||||
<CampaignGlobalSearchProvider>
|
||||
<CampaignContentScroll>{children}</CampaignContentScroll>
|
||||
<CampaignQuickActionsHost role={role} />
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<CampaignContentScroll>{children}</CampaignContentScroll>
|
||||
<CampaignQuickActionsHost role={role} />
|
||||
</div>
|
||||
</CampaignGlobalSearchProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
163
src/components/campaign/shell/ai/CampaignAIChat.tsx
Normal file
163
src/components/campaign/shell/ai/CampaignAIChat.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
src/components/campaign/shell/ai/CampaignAIFab.tsx
Normal file
32
src/components/campaign/shell/ai/CampaignAIFab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/components/campaign/shell/ai/CampaignAIHeaderButton.tsx
Normal file
26
src/components/campaign/shell/ai/CampaignAIHeaderButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
src/components/campaign/shell/ai/CampaignAISidebar.tsx
Normal file
66
src/components/campaign/shell/ai/CampaignAISidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
48
src/components/campaign/shell/ai/CampaignAISidebarShell.tsx
Normal file
48
src/components/campaign/shell/ai/CampaignAISidebarShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
125
src/components/ui/bubble.tsx
Normal file
125
src/components/ui/bubble.tsx
Normal 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 }
|
||||
66
src/components/ui/marker.tsx
Normal file
66
src/components/ui/marker.tsx
Normal 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 }
|
||||
85
src/components/ui/message.tsx
Normal file
85
src/components/ui/message.tsx
Normal 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
9
src/lib/ai/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
46
src/utilities/ai/rateLimit.ts
Normal file
46
src/utilities/ai/rateLimit.ts
Normal 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
|
||||
}
|
||||
27
src/utilities/ai/systemPrompt.ts
Normal file
27
src/utilities/ai/systemPrompt.ts
Normal 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.
|
||||
`
|
||||
45
src/utilities/ai/tools/calculate.ts
Normal file
45
src/utilities/ai/tools/calculate.ts
Normal 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' }
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
115
src/utilities/ai/tools/getDobradinhas.ts
Normal file
115
src/utilities/ai/tools/getDobradinhas.ts
Normal 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,
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
91
src/utilities/ai/tools/getLeaderships.ts
Normal file
91
src/utilities/ai/tools/getLeaderships.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
113
src/utilities/ai/tools/getMunicipalityOverview.ts
Normal file
113
src/utilities/ai/tools/getMunicipalityOverview.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
131
src/utilities/ai/tools/getMunicipalityVotes.ts
Normal file
131
src/utilities/ai/tools/getMunicipalityVotes.ts
Normal 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`),
|
||||
}
|
||||
}
|
||||
93
src/utilities/ai/tools/getOrganizations.ts
Normal file
93
src/utilities/ai/tools/getOrganizations.ts
Normal 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,
|
||||
}
|
||||
}),
|
||||
}
|
||||
},
|
||||
})
|
||||
109
src/utilities/ai/tools/getTopDeputies.ts
Normal file
109
src/utilities/ai/tools/getTopDeputies.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
28
src/utilities/ai/tools/index.ts
Normal file
28
src/utilities/ai/tools/index.ts
Normal 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),
|
||||
})
|
||||
136
src/utilities/ai/tools/searchEntities.ts
Normal file
136
src/utilities/ai/tools/searchEntities.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue