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 ( +