From 8bbf58e2e0c5b1693d9e0e53283edc3bf99ba6d3 Mon Sep 17 00:00:00 2001 From: Alon Barad <35544532+alon710@users.noreply.github.com> Date: Wed, 27 May 2026 13:56:41 +0300 Subject: [PATCH 01/13] feat(chat): AI chat agent page with tool-based data access Adds a streaming /chat page that uses the same AI provider configured for categorization (Claude or Ollama). The agent answers free-text questions about the user's finances through tool calls that wrap existing read-only DB queries (transactions, monthly summary, top merchants, category breakdown). Page and sidebar entry are disabled when no AI provider is configured. Co-Authored-By: Claude Opus 4.7 --- package-lock.json | 205 ++++++++++++++++++++- package.json | 7 +- src/app/api/chat/route.ts | 48 +++++ src/app/chat/page.tsx | 36 ++++ src/components/chat/chat-client.tsx | 245 ++++++++++++++++++++++++++ src/components/chat/chat-disabled.tsx | 33 ++++ src/components/layout/app-sidebar.tsx | 34 +++- src/i18n/messages/en.json | 21 +++ src/i18n/messages/he.json | 21 +++ src/server/ai/chat-model.ts | 37 ++++ src/server/ai/chat-tools.ts | 162 +++++++++++++++++ 11 files changed, 846 insertions(+), 3 deletions(-) create mode 100644 src/app/api/chat/route.ts create mode 100644 src/app/chat/page.tsx create mode 100644 src/components/chat/chat-client.tsx create mode 100644 src/components/chat/chat-disabled.tsx create mode 100644 src/server/ai/chat-model.ts create mode 100644 src/server/ai/chat-tools.ts diff --git a/package-lock.json b/package-lock.json index 0c4c131..9c03a33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,13 @@ "name": "spent", "version": "0.1.0", "dependencies": { + "@ai-sdk/anthropic": "^3.0.79", + "@ai-sdk/react": "^3.0.193", "@anthropic-ai/sdk": "0.95.2", "@base-ui/react": "^1.4.1", "@tanstack/react-query": "^5.100.10", + "ai": "^6.0.191", + "ai-sdk-ollama": "^3.8.4", "better-sqlite3": "12.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -27,7 +31,8 @@ "shadcn": "^4.7.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -41,6 +46,86 @@ "typescript": "^5" } }, + "node_modules/@ai-sdk/anthropic": { + "version": "3.0.79", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.79.tgz", + "integrity": "sha512-saEX+h5JDOkT9P/+REKDyikbnJiToFuLipgNcsmu4Zr3GW5kW1m9HhvrPK+vj63itIOsoZU6tmVIjkrePOlIUA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.120", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.120.tgz", + "integrity": "sha512-MYKAeD2q7/sa1ZdqtL2tw0Me0B8Tok6Q/fhkJDhJl39dG8u+VBlWO9yk9lcdm784bM418o1EKObo4aOxs6+18Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", + "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/react": { + "version": "3.0.193", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.193.tgz", + "integrity": "sha512-El0jUZ/B7mvBHAD5rfSDqOAhWxutVTq7BCNhfGuwfDPT9SO0TMHybh2bMkieJQI7YOfl+qNBoWrRAOHHaFb99Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "4.0.27", + "ai": "6.0.191", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -2011,6 +2096,15 @@ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "license": "MIT" }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -3791,6 +3885,15 @@ "win32" ] }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3836,6 +3939,42 @@ "node": ">= 14" } }, + "node_modules/ai": { + "version": "6.0.191", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.191.tgz", + "integrity": "sha512-zAxvjKebQE7YkSyyNIl0OM7i6/zygnKeF+yNUjD4nWOelYrG+LpDd6RnH6mjySI4zUpZ7o4wbnmAy8jc6u98vQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.120", + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27", + "@opentelemetry/api": "^1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ai-sdk-ollama": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/ai-sdk-ollama/-/ai-sdk-ollama-3.8.4.tgz", + "integrity": "sha512-vvhLHo9MrOhDxsxRWfOGqBmQsnbDPKQHqDG5vSYMkdu6q8RPxJIYag001jQIAKA2MO37Nf96gCAPWM+dufvglw==", + "license": "MIT", + "dependencies": { + "@ai-sdk/provider": "^3.0.10", + "@ai-sdk/provider-utils": "^4.0.27", + "jsonrepair": "^3.14.0", + "ollama": "^0.6.3" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "ai": "^6.0.177" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -5209,6 +5348,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -7819,6 +7967,12 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-to-ts": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", @@ -7876,6 +8030,15 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonrepair": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.14.0.tgz", + "integrity": "sha512-tWPGKMZf/8UPim+fcW2EfcQ/d/7aKUrP6IECz9G3Tu6Q5dX0orSleqJ9z6sSw7qrQkjF8/Edo4DvsWBZ8H+HNg==", + "license": "ISC", + "bin": { + "jsonrepair": "bin/cli.js" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -9051,6 +9214,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ollama": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.6.3.tgz", + "integrity": "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg==", + "license": "MIT", + "dependencies": { + "whatwg-fetch": "^3.6.20" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -11048,6 +11220,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", + "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -11137,6 +11322,18 @@ "b4a": "^1.6.4" } }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -11718,6 +11915,12 @@ "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", "license": "Apache-2.0" }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 08ac3d9..b9df419 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,13 @@ "menubar:install:windows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"& './menubar/windows/build.ps1'; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; $d = Join-Path $env:LOCALAPPDATA 'Programs\\Spent'; New-Item -ItemType Directory -Force -Path $d | Out-Null; Copy-Item -Force 'menubar/windows/build/Spent.exe' (Join-Path $d 'Spent.exe')\"" }, "dependencies": { + "@ai-sdk/anthropic": "^3.0.79", + "@ai-sdk/react": "^3.0.193", "@anthropic-ai/sdk": "0.95.2", "@base-ui/react": "^1.4.1", "@tanstack/react-query": "^5.100.10", + "ai": "^6.0.191", + "ai-sdk-ollama": "^3.8.4", "better-sqlite3": "12.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -45,7 +49,8 @@ "shadcn": "^4.7.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts new file mode 100644 index 0000000..4504d5c --- /dev/null +++ b/src/app/api/chat/route.ts @@ -0,0 +1,48 @@ +import "server-only"; + +import { + convertToModelMessages, + streamText, + stepCountIs, + type UIMessage, +} from "ai"; +import { NextResponse } from "next/server"; +import { createChatModel } from "@/server/ai/chat-model"; +import { buildChatTools } from "@/server/ai/chat-tools"; +import { getWorkspaceIdFromRequest } from "@/server/lib/workspace-context"; + +export const maxDuration = 60; + +const SYSTEM_PROMPT = `You are Spent, a friendly assistant inside a personal finance app for an Israeli user. The user's transactions, categories, and summaries are private and live in a local SQLite database that you can query through the provided tools. + +Guidelines: +- Always call tools to get real data instead of guessing or fabricating numbers. +- Default currency is ILS (₪). Format amounts with at most two decimals and a thousands separator. +- Today's reasoning baseline: assume the user's local time zone is Asia/Jerusalem. +- When the user references a relative period ("last month", "this year"), compute concrete YYYY-MM-DD ranges before calling tools. +- When you need a category id, call listCategories first. +- Keep replies short and conversational. Use bullet points or small tables when listing multiple values. +- If a question is not about the user's finances, politely steer back to the app's purpose.`; + +export async function POST(req: Request) { + const model = createChatModel(); + if (!model) { + return NextResponse.json( + { error: "AI provider not configured" }, + { status: 400 } + ); + } + + const workspaceId = getWorkspaceIdFromRequest(req); + const { messages } = (await req.json()) as { messages: UIMessage[] }; + + const result = streamText({ + model, + system: SYSTEM_PROMPT, + messages: await convertToModelMessages(messages), + tools: buildChatTools(workspaceId), + stopWhen: stepCountIs(8), + }); + + return result.toUIMessageStreamResponse(); +} diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx new file mode 100644 index 0000000..0472bdd --- /dev/null +++ b/src/app/chat/page.tsx @@ -0,0 +1,36 @@ +import { redirect } from "next/navigation"; +import { AppShell } from "@/components/layout/app-shell"; +import { ChatClient } from "@/components/chat/chat-client"; +import { ChatDisabled } from "@/components/chat/chat-disabled"; +import { getDb } from "@/server/db/index"; +import { getAppSettings } from "@/server/db/queries/settings"; + +export const dynamic = "force-dynamic"; + +function anyWorkspaceHasBank(): boolean { + const row = getDb() + .prepare("SELECT COUNT(*) as count FROM bank_credentials") + .get() as { count: number }; + return row.count > 0; +} + +function firstWorkspaceId(): number { + const row = getDb() + .prepare("SELECT id FROM workspaces ORDER BY id LIMIT 1") + .get() as { id: number } | undefined; + if (!row) throw new Error("No workspace exists"); + return row.id; +} + +export default function ChatPage() { + if (!anyWorkspaceHasBank()) { + redirect("/setup"); + } + + const settings = getAppSettings(firstWorkspaceId()); + const enabled = settings.aiProvider !== "none"; + + return ( + {enabled ? : } + ); +} diff --git a/src/components/chat/chat-client.tsx b/src/components/chat/chat-client.tsx new file mode 100644 index 0000000..7382ee8 --- /dev/null +++ b/src/components/chat/chat-client.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { useTranslations } from "next-intl"; +import { ArrowUp, Loader2, MessageSquare, Square, Sparkles } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { PageHeader } from "@/components/layout/app-shell"; +import { useActiveWorkspaceId } from "@/lib/workspace-store"; +import { cn } from "@/lib/utils"; + +export function ChatClient() { + const t = useTranslations("chat"); + const workspaceId = useActiveWorkspaceId(); + + const { messages, sendMessage, status, stop, error, setMessages } = useChat({ + transport: new DefaultChatTransport({ + api: "/api/chat", + headers: (): Record => + workspaceId != null ? { "x-workspace-id": String(workspaceId) } : {}, + }), + }); + + const [input, setInput] = useState(""); + const scrollerRef = useRef(null); + + useEffect(() => { + const el = scrollerRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, [messages, status]); + + const isBusy = status === "submitted" || status === "streaming"; + + function submit() { + const text = input.trim(); + if (!text || isBusy) return; + sendMessage({ text }); + setInput(""); + } + + return ( + <> + 0 ? ( + + ) : null + } + /> + +
+
+
+ {messages.length === 0 && ( + { + setInput(""); + sendMessage({ text: s }); + }} + busy={isBusy} + /> + )} + + {messages.map((message) => ( + + ))} + + {status === "submitted" && ( +
+ + {t("thinking")} +
+ )} + + {error && ( +
+ {t("error")} +
+ )} +
+
+ +
+
{ + e.preventDefault(); + submit(); + }} + className="mx-auto flex max-w-3xl items-end gap-2" + > +