feat(insights): adiciona suporte ao Ollama

This commit is contained in:
Felipe Coutinho
2026-05-28 20:01:44 -03:00
parent 26cb18a9ad
commit e50eeba36e
20 changed files with 1110 additions and 368 deletions

View File

@@ -61,6 +61,8 @@ OPENAI_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY=
MINIMAX_API_KEY=
OPENROUTER_API_KEY=
OLLAMA_BASE_URL=http://127.0.0.1:11434/v1
OLLAMA_API_KEY=
# === Logo.dev (Opcional) ===
# Logos automáticos de estabelecimentos. Cadastre em https://www.logo.dev

View File

@@ -7,14 +7,16 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [2.7.0] - 2026-05-28
Esta versão melhora o uso diário do OpenMonetis em várias frentes: sessões mais confortáveis em dispositivos pessoais, novos recursos de IA com MiniMax, registro rápido de rendimentos no extrato e uma divisão de lançamentos mais completa para despesas compartilhadas. Também há polimentos importantes em filtros, relatórios, dashboard e telas de detalhe para deixar a leitura das informações financeiras mais direta.
Esta versão amplia o OpenMonetis para quem usa o app todos os dias e para quem prefere mais controle sobre os próprios dados. Os Insights ganham novas opções de IA, incluindo modelos locais via Ollama, enquanto a autenticação fica mais confortável em dispositivos pessoais. Também entram melhorias práticas em contas, lançamentos compartilhados, filtros, relatórios e dashboard, deixando os fluxos financeiros mais completos e fáceis de revisar.
### Adicionado
- Autenticação: a tela de login agora tem a opção "Manter conectado neste dispositivo", usando a persistência nativa do Better Auth para evitar novo login ao reabrir o navegador ou PWA.
- Autenticação: novas variáveis `AUTH_SESSION_EXPIRES_IN_DAYS` e `AUTH_SESSION_UPDATE_AGE_HOURS` para configurar, em ambientes self-hosted, a duração e a renovação de sessões persistentes.
- Contas: o extrato de uma conta agora tem um atalho "Adicionar rendimento" ao lado de "Ajustar saldo", abrindo um modal simples com valor e data para criar uma receita paga na conta atual, com categoria `Rendimentos`, forma de pagamento `Transferência bancária` e pessoa admin.
- Insights: adicionado suporte ao provider MiniMax via `vercel-minimax-ai-provider`, incluindo os modelos M2.7, M2.7 Highspeed, M2.5, M2.5 Highspeed, M2.1, M2.1 Highspeed e M2.
- Configuração: adicionada a variável `MINIMAX_API_KEY` aos exemplos de ambiente e ao assistente de setup.
- Insights: adicionado suporte ao provider Ollama via endpoint OpenAI-compatible, com modelos sugeridos `llama3.2`, `llama3.1`, `qwen2.5` e `mistral`, além de input para qualquer modelo instalado localmente.
- Configuração: adicionadas as variáveis `MINIMAX_API_KEY`, `OLLAMA_BASE_URL` e `OLLAMA_API_KEY` aos exemplos de ambiente, ao assistente de setup e à documentação.
- Dependências: adicionada `@ai-sdk/openai-compatible` para integrar provedores compatíveis com a API da OpenAI, incluindo Ollama.
- Lançamentos: o campo "Dividir com" agora permite selecionar múltiplas pessoas e exibe um campo de valor para cada participante escolhido.
- Lançamentos: o modal de criação e edição agora exibe um card compacto de resumo da operação abaixo dos anexos, incluindo forma de pagamento, destino, categoria, pessoas, valores divididos e quantidade de lançamentos que serão criados.
@@ -22,6 +24,8 @@ Esta versão melhora o uso diário do OpenMonetis em várias frentes: sessões m
- Contas: o modal "Adicionar rendimento" usa o mesmo seletor de data do modal de lançamentos e os botões de rendimento e ajuste de saldo agora exibem tooltip.
- Categorias: o header de `/categories/[categoryId]` agora usa três blocos de métrica alinhados para total do mês selecionado, total do mês anterior e variação.
- Dashboard: o botão expansível dos widgets passou de "Ver tudo" para "Expandir", com visual secundário e gradiente inferior mais compacto para diferenciar melhor a ação de abrir o modal dos links que navegam para páginas completas.
- Insights: a resolução de modelos foi centralizada em `model-provider.ts`, reduzindo ramificações na action de geração e preservando OpenAI, Anthropic, Google, MiniMax e OpenRouter.
- Insights: o aviso de privacidade agora diferencia providers externos de providers locais como Ollama.
- Lançamentos: o filtro de categorias agora separa as opções em grupos de `Despesas` e `Receitas`, preservando ícones e busca dentro do seletor.
- Lançamentos: a configuração de divisão foi movida para um modal dedicado e minimalista, com seleção direta de participantes, divisão igual e conferência do total distribuído.
- Lançamentos: a validação de divisão agora aceita uma lista de participações, exige pessoas distintas e confere se a soma dos valores bate com o total do lançamento.

View File

@@ -72,7 +72,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
💸 **Parcelamentos avançados** — Séries de parcelas, antecipação com cálculo de desconto, análise consolidada.
🤖 **Insights com IA** — Análises geradas por Claude, GPT, Gemini, MiniMax ou OpenRouter. Insights personalizados e histórico salvo.
🤖 **Insights com IA** — Análises geradas por Claude, GPT, Gemini, MiniMax, OpenRouter ou modelos locais via Ollama. Insights personalizados e histórico salvo.
👥 **Gestão colaborativa** — Pagadores com permissões (admin/viewer), notificações automáticas por e-mail, códigos de compartilhamento.
@@ -96,7 +96,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
- **shadcn/ui** (Radix UI) + **Tailwind CSS**
- **Docker** (multi-stage build)
- **Biome** (linting + formatting)
- **Vercel AI SDK** (Claude, GPT, Gemini, MiniMax, OpenRouter)
- **Vercel AI SDK** (Claude, GPT, Gemini, MiniMax, OpenRouter, Ollama)
---
@@ -474,6 +474,8 @@ OPENAI_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY=
MINIMAX_API_KEY=
OPENROUTER_API_KEY=
OLLAMA_BASE_URL=http://localhost:11434/v1
OLLAMA_API_KEY=
# Logo.dev (opcional, necessário para logos automáticos de estabelecimentos)
# Ambas as variáveis são runtime — basta definir no host; nenhum build arg necessário.
@@ -481,6 +483,25 @@ LOGO_DEV_TOKEN=
LOGO_DEV_SECRET_KEY=
```
### IA local com Ollama
O provider Ollama permite gerar insights usando modelos locais. Instale e suba o Ollama no host onde o modelo ficará disponível:
```bash
ollama pull llama3.2
ollama serve
```
Configure a URL OpenAI-compatible no `.env`:
```env
OLLAMA_BASE_URL=http://localhost:11434/v1
# Opcional; normalmente o Ollama local não exige chave.
OLLAMA_API_KEY=
```
Se o OpenMonetis estiver rodando dentro de um container Docker e o Ollama estiver no host, `localhost` aponta para o próprio container. Nesse caso, use uma URL acessível a partir do container, como `http://host.docker.internal:11434/v1` quando disponível, ou o endereço da rede Docker/host configurado no seu ambiente.
---
## 🏗️ Arquitetura

