diff --git a/.env.example b/.env.example
index 4d9bb73..efc1c0e 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 686a470..133d053 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index 16afbb6..ea359d1 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/package.json b/package.json
index 87987b8..4b3d939 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d3f0848..2aae355 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/public/providers/ollama_dark.svg b/public/providers/ollama_dark.svg
new file mode 100644
index 0000000..fa8a612
--- /dev/null
+++ b/public/providers/ollama_dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/providers/ollama_light.svg b/public/providers/ollama_light.svg
new file mode 100644
index 0000000..833defd
--- /dev/null
+++ b/public/providers/ollama_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/setup.mjs b/setup.mjs
index 64e9391..d80e6cf 100644
--- a/setup.mjs
+++ b/setup.mjs
@@ -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);
diff --git a/src/app/globals.css b/src/app/globals.css
index 5b90572..bcb10b1 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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);
diff --git a/src/features/insights/actions/generate.ts b/src/features/insights/actions/generate.ts
index 4ee646c..261380d 100644
--- a/src/features/insights/actions/generate.ts
+++ b/src/features/insights/actions/generate.ts
@@ -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> {
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;
-
- 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
diff --git a/src/features/insights/components/analysis-summary-card.tsx b/src/features/insights/components/analysis-summary-card.tsx
new file mode 100644
index 0000000..9d5f210
--- /dev/null
+++ b/src/features/insights/components/analysis-summary-card.tsx
@@ -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 (
+
+ );
+}
+
+function SummaryRow({
+ icon,
+ label,
+ value,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ value: string;
+}) {
+ return (
+
- {/* Privacy Warning */}
-
-
-
- Aviso de privacidade: 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.
-
-
-
- {/* Model Selector */}
{
+ setSelectedModelOverride(null);
+ setError(null);
+ }}
disabled={isPending}
+ isLoadingSavedInsights={isLoadingSavedInsights}
/>
- {/* Analyze Button */}
-
- Escolha o provedor de IA e o modelo específico que será utilizado para
- gerar insights sobre seus dados financeiros.
- Diferentes modelos podem oferecer perspectivas variadas na análise.
-