View File

@@ -34,6 +34,7 @@
"@ai-sdk/anthropic": "^3.0.79",
"@ai-sdk/google": "^3.0.79",
"@ai-sdk/openai": "^3.0.65",
"@ai-sdk/openai-compatible": "^2.0.48",
"@aws-sdk/client-s3": "^3.1050.0",
"@aws-sdk/s3-request-presigner": "^3.1050.0",
"@better-auth/passkey": "^1.6.11",

15
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
'@ai-sdk/openai':
specifier: ^3.0.65
version: 3.0.65(zod@4.4.3)
'@ai-sdk/openai-compatible':
specifier: ^2.0.48
version: 2.0.48(zod@4.4.3)
'@aws-sdk/client-s3':
specifier: ^3.1050.0
version: 3.1053.0
@@ -264,6 +267,12 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/openai-compatible@2.0.48':
resolution: {integrity: sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/openai@3.0.65':
resolution: {integrity: sha512-ZlVoWH+zrdiYDiUt6n/xvfCsk33mzsB81TUQkBRVx79rxU1FKZqVH9J/QCtEpSLqx0cUzjvtIw9l9p7EbUv+dw==}
engines: {node: '>=18'}
@@ -3775,6 +3784,12 @@ snapshots:
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
zod: 4.4.3
'@ai-sdk/openai-compatible@2.0.48(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
zod: 4.4.3
'@ai-sdk/openai@3.0.65(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -231,6 +231,8 @@ let openaiKey = "";
let googleAiKey = "";
let minimaxKey = "";
let openrouterKey = "";
let ollamaBaseUrl = "";
let ollamaApiKey = "";
if (await askYesNo(" Insights com IA (Claude, GPT, Gemini, MiniMax, OpenRouter)?")) {
console.log(` ${c.dim}Deixe em branco o que não for usar${c.reset}`);
anthropicKey = await ask(" ANTHROPIC_API_KEY: ");
@@ -239,6 +241,10 @@ if (await askYesNo(" Insights com IA (Claude, GPT, Gemini, MiniMax, OpenRouter)
minimaxKey = await ask(" MINIMAX_API_KEY: ");
openrouterKey = await ask(" OPENROUTER_API_KEY: ");
}
if (await askYesNo(" Insights locais com Ollama?")) {
ollamaBaseUrl = await askDefault(" OLLAMA_BASE_URL", "http://localhost:11434/v1");
ollamaApiKey = await ask(" OLLAMA_API_KEY (opcional): ");
}
// Domínio público
let publicDomain = "";
@@ -317,6 +323,8 @@ const envContent = [
opt("GOOGLE_GENERATIVE_AI_API_KEY", googleAiKey),
opt("MINIMAX_API_KEY", minimaxKey),
opt("OPENROUTER_API_KEY", openrouterKey),
opt("OLLAMA_BASE_URL", ollamaBaseUrl),
opt("OLLAMA_API_KEY", ollamaApiKey),
].join("\n");
writeFileSync(join(targetDir, ".env"), envContent);

View File

@@ -90,7 +90,7 @@
.dark {
--background: oklch(18% 0.004 55);
--foreground: oklch(93% 0.008 80);
--foreground: #feefe1;
--card: oklch(21.531% 0.00369 48.293);
--card-foreground: var(--foreground);
--popover: oklch(24% 0.004 55);

View File

@@ -1,17 +1,14 @@
"use server";
import { anthropic } from "@ai-sdk/anthropic";
import { google } from "@ai-sdk/google";
import { openai } from "@ai-sdk/openai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { generateObject } from "ai";
import { minimax } from "vercel-minimax-ai-provider";
import { getUser } from "@/shared/lib/auth/server";
import {
type InsightsResponse,
InsightsResponseSchema,
} from "@/shared/lib/schemas/insights";
import { AVAILABLE_MODELS, INSIGHTS_SYSTEM_PROMPT } from "../constants";
import { INSIGHTS_SYSTEM_PROMPT } from "../constants";
import { resolveInsightsModel } from "../lib/model-provider";
import { USER_INSTRUCTIONS_MAX_LENGTH } from "../lib/user-instructions";
import { aggregateMonthData } from "./aggregate";
import type { ActionResult } from "./types";
@@ -20,6 +17,7 @@ const PERIOD_REGEX = /^\d{4}-\d{2}$/;
export async function generateInsightsAction(
period: string,
modelId: string,
userInstructions?: string,
): Promise<ActionResult<InsightsResponse>> {
try {
const user = await getUser();
@@ -31,52 +29,23 @@ export async function generateInsightsAction(
};
}
const selectedModel = AVAILABLE_MODELS.find((m) => m.id === modelId);
const isOpenRouterFormat = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/.test(
modelId,
);
if (!selectedModel && !isOpenRouterFormat) {
const normalizedUserInstructions = userInstructions?.trim() ?? "";
if (normalizedUserInstructions.length > USER_INSTRUCTIONS_MAX_LENGTH) {
return {
success: false,
error: "Modelo inválido.",
error: `As orientações devem ter no máximo ${USER_INSTRUCTIONS_MAX_LENGTH} caracteres.`,
};
}
const resolvedModel = resolveInsightsModel(modelId);
if (!resolvedModel.success) {
return resolvedModel;
}
const aggregatedData = await aggregateMonthData(user.id, period);
let model: ReturnType<typeof google>;
if (isOpenRouterFormat && !selectedModel) {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return {
success: false,
error:
"OPENROUTER_API_KEY não configurada. Adicione a chave no arquivo .env",
};
}
const openrouter = createOpenRouter({
apiKey,
});
model = openrouter.chat(modelId);
} else if (selectedModel?.provider === "openai") {
model = openai(modelId);
} else if (selectedModel?.provider === "anthropic") {
model = anthropic(modelId);
} else if (selectedModel?.provider === "google") {
model = google(modelId);
} else if (selectedModel?.provider === "minimax") {
model = minimax(modelId);
} else {
return {
success: false,
error: "Provider de modelo não suportado.",
};
}
const result = await generateObject({
model,
model: resolvedModel.model,
schema: InsightsResponseSchema,
system: INSIGHTS_SYSTEM_PROMPT,
prompt: `Analise os seguintes dados financeiros agregados do período ${period}.
@@ -101,6 +70,11 @@ DADOS IMPORTANTES PARA SUA ANÁLISE:
- Comprometimento futuro de R$ ${aggregatedData.installments.futureCommitment.toFixed(2)}
- Use isso para alertas sobre comprometimento de renda futura
ORIENTAÇÕES DO USUÁRIO PARA ESTA ANÁLISE:
${normalizedUserInstructions || "Nenhuma orientação adicional."}
Use as orientações do usuário apenas para priorizar achados, ajustar foco e calibrar o tom da análise. Não ignore o schema obrigatório, não invente dados que não estejam nos agregados e não execute ações ou alterações no sistema.
Organize suas observações nas 4 categories especificadas no prompt do sistema:
1. Comportamentos Observados (behaviors): 3-6 itens
2. Gatilhos de Consumo (triggers): 3-6 itens

View File

@@ -0,0 +1,246 @@
import {
RiCalendarLine,
RiDatabase2Line,
RiEditLine,
RiInformationLine,
RiSearchLine,
RiShieldCheckLine,
RiSparklingLine,
} from "@remixicon/react";
import type React from "react";
import { type AIProvider, PROVIDERS } from "@/features/insights/constants";
import { USER_INSTRUCTIONS_MAX_LENGTH } from "@/features/insights/lib/user-instructions";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { Textarea } from "@/shared/components/ui/textarea";
import { displayPeriod } from "@/shared/utils/period";
import { cn } from "@/shared/utils/ui";
import { ProviderIcon } from "./provider-icon";
interface AnalysisSummaryCardProps {
period: string;
currentProvider: AIProvider;
selectedModelLabel: string;
userInstructions: string;
onUserInstructionsChange: (value: string) => void;
}
export function AnalysisSummaryCard({
period,
currentProvider,
selectedModelLabel,
userInstructions,
onUserInstructionsChange,
}: AnalysisSummaryCardProps) {
const hasUserInstructions = userInstructions.trim().length > 0;
const handleUserInstructionsChange = (value: string) => {
onUserInstructionsChange(value.slice(0, USER_INSTRUCTIONS_MAX_LENGTH));
};
return (
<aside>
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardContent className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<RiSparklingLine className="size-4" />
</div>
<div>
<h3 className="font-semibold text-sm">Resumo da análise</h3>
<p className="text-muted-foreground text-xs">
Configuração atual
</p>
</div>
</div>
<Dialog>
<DialogTrigger asChild>
<Button
className="w-full justify-start"
type="button"
variant="secondary"
>
<RiEditLine className="size-4" />
{hasUserInstructions
? "Editar orientações da IA"
: "Adicionar orientações da IA"}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Orientações para a IA</DialogTitle>
<DialogDescription>
Use este campo para direcionar o foco e o tom desta análise.
Essas orientações não alteram os dados analisados nem
substituem o formato obrigatório da resposta.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-2xl bg-warning/15 p-4">
<div className="flex gap-3">
<RiInformationLine className="mt-0.5 size-5 shrink-0 text-warning" />
<div className="space-y-1">
<p className="font-medium text-sm">
O que pode ser ajustado
</p>
<p className="text-muted-foreground text-xs leading-relaxed">
Você pode pedir mais foco em parcelamentos, gastos
recorrentes, cartão de crédito, oportunidades de
economia ou preferir um tom mais direto. A IA ainda deve
seguir o schema e usar apenas os dados agregados do
período.
</p>
</div>
</div>
</div>
<Textarea
className="min-h-52 resize-y"
maxLength={USER_INSTRUCTIONS_MAX_LENGTH}
onChange={(event) =>
handleUserInstructionsChange(event.target.value)
}
placeholder="Ex: foque em parcelamentos e despesas recorrentes; seja mais direto; ignore gastos de mercado."
value={userInstructions}
/>
<div className="flex items-center justify-between gap-3 text-muted-foreground text-xs">
<span>
Exemplos bons: priorize economia, mais atenção ao
cartão, seja objetivo.
</span>
<span className="shrink-0">
{userInstructions.length}/{USER_INSTRUCTIONS_MAX_LENGTH}
</span>
</div>
</div>
<DialogFooter>
<Button
onClick={() => onUserInstructionsChange("")}
type="button"
variant="outline"
>
Limpar
</Button>
<DialogClose asChild>
<Button type="button">Aplicar orientações</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<p
className={cn(
"min-h-8 text-xs leading-relaxed",
hasUserInstructions ? "text-primary" : "text-info",
)}
>
{hasUserInstructions
? "Prompt personalizado ativo. As orientações serão consideradas nesta análise."
: "Prompt padrão ativo. A análise seguirá o formato e as prioridades originais."}
</p>
<div className="grid gap-2">
<SummaryRow
icon={<RiCalendarLine className="size-4" />}
label="Período"
value={displayPeriod(period)}
/>
<SummaryRow
icon={<RiDatabase2Line className="size-4" />}
label="Fonte dos dados"
value="Transações, categorias, cartões, contas, orçamentos, recorrências e parcelamentos do mês."
/>
</div>
<div>
<p className="mb-2 font-medium text-muted-foreground text-xs uppercase tracking-wide">
Modelo selecionado
</p>
<div className="flex items-center gap-3">
<ProviderIcon provider={currentProvider} />
<div className="min-w-0 flex-1">
<p className="font-semibold text-sm">
{PROVIDERS[currentProvider].name}
</p>
<p className="truncate text-muted-foreground text-xs">
{selectedModelLabel || "Nenhum modelo selecionado"}
</p>
</div>
{currentProvider === "ollama" && (
<Badge
className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-none"
variant="outline"
>
Local
</Badge>
)}
</div>
</div>
<div>
<div className="rounded-2xl bg-warning/15 p-4">
<div className="flex gap-3">
<RiSearchLine className="mt-0.5 size-4 shrink-0 text-warning" />
<div className="space-y-1">
<p className="font-medium text-xs">Escopo da análise</p>
<p className="text-muted-foreground text-xs leading-relaxed">
Busca comportamentos, gatilhos, recomendações e melhorias.
</p>
</div>
</div>
</div>
</div>
<div className="rounded-2xl bg-violet-500/10 p-4">
<div className="flex gap-3">
<RiShieldCheckLine className="mt-0.5 size-4 shrink-0 text-violet-600 dark:text-violet-300" />
<div className="space-y-1">
<p className="font-medium text-xs">Privacidade dos dados</p>
<p className="text-muted-foreground text-xs leading-relaxed">
{currentProvider === "ollama"
? "Dados enviados para sua instância Ollama."
: "Dados enviados ao provedor externo escolhido."}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</aside>
);
}
function SummaryRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex gap-3">
<div className="mt-0.5 text-muted-foreground">{icon}</div>
<div className="space-y-1">
<p className="font-semibold text-xs">{label}</p>
<p className="text-muted-foreground text-xs leading-relaxed">{value}</p>
</div>
</div>
);
}

View File

@@ -5,7 +5,9 @@ import {
RiFlashlightLine,
RiLightbulbLine,
RiRocketLine,
RiSparklingLine,
} from "@remixicon/react";
import type React from "react";
import {
Card,
CardContent,
@@ -22,6 +24,7 @@ import { cn } from "@/shared/utils/ui";
interface InsightsGridProps {
insights: InsightsResponse;
action?: React.ReactNode;
}
const CATEGORY_ICONS: Record<InsightCategoryId, RemixiconComponentType> = {
@@ -53,21 +56,34 @@ const CATEGORY_COLORS: Record<
},
};
export function InsightsGrid({ insights }: InsightsGridProps) {
export function InsightsGrid({ insights, action }: InsightsGridProps) {
const formattedPeriod = displayPeriod(insights.month);
return (
<div className="space-y-6">
<div className="space-y-2 px-1 text-muted-foreground">
<p>
No período selecionado ({formattedPeriod}), identificamos os
principais comportamentos e gatilhos que impactaram seu padrão de
consumo.
</p>
<p>Segue um panorama prático com recomendações acionáveis.</p>
</div>
<Card className="overflow-hidden border-primary/10 bg-linear-to-br from-primary/10 via-card to-card">
<CardContent className="px-4 py-1">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<RiSparklingLine className="size-5" />
</div>
<div className="space-y-1">
<p className="font-semibold text-lg tracking-tight">
Análise pronta para {formattedPeriod}
</p>
<p className="max-w-2xl text-muted-foreground text-sm leading-relaxed">
Organizamos os sinais mais relevantes do período em quatro
blocos: comportamentos, gatilhos, recomendações e
oportunidades de melhoria.
</p>
</div>
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
</CardContent>
</Card>
{/* Grid de Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{insights.categories.map((categoryData) => {
const categoryConfig = INSIGHT_CATEGORIES[categoryData.category];

View File

@@ -1,15 +1,18 @@
"use client";
import {
RiAlertLine,
RiDeleteBinLine,
RiEyeLine,
RiFlashlightLine,
RiLightbulbLine,
RiLoader4Line,
RiRocketLine,
RiSaveLine,
RiSparklingLine,
} from "@remixicon/react";
import { useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useEffect, useState, useTransition } from "react";
import { useEffect, useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import {
deleteSavedInsightsAction,
@@ -21,8 +24,6 @@ import {
savedInsightsQueryKey,
useSavedInsights,
} from "@/features/insights/hooks/use-saved-insights";
import { EmptyState } from "@/shared/components/feedback/empty-state";
import { Alert, AlertDescription } from "@/shared/components/ui/alert";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton";
@@ -47,6 +48,9 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
string | null
>(null);
const [error, setError] = useState<string | null>(null);
const [userInstructions, setUserInstructions] = useState("");
const [shouldScrollToAnalysis, setShouldScrollToAnalysis] = useState(false);
const analysisAreaRef = useRef<HTMLDivElement>(null);
const savedInsights = savedInsightsQuery.data ?? null;
const insights = draftInsights ?? savedInsights?.insights ?? null;
const selectedModel =
@@ -59,20 +63,45 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
draftInsights === null && savedInsightsQuery.error instanceof Error
? savedInsightsQuery.error.message
: null;
const shouldShowAnalysisArea = Boolean(
isPending ||
isLoadingSavedInsights ||
insights ||
error ||
savedInsightsError,
);
useEffect(() => {
void period;
setDraftInsights(null);
setSelectedModelOverride(null);
setError(null);
setShouldScrollToAnalysis(false);
}, [period]);
useEffect(() => {
if (!shouldScrollToAnalysis || !shouldShowAnalysisArea) return;
requestAnimationFrame(() => {
analysisAreaRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
});
setShouldScrollToAnalysis(false);
}, [shouldScrollToAnalysis, shouldShowAnalysisArea]);
const handleAnalyze = () => {
setError(null);
setShouldScrollToAnalysis(true);
onAnalyze?.();
startTransition(async () => {
try {
const result = await generateInsightsAction(period, selectedModel);
const result = await generateInsightsAction(
period,
selectedModel,
userInstructions,
);
if (result.success) {
setDraftInsights(result.data);
@@ -145,145 +174,173 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
return (
<div className="flex flex-col gap-6">
{/* Privacy Warning */}
<Alert className="border-none bg-primary/10">
<RiAlertLine className="size-4" color="red" />
<AlertDescription className="text-sm text-card-foreground">
<strong>Aviso de privacidade:</strong> Ao gerar insights, seus dados
financeiros serão enviados para o provedor de IA selecionado
(Anthropic, OpenAI, Google, MiniMax ou OpenRouter) para processamento.
Certifique-se de que você confia no provedor escolhido antes de
prosseguir.
</AlertDescription>
</Alert>
{/* Model Selector */}
<ModelSelector
value={selectedModel}
onValueChange={setSelectedModelOverride}
period={period}
onAnalyze={handleAnalyze}
userInstructions={userInstructions}
onUserInstructionsChange={setUserInstructions}
onCancel={() => {
setSelectedModelOverride(null);
setError(null);
}}
disabled={isPending}
isLoadingSavedInsights={isLoadingSavedInsights}
/>
{/* Analyze Button */}
<div className="flex items-center gap-3 flex-wrap">
<Button
onClick={handleAnalyze}
disabled={isPending || isLoadingSavedInsights}
className="bg-linear-to-r from-primary via-violet-400 to-cyan-400 dark:from-primary-dark dark:to-cyan-600"
>
<RiSparklingLine className="mr-2 size-5" aria-hidden="true" />
{isPending ? "Analisando..." : "Gerar análise inteligente"}
</Button>
{insights && !error && (
<Button
onClick={isSaved ? handleDelete : handleSave}
disabled={isSaving || isPending || isLoadingSavedInsights}
variant={isSaved ? "destructive" : "outline"}
>
{isSaved ? (
<>
<RiDeleteBinLine className="mr-2 size-4" />
{isSaving ? "Removendo..." : "Remover análise"}
</>
) : (
<>
<RiSaveLine className="mr-2 size-4" />
{isSaving ? "Salvando..." : "Salvar análise"}
</>
)}
</Button>
)}
{isSaved && savedDate && (
<span className="text-sm text-muted-foreground">
Salva em{" "}
{format(new Date(savedDate), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR,
})}
</span>
)}
</div>
{/* Content Area */}
<div className="min-h-[400px]">
{(isPending || isLoadingSavedInsights) && <LoadingState />}
{!isPending &&
!isLoadingSavedInsights &&
!insights &&
!error &&
!savedInsightsError && (
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
<EmptyState
media={<RiSparklingLine className="size-6 text-primary" />}
title="Nenhuma análise realizada"
description="Clique no botão acima para gerar insights inteligentes sobre seus
dados financeiros do mês selecionado."
/>
</Card>
)}
{!isPending && !isLoadingSavedInsights && error && (
<ErrorState
title="Erro ao gerar insights"
error={error}
onRetry={handleAnalyze}
/>
)}
{!isPending &&
!isLoadingSavedInsights &&
!error &&
savedInsightsError && (
{shouldShowAnalysisArea && (
<div className="min-h-[320px] scroll-mt-6" ref={analysisAreaRef}>
{(isPending || isLoadingSavedInsights) && <LoadingState />}
{!isPending && !isLoadingSavedInsights && error && (
<ErrorState
title="Erro ao carregar insights salvos"
error={savedInsightsError}
onRetry={() => void savedInsightsQuery.refetch()}
title="Erro ao gerar insights"
error={error}
onRetry={handleAnalyze}
/>
)}
{!isPending &&
!isLoadingSavedInsights &&
insights &&
!error &&
!savedInsightsError && <InsightsGrid insights={insights} />}
</div>
{!isPending &&
!isLoadingSavedInsights &&
!error &&
savedInsightsError && (
<ErrorState
title="Erro ao carregar insights salvos"
error={savedInsightsError}
onRetry={() => void savedInsightsQuery.refetch()}
/>
)}
{!isPending &&
!isLoadingSavedInsights &&
insights &&
!error &&
!savedInsightsError && (
<InsightsGrid
insights={insights}
action={
<div className="flex flex-col items-start sm:items-end">
<Button
onClick={isSaved ? handleDelete : handleSave}
disabled={isSaving || isPending || isLoadingSavedInsights}
variant={isSaved ? "destructive" : "secondary"}
>
{isSaved ? (
<>
<RiDeleteBinLine className="mr-2 size-4" />
{isSaving ? "Removendo..." : "Remover análise"}
</>
) : (
<>
<RiSaveLine className="mr-2 size-4" />
{isSaving ? "Salvando..." : "Salvar análise"}
</>
)}
</Button>
{isSaved && savedDate && (
<span className="text-muted-foreground text-xs">
Salva em{" "}
{format(new Date(savedDate), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR,
})}
</span>
)}
</div>
}
/>
)}
</div>
)}
</div>
);
}
function LoadingState() {
return (
<div className="space-y-6">
{/* Intro text skeleton */}
<div className="space-y-2 px-1">
<Skeleton className="h-5 w-full max-w-2xl" />
<Skeleton className="h-5 w-full max-w-md" />
</div>
const categories = [
{
label: "Comportamentos",
icon: RiEyeLine,
color: "text-orange-600 dark:text-orange-400",
},
{
label: "Gatilhos",
icon: RiFlashlightLine,
color: "text-amber-600 dark:text-amber-400",
},
{
label: "Recomendações",
icon: RiLightbulbLine,
color: "text-sky-600 dark:text-sky-400",
},
{
label: "Melhorias",
icon: RiRocketLine,
color: "text-emerald-600 dark:text-emerald-400",
},
];
{/* Grid de Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="relative overflow-hidden">
<CardHeader>
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" />
<Skeleton className="h-5 w-32" />
return (
<div className="space-y-4">
<Card className="overflow-hidden border-primary/10 bg-linear-to-br from-primary/10 via-card to-card">
<CardContent className="px-4 py-1">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<RiLoader4Line className="size-5 animate-spin" />
</div>
</CardHeader>
<CardContent>
{Array.from({ length: 4 }).map((_, j) => (
<div
key={j}
className="flex flex-1 border-b border-dashed py-2.5 gap-2 items-start last:border-0"
>
<Skeleton className="size-4 shrink-0 rounded" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
<div className="space-y-2">
<div className="space-y-1">
<p className="font-semibold text-lg tracking-tight">
Preparando sua análise
</p>
<p className="max-w-2xl text-muted-foreground text-sm leading-relaxed">
Estamos consolidando os dados do período e organizando os
achados em comportamentos, gatilhos, recomendações e
melhorias.
</p>
</div>
))}
</CardContent>
</Card>
))}
</div>
</div>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{categories.map((category) => {
const Icon = category.icon;
return (
<Card
key={category.label}
className="relative min-h-[390px] overflow-hidden"
>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Icon className={`size-5 ${category.color}`} />
<span className={`font-semibold ${category.color}`}>
{category.label}
</span>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, index) => (
<div className="space-y-2" key={index}>
<div className="flex items-start gap-2">
<Skeleton className="mt-0.5 size-4 shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-[82%]" />
</div>
</div>
{(index === 1 || index === 3) && (
<Skeleton className="ml-6 h-10 w-[72%] rounded-xl" />
)}
</div>
))}
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
);

View File

@@ -0,0 +1,154 @@
import { RiExternalLinkLine, RiSparklingLine } from "@remixicon/react";
import Link from "next/link";
import type {
AIProvider,
AVAILABLE_MODELS,
} from "@/features/insights/constants";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import { Input } from "@/shared/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
interface ModelSelectionCardProps {
currentProvider: AIProvider;
providerModels: Array<(typeof AVAILABLE_MODELS)[number]>;
selectValue: string;
customModel: string;
isCustomModelActive: boolean;
canUseCustomModel: boolean;
canAnalyze: boolean;
disabled?: boolean;
onModelSelect: (modelId: string) => void;
onCustomModelChange: (modelName: string) => void;
onCancel?: () => void;
onAnalyze: () => void;
}
export const CUSTOM_MODEL_VALUE = "custom";
export function ModelSelectionCard({
currentProvider,
providerModels,
selectValue,
customModel,
isCustomModelActive,
canUseCustomModel,
canAnalyze,
disabled,
onModelSelect,
onCustomModelChange,
onCancel,
onAnalyze,
}: ModelSelectionCardProps) {
return (
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardContent className="space-y-6">
<div className="space-y-3">
<div className="space-y-1">
<h3 className="font-semibold text-sm">2. Modelo específico</h3>
<p className="text-muted-foreground text-xs">
Escolha o modelo do provedor selecionado para esta análise.
</p>
</div>
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="flex min-w-0 flex-col gap-2 lg:flex-row">
<div className="w-full lg:w-72">
{currentProvider === "openrouter" ? (
<Input
value={customModel}
onChange={(event) =>
onCustomModelChange(event.target.value)
}
placeholder="anthropic/claude-opus-4.8-fast"
disabled={disabled}
className="h-9 w-full border-border/70 bg-background"
/>
) : (
<Select
value={selectValue}
onValueChange={onModelSelect}
disabled={disabled}
>
<SelectTrigger className="h-9 w-full border-border/70 bg-background">
<SelectValue placeholder="Selecione um modelo" />
</SelectTrigger>
<SelectContent>
{providerModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
{model.id === "gpt-5.5" ? " (Recomendado)" : ""}
</SelectItem>
))}
{canUseCustomModel && (
<SelectItem value={CUSTOM_MODEL_VALUE}>
Modelo customizado
</SelectItem>
)}
</SelectContent>
</Select>
)}
</div>
{isCustomModelActive && currentProvider === "ollama" && (
<div className="w-full lg:w-72">
<Input
value={customModel}
onChange={(event) =>
onCustomModelChange(event.target.value)
}
placeholder="Ex: llama3.2"
disabled={disabled}
className="h-9 w-full border-border/70 bg-background"
/>
</div>
)}
</div>
<div className="flex min-h-9 shrink-0 items-center text-muted-foreground text-xs lg:max-w-none">
{currentProvider === "openrouter" && (
<Link
href="https://openrouter.ai/models"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 transition-colors hover:text-foreground"
>
<RiExternalLinkLine className="size-3" />
Ver modelos do OpenRouter
</Link>
)}
{currentProvider === "ollama" && (
<span>
O modelo precisa estar instalado na instância Ollama
configurada.
</span>
)}
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<Button
disabled={disabled}
onClick={onCancel}
type="button"
variant="outline"
>
Cancelar
</Button>
<Button onClick={onAnalyze} disabled={!canAnalyze}>
<RiSparklingLine className="size-4" />
{disabled ? "Analisando..." : "Gerar insights"}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,97 +1,96 @@
"use client";
import { RiExternalLinkLine } from "@remixicon/react";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import {
type AIProvider,
AVAILABLE_MODELS,
DEFAULT_PROVIDER,
PROVIDERS,
} from "@/features/insights/constants";
import { Card } from "@/shared/components/ui/card";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { AnalysisSummaryCard } from "./analysis-summary-card";
import { CUSTOM_MODEL_VALUE, ModelSelectionCard } from "./model-selection-card";
import { ProviderSelectionCard } from "./provider-selection-card";
interface ModelSelectorProps {
value: string;
onValueChange: (value: string) => void;
period: string;
onAnalyze: () => void;
userInstructions: string;
onUserInstructionsChange: (value: string) => void;
onCancel?: () => void;
disabled?: boolean;
isLoadingSavedInsights?: boolean;
}
const PROVIDER_ICON_PATHS: Record<
AIProvider,
{ light: string; dark?: string }
> = {
openai: {
light: "/providers/chatgpt.svg",
dark: "/providers/chatgpt_dark_mode.svg",
},
anthropic: {
light: "/providers/claude.svg",
},
google: {
light: "/providers/gemini.svg",
},
minimax: {
light: "/providers/minimax.svg",
},
openrouter: {
light: "/providers/openrouter_light.svg",
dark: "/providers/openrouter_dark.svg",
},
};
const CUSTOM_MODEL_PROVIDERS = ["openrouter", "ollama"] as const;
function isCustomModelProvider(
provider: AIProvider,
): provider is (typeof CUSTOM_MODEL_PROVIDERS)[number] {
return CUSTOM_MODEL_PROVIDERS.includes(
provider as (typeof CUSTOM_MODEL_PROVIDERS)[number],
);
}
function getProviderFromValue(value: string): AIProvider | null {
if (value.startsWith("openrouter:")) {
return "openrouter";
}
if (value.startsWith("ollama:")) {
return "ollama";
}
if (value.includes("/")) {
return "openrouter";
}
return AVAILABLE_MODELS.find((model) => model.id === value)?.provider ?? null;
}
function stripCustomProviderPrefix(value: string, provider: AIProvider) {
if (!isCustomModelProvider(provider)) {
return value;
}
return value.startsWith(`${provider}:`)
? value.slice(`${provider}:`.length)
: value;
}
function getModelLabel(modelId: string) {
const model = AVAILABLE_MODELS.find((item) => item.id === modelId);
if (model) return model.name;
const provider = getProviderFromValue(modelId);
return provider ? stripCustomProviderPrefix(modelId, provider) : modelId;
}
export function ModelSelector({
value,
onValueChange,
period,
onAnalyze,
userInstructions,
onUserInstructionsChange,
onCancel,
disabled,
isLoadingSavedInsights,
}: ModelSelectorProps) {
// Estado para armazenar o provider selecionado manualmente
const [selectedProvider, setSelectedProvider] = useState<AIProvider | null>(
null,
);
const [customModel, setCustomModel] = useState(value);
// Sincronizar customModel quando value mudar (importante para pré-carregamento)
useEffect(() => {
// Se o value tem "/" é um modelo OpenRouter customizado
if (value.includes("/") || selectedProvider === "openrouter") {
setCustomModel(value);
setSelectedProvider("openrouter");
} else {
setCustomModel(value);
// Limpar selectedProvider para deixar o useMemo detectar automaticamente
setSelectedProvider(null);
}
}, [value, selectedProvider]);
// Determinar provider atual baseado no modelo selecionado ou provider manual
const currentProvider = useMemo(() => {
// Se há um provider selecionado manualmente, use-o
if (selectedProvider) {
return selectedProvider;
const detectedProvider = getProviderFromValue(value);
if (detectedProvider && isCustomModelProvider(detectedProvider)) {
setCustomModel(stripCustomProviderPrefix(value, detectedProvider));
return;
}
// Se o modelo tem "/" é OpenRouter
if (value.includes("/")) {
return "openrouter";
}
setCustomModel(value);
}, [value]);
// Caso contrário, tente detectar baseado no modelo
const model = AVAILABLE_MODELS.find((m) => m.id === value);
return model?.provider ?? DEFAULT_PROVIDER;
}, [value, selectedProvider]);
const currentProvider = getProviderFromValue(value) ?? DEFAULT_PROVIDER;
// Agrupar modelos por provider
const modelsByProvider = useMemo(() => {
const grouped: Record<
AIProvider,
@@ -102,6 +101,7 @@ export function ModelSelector({
google: [],
minimax: [],
openrouter: [],
ollama: [],
};
AVAILABLE_MODELS.forEach((model) => {
@@ -111,130 +111,88 @@ export function ModelSelector({
return grouped;
}, []);
// Atualizar provider (seleciona primeiro modelo daquele provider)
const handleProviderChange = (newProvider: AIProvider) => {
setSelectedProvider(newProvider);
const providerModels = modelsByProvider[currentProvider];
const selectedModelIsKnown = providerModels.some(
(model) => model.id === value,
);
const selectValue = selectedModelIsKnown ? value : CUSTOM_MODEL_VALUE;
const isCustomModelActive =
isCustomModelProvider(currentProvider) && !selectedModelIsKnown;
const selectedModelLabel = getModelLabel(value);
const canAnalyze =
!disabled &&
!isLoadingSavedInsights &&
selectedModelLabel.trim().length > 0;
const handleProviderChange = (newProvider: AIProvider) => {
if (newProvider === "openrouter") {
// Para OpenRouter, usa o modelo customizado ou limpa o valor
onValueChange(customModel || "");
setCustomModel("");
onValueChange("openrouter:");
return;
}
const firstModel = modelsByProvider[newProvider][0];
if (firstModel) {
onValueChange(firstModel.id);
return;
}
if (isCustomModelProvider(newProvider)) {
onValueChange(
customModel ? `${newProvider}:${customModel}` : `${newProvider}:`,
);
}
};
// Atualizar modelo customizado do OpenRouter
const handleModelSelect = (modelId: string) => {
if (modelId === CUSTOM_MODEL_VALUE) {
setCustomModel("");
onValueChange(`${currentProvider}:`);
return;
}
onValueChange(modelId);
};
const handleCustomModelChange = (modelName: string) => {
setCustomModel(modelName);
onValueChange(modelName);
onValueChange(`${currentProvider}:${modelName}`);
};
return (
<Card className="grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-6 items-start p-6">
{/* Descrição */}
<div className="space-y-2">
<h3 className="text-lg font-semibold">Definir modelo de análise</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Escolha o provedor de IA e o modelo específico que será utilizado para
gerar insights sobre seus dados financeiros. <br />
Diferentes modelos podem oferecer perspectivas variadas na análise.
</p>
</div>
{/* Seletor */}
<div className="flex flex-col gap-4 min-w-xs">
<RadioGroup
value={currentProvider}
onValueChange={(v) => handleProviderChange(v as AIProvider)}
disabled={disabled}
className="gap-3"
>
{(Object.keys(PROVIDERS) as AIProvider[]).map((providerId) => {
const provider = PROVIDERS[providerId];
const iconPaths = PROVIDER_ICON_PATHS[providerId];
return (
<div key={providerId} className="flex items-center gap-3">
<RadioGroupItem
value={providerId}
id={`provider-${providerId}`}
disabled={disabled}
/>
<div className="size-6 relative">
<Image
src={iconPaths.light}
alt={provider.name}
width={22}
height={22}
className={iconPaths.dark ? "dark:hidden" : ""}
/>
{iconPaths.dark && (
<Image
src={iconPaths.dark}
alt={provider.name}
width={22}
height={22}
className="hidden dark:block"
/>
)}
</div>
<Label
htmlFor={`provider-${providerId}`}
className="text-sm font-medium cursor-pointer flex-1"
>
{provider.name}
</Label>
</div>
);
})}
</RadioGroup>
{/* Seletor de Modelo */}
{currentProvider === "openrouter" ? (
<div className="space-y-2">
<Input
value={customModel}
onChange={(e) => handleCustomModelChange(e.target.value)}
placeholder="Ex: anthropic/claude-3.5-sonnet"
disabled={disabled}
className="border-none bg-neutral-200 dark:bg-neutral-800"
/>
<a
href="https://openrouter.ai/models"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RiExternalLinkLine className="h-3 w-3" />
Ver modelos disponíveis no OpenRouter
</a>
</div>
) : (
<Select
value={value}
onValueChange={onValueChange}
<section className="space-y-4">
<div className="grid items-stretch gap-4 xl:grid-cols-[minmax(0,1fr)_340px]">
<div className="space-y-4">
<ProviderSelectionCard
currentProvider={currentProvider}
disabled={disabled}
>
<SelectTrigger
disabled={disabled}
className="border-none bg-neutral-200 dark:bg-neutral-800"
>
<SelectValue placeholder="Selecione um modelo" />
</SelectTrigger>
<SelectContent>
{modelsByProvider[currentProvider].map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
onProviderChange={handleProviderChange}
/>
<ModelSelectionCard
currentProvider={currentProvider}
providerModels={providerModels}
selectValue={selectValue}
customModel={customModel}
isCustomModelActive={isCustomModelActive}
canUseCustomModel={isCustomModelProvider(currentProvider)}
canAnalyze={canAnalyze}
disabled={disabled}
onModelSelect={handleModelSelect}
onCustomModelChange={handleCustomModelChange}
onCancel={onCancel}
onAnalyze={onAnalyze}
/>
</div>
<AnalysisSummaryCard
period={period}
currentProvider={currentProvider}
selectedModelLabel={selectedModelLabel}
userInstructions={userInstructions}
onUserInstructionsChange={onUserInstructionsChange}
/>
</div>
</Card>
</section>
);
}

View File

@@ -0,0 +1,59 @@
import Image from "next/image";
import { type AIProvider, PROVIDERS } from "@/features/insights/constants";
const PROVIDER_ICON_PATHS: Partial<
Record<
AIProvider,
{ light: string; dark?: string; width?: number; height?: number }
>
> = {
openai: {
light: "/providers/chatgpt.svg",
dark: "/providers/chatgpt_dark_mode.svg",
},
anthropic: {
light: "/providers/claude.svg",
},
google: {
light: "/providers/gemini.svg",
},
minimax: {
light: "/providers/minimax.svg",
},
openrouter: {
light: "/providers/openrouter_light.svg",
dark: "/providers/openrouter_dark.svg",
},
ollama: {
light: "/providers/ollama_light.svg",
dark: "/providers/ollama_dark.svg",
width: 17,
height: 22,
},
};
export function ProviderIcon({ provider }: { provider: AIProvider }) {
const iconPaths = PROVIDER_ICON_PATHS[provider];
if (!iconPaths) return null;
return (
<div className="relative flex size-10 items-center justify-center">
<Image
src={iconPaths.light}
alt={PROVIDERS[provider].name}
width={iconPaths.width ?? 32}
height={iconPaths.height ?? 32}
className={iconPaths.dark ? "dark:hidden" : ""}
/>
{iconPaths.dark && (
<Image
src={iconPaths.dark}
alt={PROVIDERS[provider].name}
width={iconPaths.width ?? 32}
height={iconPaths.height ?? 32}
className="hidden dark:block"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,109 @@
import { RiCheckLine } from "@remixicon/react";
import { type AIProvider, PROVIDERS } from "@/features/insights/constants";
import { Card, CardContent } from "@/shared/components/ui/card";
import { cn } from "@/shared/utils/ui";
import { ProviderIcon } from "./provider-icon";
const PROVIDER_DETAILS: Record<AIProvider, { description: string }> = {
openai: {
description: "Qualidade e equilíbrio entre análise e custo.",
},
anthropic: {
description: "Forte em raciocínio e análises profundas.",
},
google: {
description: "Ideal para integração e velocidade.",
},
minimax: {
description: "Eficiente para grandes volumes de dados.",
},
openrouter: {
description: "Acesso a múltiplos modelos via API.",
},
ollama: {
description: "Execução local com privacidade total.",
},
};
interface ProviderSelectionCardProps {
currentProvider: AIProvider;
disabled?: boolean;
onProviderChange: (provider: AIProvider) => void;
}
export function ProviderSelectionCard({
currentProvider,
disabled,
onProviderChange,
}: ProviderSelectionCardProps) {
return (
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardContent className="space-y-6">
<div className="space-y-2">
<h2 className="font-semibold text-2xl tracking-tight">
Definir modelo de análise
</h2>
<p className="max-w-2xl text-muted-foreground text-sm leading-relaxed">
Escolha o provedor de IA e o modelo específico que serão usados para
gerar insights sobre seus dados financeiros. Diferentes modelos
podem oferecer perspectivas variadas na análise.
</p>
</div>
<div className="space-y-3">
<div className="space-y-1">
<h3 className="font-semibold text-sm">1. Provedor de IA</h3>
<p className="text-muted-foreground text-xs">
Selecione o provedor que melhor atende às suas necessidades.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{(Object.keys(PROVIDERS) as AIProvider[]).map((providerId) => {
const provider = PROVIDERS[providerId];
const details = PROVIDER_DETAILS[providerId];
const isSelected = currentProvider === providerId;
return (
<button
type="button"
key={providerId}
onClick={() => onProviderChange(providerId)}
disabled={disabled}
className={cn(
"group relative rounded-2xl border p-4 text-left transition-all hover:border-primary/60 hover:bg-primary/5 disabled:cursor-not-allowed disabled:opacity-70",
isSelected &&
"border-primary bg-primary/10 shadow-sm ring-1 ring-primary/20",
)}
>
<div className="flex items-start gap-3">
<div
className={cn(
"mt-1 flex size-4 shrink-0 items-center justify-center rounded-full border text-transparent transition-colors",
isSelected &&
"border-primary bg-primary text-primary-foreground",
)}
>
<RiCheckLine className="size-3" />
</div>
<ProviderIcon provider={providerId} />
<div className="min-w-0 flex-1 space-y-2">
<span className="font-semibold text-sm leading-none">
{provider.name}
</span>
<p className="text-muted-foreground text-xs leading-relaxed">
{details.description}
</p>
</div>
</div>
</button>
);
})}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -6,7 +6,8 @@ export type AIProvider =
| "anthropic"
| "google"
| "minimax"
| "openrouter";
| "openrouter"
| "ollama";
/**
* Metadados dos providers
@@ -15,27 +16,26 @@ export const PROVIDERS = {
openai: {
id: "openai" as const,
name: "ChatGPT",
icon: "RiOpenaiLine",
},
anthropic: {
id: "anthropic" as const,
name: "Claude AI",
icon: "RiRobot2Line",
},
google: {
id: "google" as const,
name: "Gemini",
icon: "RiGoogleLine",
},
minimax: {
id: "minimax" as const,
name: "MiniMax",
icon: "RiRobot2Line",
},
openrouter: {
id: "openrouter" as const,
name: "OpenRouter",
icon: "RiRouterLine",
},
ollama: {
id: "ollama" as const,
name: "Ollama",
},
} as const;
@@ -52,8 +52,8 @@ export const AVAILABLE_MODELS = [
// Anthropic
{
id: "claude-opus-4-7",
name: "Claude Opus 4.7",
id: "claude-opus-4-8",
name: "Claude Opus 4.8",
provider: "anthropic" as const,
},
{
@@ -120,6 +120,12 @@ export const AVAILABLE_MODELS = [
name: "MiniMax M2",
provider: "minimax" as const,
},
// Ollama
{ id: "ollama:llama3.2", name: "Llama 3.2", provider: "ollama" as const },
{ id: "ollama:llama3.1", name: "Llama 3.1", provider: "ollama" as const },
{ id: "ollama:qwen2.5", name: "Qwen 2.5", provider: "ollama" as const },
{ id: "ollama:mistral", name: "Mistral", provider: "ollama" as const },
] as const;
export const DEFAULT_MODEL = "gpt-5.5";

View File

@@ -0,0 +1,109 @@
import { anthropic } from "@ai-sdk/anthropic";
import { google } from "@ai-sdk/google";
import { openai } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import type { LanguageModel } from "ai";
import { minimax } from "vercel-minimax-ai-provider";
import { AVAILABLE_MODELS } from "../constants";
const OPENROUTER_MODEL_REGEX = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._:-]+$/;
type ResolveInsightsModelResult =
| { success: true; model: LanguageModel }
| { success: false; error: string };
function stripProviderPrefix(
modelId: string,
provider: "openrouter" | "ollama",
) {
return modelId.startsWith(`${provider}:`)
? modelId.slice(`${provider}:`.length).trim()
: modelId.trim();
}
export function resolveInsightsModel(
modelId: string,
): ResolveInsightsModelResult {
const normalizedModelId = modelId.trim();
const selectedModel = AVAILABLE_MODELS.find(
(m) => m.id === normalizedModelId,
);
const isOpenRouterModel =
normalizedModelId.startsWith("openrouter:") ||
(!selectedModel && OPENROUTER_MODEL_REGEX.test(normalizedModelId));
const isOllamaModel = normalizedModelId.startsWith("ollama:");
if (!selectedModel && !isOpenRouterModel && !isOllamaModel) {
return {
success: false,
error: "Modelo inválido.",
};
}
if (isOpenRouterModel) {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return {
success: false,
error:
"OPENROUTER_API_KEY não configurada. Adicione a chave no arquivo .env",
};
}
const openrouterModelId = stripProviderPrefix(
normalizedModelId,
"openrouter",
);
if (!openrouterModelId) {
return {
success: false,
error: "Informe um modelo válido do OpenRouter.",
};
}
const openrouter = createOpenRouter({ apiKey });
return { success: true, model: openrouter.chat(openrouterModelId) };
}
if (isOllamaModel || selectedModel?.provider === "ollama") {
const ollamaModelId = stripProviderPrefix(normalizedModelId, "ollama");
if (!ollamaModelId) {
return {
success: false,
error: "Informe um modelo válido do Ollama.",
};
}
const ollama = createOpenAICompatible({
name: "ollama",
baseURL: process.env.OLLAMA_BASE_URL ?? "http://localhost:11434/v1",
apiKey: process.env.OLLAMA_API_KEY || "ollama",
supportsStructuredOutputs: false,
});
return { success: true, model: ollama.chatModel(ollamaModelId) };
}
if (selectedModel?.provider === "openai") {
return { success: true, model: openai(normalizedModelId) };
}
if (selectedModel?.provider === "anthropic") {
return { success: true, model: anthropic(normalizedModelId) };
}
if (selectedModel?.provider === "google") {
return { success: true, model: google(normalizedModelId) };
}
if (selectedModel?.provider === "minimax") {
return { success: true, model: minimax(normalizedModelId) };
}
return {
success: false,
error: "Provider de modelo não suportado.",
};
}

View File

@@ -0,0 +1 @@
export const USER_INSTRUCTIONS_MAX_LENGTH = 1000;