feat: add read-only savings agent tools

This commit is contained in:
2026-06-15 21:21:57 +02:00
parent 4a1cbd105b
commit 1c88d12f0d
4 changed files with 1274 additions and 46 deletions

View File

@@ -0,0 +1,53 @@
---
id: TASK-3
title: Build read-only savings agent with tool calls
status: In Progress
assignee: []
created_date: '2026-06-15 19:02'
updated_date: '2026-06-15 19:11'
labels: []
dependencies: []
priority: high
ordinal: 3000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Convert Talk to Savings from a prompt-packed chat into a read-only AI SDK tool-calling agent with deterministic transaction retrieval, spending summaries, forecasts, and compact frontend tool traces.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Savings chat action uses AI SDK tool calls with a bounded multi-step loop and returns a compact tool trace
- [x] #2 Read-only transaction retrieval tool supports selected scope, bounded custom ranges, account/category/search filters, exact totals, sanitized rows, and hasMore
- [x] #3 Spending summary and cashflow forecast tools compute deterministic aggregates without model-invented math
- [x] #4 Frontend remains localStorage-backed, loads legacy chats, and renders compact tool traces under assistant answers
- [x] #5 Focused Convex tests, build, and targeted lint verification pass or known blockers are documented
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Write failing Convex tests for read-only tool queries and traces
2. Implement internal read-only savings agent tool queries
3. Switch savingsChat.ask to AI SDK tool calling with bounded multi-step loop
4. Update frontend chat messages and compact tool trace rendering
5. Run focused tests, build, targeted lint, and record verification notes
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implementation complete pending user confirmation.
Subagents:
- Frontend UX worker updated src/pages/SavingsChatPage.tsx for legacy-safe localStorage message normalization and compact assistant tool traces.
- QA explorer reviewed Convex test strategy and confirmed mock AI SDK approach for ask() coverage.
Verification:
- PASS npx vitest convex/savingsChat.test.ts --run (8 tests)
- PASS npx eslint convex/savingsChat.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx
- PASS npm run build
- BLOCKED npm run lint on pre-existing unrelated files: convex/bank/comdirectProvider.ts, convex/bank/config.ts, src/components/import/TanAwaitDialog.tsx, layout/ui fast-refresh exports, SettingsPage.tsx, generated eslint-disable warnings, and existing React compiler warnings. No TASK-3 touched file appears in the full-lint error list.
<!-- SECTION:NOTES:END -->

View File

@@ -1,14 +1,58 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
import { convexTest } from "convex-test"; import { convexTest } from "convex-test";
import { describe, expect, test } from "vitest"; import { generateText } from "ai";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { api, internal } from "./_generated/api"; import { api, internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel"; import type { Id } from "./_generated/dataModel";
import { buildToolTraceFromSteps } from "./savingsChat";
import schema from "./schema"; import schema from "./schema";
vi.mock("ai", async (importOriginal) => {
const actual = await importOriginal<typeof import("ai")>();
return {
...actual,
generateText: vi.fn(async (options: {
tools: {
get_transactions: { execute: (input: unknown) => Promise<unknown> };
summarize_spending: { execute: (input: unknown) => Promise<unknown> };
};
}) => {
const transactionInput = { from: "2026-02-01", to: "2026-02-28", limit: 2 };
const summaryInput = { from: "2026-02-01", to: "2026-02-28" };
const transactionOutput = await options.tools.get_transactions.execute(transactionInput);
const summaryOutput = await options.tools.summarize_spending.execute(summaryInput);
return {
text: "Agenten-Antwort",
steps: [
{
toolResults: [
{
toolName: "get_transactions",
input: transactionInput,
output: transactionOutput,
},
{
toolName: "summarize_spending",
input: summaryInput,
output: summaryOutput,
},
],
},
],
};
}),
};
});
const modules = import.meta.glob("./**/*.ts"); const modules = import.meta.glob("./**/*.ts");
delete modules["./savingsChat.test.ts"]; delete modules["./savingsChat.test.ts"];
beforeEach(() => {
vi.clearAllMocks();
});
describe("savingsChat.getContext", () => { describe("savingsChat.getContext", () => {
test("counts and sums every matching transaction before applying prompt limits", async () => { test("counts and sums every matching transaction before applying prompt limits", async () => {
const t = convexTest(schema, modules); const t = convexTest(schema, modules);
@@ -192,3 +236,479 @@ describe("savingsChat.getContext", () => {
expect(context.transactionLines.join("\n")).not.toContain("Other account should not appear"); expect(context.transactionLines.join("\n")).not.toContain("Other account should not appear");
}); });
}); });
describe("savingsChat read-only agent tools", () => {
test("getTransactionsTool applies account scope, exact totals, limits, and sanitizes rows", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Tool User",
email: "tool@example.com",
});
const categoryId = await ctx.db.insert("categories", {
userId,
name: "Lebensmittel",
kind: "ausgabe",
block: "variabel",
color: "#22c55e",
sortOrder: 1,
isSystem: false,
});
const giroAccountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const savingsAccountId = await ctx.db.insert("accounts", {
userId,
name: "Tagesgeld",
type: "savings",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
await ctx.db.insert("transactions", {
userId,
accountId: giroAccountId,
categoryId,
bookingDate: "2026-01-05",
valueDate: "2026-01-05",
description: "Supermarkt",
counterparty: "Markt GmbH",
amount: -100,
isPending: false,
rawText: "RAW BANK PAYLOAD",
notes: "private note",
dedupHash: "secret-hash",
externalRef: "external-ref",
effectiveMonth: "2026-01",
});
await ctx.db.insert("transactions", {
userId,
accountId: giroAccountId,
bookingDate: "2026-01-07",
valueDate: "2026-01-07",
description: "Baecker",
amount: -15,
isPending: false,
effectiveMonth: "2026-01",
});
await ctx.db.insert("transactions", {
userId,
accountId: giroAccountId,
bookingDate: "2026-01-03",
valueDate: "2026-01-03",
description: "Kaffee",
amount: -5,
isPending: false,
effectiveMonth: "2026-01",
});
await ctx.db.insert("transactions", {
userId,
accountId: savingsAccountId,
bookingDate: "2026-01-08",
valueDate: "2026-01-08",
description: "Other account",
amount: 999,
isPending: false,
effectiveMonth: "2026-01",
});
return { userId, giroAccountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(internal.savingsChat.getTransactionsTool, {
scope: {
from: "2026-01-01",
to: "2026-01-31",
accountId: seeded.giroAccountId as Id<"accounts">,
basis: "booking",
},
limit: 2,
});
expect(result.totalCount).toBe(3);
expect(result.hasMore).toBe(true);
expect(result.rows).toHaveLength(2);
expect(result.totals).toEqual({
transactionCount: 3,
income: 0,
expenses: -120,
balance: -120,
});
expect(result.rows[0].accountName).toBe("Girokonto");
expect(result.rows.find((row) => row.description === "Supermarkt")?.categoryName).toBe(
"Lebensmittel",
);
expect(Object.keys(result.rows[0])).not.toContain("rawText");
expect(Object.keys(result.rows[0])).not.toContain("notes");
expect(Object.keys(result.rows[0])).not.toContain("dedupHash");
expect(Object.keys(result.rows[0])).not.toContain("externalRef");
expect(Object.keys(result.rows[0])).not.toContain("userId");
});
test("getTransactionsTool includes legacy effective-month rows via booking fallback", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Legacy User",
email: "legacy@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-03-12",
valueDate: "2026-03-12",
description: "Legacy no effective month",
amount: -30,
isPending: false,
});
await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-03-20",
valueDate: "2026-03-20",
description: "Modern effective month",
amount: -20,
isPending: false,
effectiveMonth: "2026-03",
});
return { userId, accountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(internal.savingsChat.getTransactionsTool, {
scope: {
from: "2026-03-01",
to: "2026-03-31",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
},
limit: 10,
});
expect(result.totalCount).toBe(2);
expect(result.totals.expenses).toBe(-50);
expect(result.rows.map((row) => row.description)).toContain("Legacy no effective month");
});
test("summarizeSpendingTool computes exact monthly and category aggregates", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Summary User",
email: "summary@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const rentId = await ctx.db.insert("categories", {
userId,
name: "Miete",
kind: "ausgabe",
block: "wiederkehrend",
color: "#64748b",
sortOrder: 1,
isSystem: false,
});
const foodId = await ctx.db.insert("categories", {
userId,
name: "Lebensmittel",
kind: "ausgabe",
block: "variabel",
color: "#22c55e",
sortOrder: 2,
isSystem: false,
});
const salaryId = await ctx.db.insert("categories", {
userId,
name: "Gehalt",
kind: "einnahme",
color: "#0ea5e9",
sortOrder: 3,
isSystem: false,
});
for (const tx of [
{ date: "2026-01-01", description: "Gehalt Januar", amount: 3000, categoryId: salaryId },
{ date: "2026-01-02", description: "Miete Januar", amount: -1000, categoryId: rentId },
{ date: "2026-01-10", description: "Supermarkt Januar", amount: -200, categoryId: foodId },
{ date: "2026-02-01", description: "Gehalt Februar", amount: 3000, categoryId: salaryId },
{ date: "2026-02-02", description: "Sonstiges Februar", amount: -50, categoryId: undefined },
]) {
await ctx.db.insert("transactions", {
userId,
accountId,
categoryId: tx.categoryId,
bookingDate: tx.date,
valueDate: tx.date,
description: tx.description,
amount: tx.amount,
isPending: false,
effectiveMonth: tx.date.slice(0, 7),
});
}
return { userId, accountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(internal.savingsChat.summarizeSpendingTool, {
scope: {
from: "2026-01-01",
to: "2026-02-28",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
},
});
expect(result.totals).toEqual({
transactionCount: 5,
income: 6000,
expenses: -1250,
balance: 4750,
});
expect(result.fixedCosts).toBe(-1000);
expect(result.variableCosts).toBe(-200);
expect(result.monthlyTrend).toEqual([
{ month: "2026-01", income: 3000, expenses: -1200, balance: 1800 },
{ month: "2026-02", income: 3000, expenses: -50, balance: 2950 },
]);
expect(result.categoryBreakdown.map((entry) => [entry.name, entry.amount])).toEqual([
["Miete", -1000],
["Lebensmittel", -200],
["Ohne Kategorie", -50],
]);
});
test("forecastCashflowTool excludes partial current month from the baseline", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Forecast User",
email: "forecast@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
for (const tx of [
{ date: "2026-04-01", description: "Gehalt April", amount: 3000 },
{ date: "2026-04-10", description: "Kosten April", amount: -2000 },
{ date: "2026-05-01", description: "Gehalt Mai", amount: 3200 },
{ date: "2026-05-10", description: "Kosten Mai", amount: -2200 },
{ date: "2026-06-01", description: "Gehalt Juni", amount: 3000 },
{ date: "2026-06-10", description: "Teilkosten Juni", amount: -500 },
]) {
await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: tx.date,
valueDate: tx.date,
description: tx.description,
amount: tx.amount,
isPending: false,
effectiveMonth: tx.date.slice(0, 7),
});
}
return { userId, accountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.query(internal.savingsChat.forecastCashflowTool, {
scope: {
from: "2026-04-01",
to: "2026-06-30",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
},
horizonMonths: 3,
asOf: "2026-06-15",
});
expect(result.baselineMonths).toEqual(["2026-04", "2026-05"]);
expect(result.excludedPartialMonth).toBe("2026-06");
expect(result.monthlyAverage).toEqual({ income: 3100, expenses: -2100, balance: 1000 });
expect(result.projection).toEqual([
{ month: "2026-07", income: 3100, expenses: -2100, balance: 1000 },
{ month: "2026-08", income: 3100, expenses: -2100, balance: 1000 },
{ month: "2026-09", income: 3100, expenses: -2100, balance: 1000 },
]);
});
test("buildToolTraceFromSteps returns compact summaries without raw tool output", () => {
const trace = buildToolTraceFromSteps([
{
toolResults: [
{
toolName: "get_transactions",
input: { from: "2026-01-01", to: "2026-01-31", limit: 50 },
output: {
totalCount: 2,
hasMore: false,
totals: { income: 0, expenses: -115, balance: -115, transactionCount: 2 },
rows: [
{ description: "Supermarkt", amount: -100 },
{ description: "Baecker", amount: -15 },
],
},
},
],
},
]);
expect(trace).toEqual([
{
name: "get_transactions",
inputSummary: "2026-01-01 bis 2026-01-31, Limit 50",
resultSummary: "2 Umsätze, Saldo -115.00€, vollständig",
},
]);
});
test("ask returns compact tool traces from executed read-only tools", async () => {
const t = convexTest(schema, modules);
const previousKey = process.env.OPENAI_API_KEY;
const previousModel = process.env.SAVINGS_CHAT_MODEL;
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Ask User",
email: "ask@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-02-01",
valueDate: "2026-02-01",
description: "Gehalt",
amount: 3000,
isPending: false,
effectiveMonth: "2026-02",
rawText: "RAW PAYLOAD SHOULD NOT LEAK",
});
await ctx.db.insert("transactions", {
userId,
accountId,
bookingDate: "2026-02-10",
valueDate: "2026-02-10",
description: "Supermarkt",
amount: -120,
isPending: false,
effectiveMonth: "2026-02",
notes: "private note should not leak",
});
return { userId, accountId };
});
try {
process.env.OPENAI_API_KEY = "test-key";
delete process.env.SAVINGS_CHAT_MODEL;
const asUser = t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
});
const result = await asUser.action(api.savingsChat.ask, {
messages: [{ role: "user", content: "Wie sieht Februar aus?" }],
from: "2026-02-01",
to: "2026-02-28",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
});
expect(result.answer).toBe("Agenten-Antwort");
expect(result.model).toBe("gpt-5.4-mini");
expect(result.usedTransactions).toBe(2);
expect(result.usedBalance).toEqual({ income: 3000, expenses: -120, balance: 2880 });
expect(result.toolTrace).toEqual([
{
name: "get_transactions",
inputSummary: "2026-02-01 bis 2026-02-28, Limit 2",
resultSummary: "2 Umsätze, Saldo 2880.00€, vollständig",
},
{
name: "summarize_spending",
inputSummary: "2026-02-01 bis 2026-02-28",
resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
},
]);
expect(JSON.stringify(result.toolTrace)).not.toContain("RAW PAYLOAD");
expect(JSON.stringify(result.toolTrace)).not.toContain("private note");
expect(generateText).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
get_transactions: expect.any(Object),
summarize_spending: expect.any(Object),
forecast_cashflow: expect.any(Object),
}),
stopWhen: expect.any(Function),
}),
);
} finally {
if (previousKey === undefined) {
delete process.env.OPENAI_API_KEY;
} else {
process.env.OPENAI_API_KEY = previousKey;
}
if (previousModel === undefined) {
delete process.env.SAVINGS_CHAT_MODEL;
} else {
process.env.SAVINGS_CHAT_MODEL = previousModel;
}
}
});
});

View File

@@ -1,9 +1,10 @@
import { action, internalQuery, query } from "./_generated/server"; import { action, internalQuery, query } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { generateText } from "ai"; import { generateText, stepCountIs, tool } from "ai";
import { openai } from "@ai-sdk/openai"; import { openai } from "@ai-sdk/openai";
import { internal } from "./_generated/api"; import { internal } from "./_generated/api";
import { bookingMonth } from "./lib/month"; import { z } from "zod";
import { addMonthsToMonthKey, bookingMonth, monthKeyFromBasis } from "./lib/month";
import { requireUserId } from "./lib/helpers"; import { requireUserId } from "./lib/helpers";
import type { Doc, Id } from "./_generated/dataModel"; import type { Doc, Id } from "./_generated/dataModel";
import type { QueryCtx } from "./_generated/server"; import type { QueryCtx } from "./_generated/server";
@@ -17,7 +18,9 @@ const chatMessageValidator = v.object({
}); });
const MAX_CONVERSATION_MESSAGES = 20; const MAX_CONVERSATION_MESSAGES = 20;
const MAX_PROMPT_CHARACTERS = 180_000; const DEFAULT_TOOL_ROW_LIMIT = 50;
const MAX_TOOL_ROW_LIMIT = 200;
const MAX_TOOL_RANGE_MONTHS = 18;
type ChatContextArgs = { type ChatContextArgs = {
from: string; from: string;
@@ -42,6 +45,34 @@ type ChatAskResult = {
answer: string; answer: string;
usedTransactions: number; usedTransactions: number;
usedBalance: { income: number; expenses: number; balance: number }; usedBalance: { income: number; expenses: number; balance: number };
toolTrace: ToolTrace[];
};
type ToolTrace = { name: string; inputSummary: string; resultSummary: string };
type TransactionTypeFilter = "income" | "expense";
type AgentToolScope = ChatContextArgs;
type TransactionToolArgs = {
scope: AgentToolScope;
from?: string;
to?: string;
accountId?: Id<"accounts">;
accountName?: string;
categoryIds?: Id<"categories">[];
categoryNames?: string[];
search?: string;
type?: TransactionTypeFilter;
limit?: number;
};
type ToolTransactionContext = {
from: string;
to: string;
basis: AgentToolScope["basis"];
accountId?: Id<"accounts">;
accountName?: string;
categories: Doc<"categories">[];
accounts: Doc<"accounts">[];
categoryById: Map<Id<"categories">, Doc<"categories">>;
accountById: Map<Id<"accounts">, Doc<"accounts">>;
transactions: Doc<"transactions">[];
}; };
function formatEuro(value: number): string { function formatEuro(value: number): string {
@@ -51,37 +82,18 @@ function formatEuro(value: number): string {
function buildSystemPrompt(context: { from: string; to: string; basis: string; accountName?: string }) { function buildSystemPrompt(context: { from: string; to: string; basis: string; accountName?: string }) {
return [ return [
"Du bist ein präziser Finanz-Chat-Assistent für Privatanwender.", "Du bist ein präziser Finanz-Chat-Assistent für Privatanwender.",
"Nutze ausschließlich die gelieferten Umsätze als Kontext und beziehe dich nur auf die angegebenen Werte.", "Nutze ausschließlich die bereitgestellten Werkzeuge und deren Ergebnisse als Finanzkontext.",
"Rufe Werkzeuge auf, wenn du Umsätze, Zusammenfassungen oder Prognosen brauchst; erfinde keine Beträge.",
"Antworte auf Deutsch, kurz und handlungsorientiert.", "Antworte auf Deutsch, kurz und handlungsorientiert.",
`Zeitraum: ${context.from} bis ${context.to}.`, `Zeitraum: ${context.from} bis ${context.to}.`,
`Basis: ${context.basis}.`, `Basis: ${context.basis}.`,
context.accountName ? `Konto: ${context.accountName}.` : "Konto: Alle Konten.", context.accountName ? `Konto: ${context.accountName}.` : "Konto: Alle Konten.",
"Wenn eine Aussage nur grob geschätzt werden kann, kennzeichne sie als Schätzung.", "Wenn eine Aussage nur grob geschätzt werden kann, kennzeichne sie als Schätzung.",
"Nenne keine internen IDs und keine Rohdatenfelder.",
"Verwende keine Links, keine HTML-Tags und keine Emojis.", "Verwende keine Links, keine HTML-Tags und keine Emojis.",
].join(" "); ].join(" ");
} }
function buildPrompt(context: ChatPromptContext, conversation: ChatMessage[]) {
return [
"Kontext der Auswertung:",
`Zeitraum: ${context.from} bis ${context.to}`,
`Basis: ${context.basis}`,
`Konto: ${context.accountName ?? "Alle Konten"}`,
`Anzahl Umsätze: ${context.totals.transactionCount}`,
`Einnahmen: ${formatEuro(context.totals.income)}`,
`Ausgaben: ${formatEuro(context.totals.expenses)}`,
`Saldo: ${formatEuro(context.totals.balance)}`,
"",
"Umsätze (neueste zuerst):",
...(context.transactionLines.length > 0
? context.transactionLines
: ["Keine Umsätze im Zeitraum."]),
"",
"Gesprächsverlauf:",
...conversation.map((message) => `${message.role}: ${message.content}`),
].join("\n");
}
function normalizeRole(role: ChatRole): "user" | "assistant" { function normalizeRole(role: ChatRole): "user" | "assistant" {
return role; return role;
} }
@@ -253,6 +265,55 @@ const contextSummaryValidator = v.object({
isComplete: v.literal(true), isComplete: v.literal(true),
}); });
const toolTraceValidator = v.object({
name: v.string(),
inputSummary: v.string(),
resultSummary: v.string(),
});
const toolScopeValidator = v.object(contextArgsValidator);
const transactionTypeFilterValidator = v.union(v.literal("income"), v.literal("expense"));
const transactionToolArgsValidator = {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
categoryIds: v.optional(v.array(v.id("categories"))),
categoryNames: v.optional(v.array(v.string())),
search: v.optional(v.string()),
type: v.optional(transactionTypeFilterValidator),
limit: v.optional(v.number()),
};
const safeTransactionRowValidator = v.object({
date: v.string(),
bookingDate: v.optional(v.string()),
effectiveMonth: v.optional(v.string()),
description: v.string(),
counterparty: v.optional(v.string()),
amount: v.number(),
categoryName: v.string(),
accountName: v.string(),
isPending: v.boolean(),
});
const monthlyTrendValidator = v.object({
month: v.string(),
income: v.number(),
expenses: v.number(),
balance: v.number(),
});
const categoryBreakdownValidator = v.object({
categoryId: v.optional(v.id("categories")),
name: v.string(),
amount: v.number(),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
});
export const getContext = query({ export const getContext = query({
args: contextArgsValidator, args: contextArgsValidator,
returns: contextSummaryValidator, returns: contextSummaryValidator,
@@ -263,6 +324,211 @@ export const getContext = query({
}, },
}); });
function parseMonthIndex(month: string) {
const [year, monthNumber] = month.split("-").map(Number);
return year * 12 + monthNumber;
}
function rangeMonthSpan(from: string, to: string) {
return parseMonthIndex(to.slice(0, 7)) - parseMonthIndex(from.slice(0, 7)) + 1;
}
function clampToolLimit(limit: number | undefined) {
if (limit === undefined) return DEFAULT_TOOL_ROW_LIMIT;
return Math.max(1, Math.min(MAX_TOOL_ROW_LIMIT, Math.floor(limit)));
}
function normalizeToolRange(scope: AgentToolScope, from?: string, to?: string) {
const range = {
from: from?.trim() || scope.from,
to: to?.trim() || scope.to,
};
if (range.from > range.to) {
throw new Error("Ungültiger Zeitraum: von-Datum liegt nach bis-Datum.");
}
if (rangeMonthSpan(range.from, range.to) > MAX_TOOL_RANGE_MONTHS) {
throw new Error(`Der Tool-Zeitraum darf maximal ${MAX_TOOL_RANGE_MONTHS} Monate umfassen.`);
}
return range;
}
async function loadNameMaps(ctx: QueryCtx, userId: Id<"users">) {
const categories = await ctx.db
.query("categories")
.withIndex("by_user", (index) => index.eq("userId", userId))
.collect();
const accounts = await ctx.db
.query("accounts")
.withIndex("by_user", (index) => index.eq("userId", userId))
.collect();
return {
categories,
accounts,
categoryById: new Map(categories.map((category) => [category._id, category])),
accountById: new Map(accounts.map((account) => [account._id, account])),
};
}
function findAccountIdByName(
accounts: Doc<"accounts">[],
accountName: string | undefined,
): Id<"accounts"> | undefined {
const normalized = accountName?.trim().toLocaleLowerCase("de-DE");
if (!normalized) return undefined;
return accounts.find((account) => account.name.toLocaleLowerCase("de-DE") === normalized)?._id;
}
function categoryNameSet(categoryNames: string[] | undefined) {
const normalized = categoryNames
?.map((name) => name.trim().toLocaleLowerCase("de-DE"))
.filter(Boolean);
return normalized && normalized.length > 0 ? new Set(normalized) : null;
}
function transactionMatchesToolFilters(
tx: Doc<"transactions">,
args: TransactionToolArgs,
context: Pick<ToolTransactionContext, "categoryById" | "accountById">,
) {
if (args.type === "income" && tx.amount <= 0) return false;
if (args.type === "expense" && tx.amount >= 0) return false;
if (args.categoryIds && args.categoryIds.length > 0) {
const categoryId = tx.categoryId;
if (!categoryId || !args.categoryIds.includes(categoryId)) return false;
}
const categoriesByName = categoryNameSet(args.categoryNames);
if (categoriesByName) {
const name = tx.categoryId ? context.categoryById.get(tx.categoryId)?.name : "Ohne Kategorie";
if (!categoriesByName.has((name ?? "Ohne Kategorie").toLocaleLowerCase("de-DE"))) {
return false;
}
}
const search = args.search?.trim().toLocaleLowerCase("de-DE");
if (search) {
const categoryName = tx.categoryId ? context.categoryById.get(tx.categoryId)?.name : "";
const accountName = tx.accountId ? context.accountById.get(tx.accountId)?.name : "";
const haystack = [
tx.description,
tx.counterparty,
categoryName,
accountName,
]
.filter(Boolean)
.join(" ")
.toLocaleLowerCase("de-DE");
if (!haystack.includes(search)) return false;
}
return true;
}
async function buildToolTransactionContext(
ctx: QueryCtx,
userId: Id<"users">,
args: TransactionToolArgs,
): Promise<ToolTransactionContext> {
const maps = await loadNameMaps(ctx, userId);
const range = normalizeToolRange(args.scope, args.from, args.to);
const accountId = args.accountId ?? findAccountIdByName(maps.accounts, args.accountName) ?? args.scope.accountId;
const account = accountId ? maps.accountById.get(accountId) : undefined;
const transactions = (await loadMatchingTransactions(ctx, userId, {
from: range.from,
to: range.to,
accountId,
basis: args.scope.basis,
})).filter((tx) => transactionMatchesToolFilters(tx, args, maps));
return {
...maps,
...range,
basis: args.scope.basis,
accountId,
accountName: account?.name,
transactions,
};
}
function safeTransactionRow(
tx: Doc<"transactions">,
context: Pick<ToolTransactionContext, "categoryById" | "accountById">,
) {
return {
date: tx.valueDate || tx.bookingDate || tx.effectiveMonth || "n/a",
bookingDate: tx.bookingDate,
effectiveMonth: tx.effectiveMonth,
description: tx.description,
counterparty: tx.counterparty,
amount: Math.round(tx.amount * 100) / 100,
categoryName: tx.categoryId
? context.categoryById.get(tx.categoryId)?.name ?? "Ohne Kategorie"
: "Ohne Kategorie",
accountName: tx.accountId
? context.accountById.get(tx.accountId)?.name ?? "Ohne Konto"
: "Ohne Konto",
isPending: tx.isPending,
};
}
function roundMoney(value: number) {
return Math.round(value * 100) / 100;
}
function summarizeTransactions(context: ToolTransactionContext) {
const monthlyMap = new Map<string, { income: number; expenses: number }>();
const categoryMap = new Map<
string,
{ categoryId?: Id<"categories">; name: string; amount: number; block?: "wiederkehrend" | "variabel" }
>();
let fixedCosts = 0;
let variableCosts = 0;
for (const tx of context.transactions) {
const month = monthKeyFromBasis(tx, context.basis);
if (month) {
const monthEntry = monthlyMap.get(month) ?? { income: 0, expenses: 0 };
if (tx.amount > 0) monthEntry.income += tx.amount;
if (tx.amount < 0) monthEntry.expenses += tx.amount;
monthlyMap.set(month, monthEntry);
}
if (tx.amount >= 0) continue;
const category = tx.categoryId ? context.categoryById.get(tx.categoryId) : undefined;
if (category?.block === "wiederkehrend") fixedCosts += tx.amount;
if (category?.block === "variabel") variableCosts += tx.amount;
const key = tx.categoryId ?? "none";
const categoryEntry = categoryMap.get(key) ?? {
categoryId: tx.categoryId,
name: category?.name ?? "Ohne Kategorie",
amount: 0,
block: category?.block,
};
categoryEntry.amount += tx.amount;
categoryMap.set(key, categoryEntry);
}
return {
totals: calculateTotals(context.transactions),
fixedCosts: roundMoney(fixedCosts),
variableCosts: roundMoney(variableCosts),
monthlyTrend: [...monthlyMap.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, data]) => ({
month,
income: roundMoney(data.income),
expenses: roundMoney(data.expenses),
balance: roundMoney(data.income + data.expenses),
})),
categoryBreakdown: [...categoryMap.values()]
.sort((a, b) => a.amount - b.amount)
.map((entry) => ({ ...entry, amount: roundMoney(entry.amount) })),
};
}
function toDisplayContextLine( function toDisplayContextLine(
tx: Doc<"transactions">, tx: Doc<"transactions">,
categoryById: Map<Id<"categories">, string>, categoryById: Map<Id<"categories">, string>,
@@ -278,6 +544,135 @@ function toDisplayContextLine(
}`; }`;
} }
export const getTransactionsTool = internalQuery({
args: transactionToolArgsValidator,
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
totalCount: v.number(),
hasMore: v.boolean(),
totals: totalsValidator,
rows: v.array(safeTransactionRowValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const limit = clampToolLimit(args.limit);
const rows = context.transactions
.slice(0, limit)
.map((tx) => safeTransactionRow(tx, context));
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
totalCount: context.transactions.length,
hasMore: context.transactions.length > limit,
totals: calculateTotals(context.transactions),
rows,
};
},
});
export const summarizeSpendingTool = internalQuery({
args: {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
categoryIds: v.optional(v.array(v.id("categories"))),
categoryNames: v.optional(v.array(v.string())),
search: v.optional(v.string()),
type: v.optional(transactionTypeFilterValidator),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
totals: totalsValidator,
fixedCosts: v.number(),
variableCosts: v.number(),
monthlyTrend: v.array(monthlyTrendValidator),
categoryBreakdown: v.array(categoryBreakdownValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const summary = summarizeTransactions(context);
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
...summary,
};
},
});
export const forecastCashflowTool = internalQuery({
args: {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
horizonMonths: v.optional(v.number()),
asOf: v.optional(v.string()),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
baselineMonths: v.array(v.string()),
excludedPartialMonth: v.union(v.string(), v.null()),
monthlyAverage: v.object({
income: v.number(),
expenses: v.number(),
balance: v.number(),
}),
projection: v.array(monthlyTrendValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const summary = summarizeTransactions(context);
const asOfMonth = (args.asOf ?? new Date().toISOString().slice(0, 10)).slice(0, 7);
const baseline = summary.monthlyTrend.filter((month) => month.month < asOfMonth);
const excludedPartialMonth = summary.monthlyTrend.some((month) => month.month === asOfMonth)
? asOfMonth
: null;
const horizonMonths = Math.max(1, Math.min(3, Math.floor(args.horizonMonths ?? 3)));
const denominator = baseline.length || 1;
const monthlyAverage = {
income: roundMoney(baseline.reduce((sum, month) => sum + month.income, 0) / denominator),
expenses: roundMoney(baseline.reduce((sum, month) => sum + month.expenses, 0) / denominator),
balance: roundMoney(baseline.reduce((sum, month) => sum + month.balance, 0) / denominator),
};
const projection = Array.from({ length: horizonMonths }, (_, index) => ({
month: addMonthsToMonthKey(asOfMonth, index + 1),
...monthlyAverage,
}));
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
baselineMonths: baseline.map((month) => month.month),
excludedPartialMonth,
monthlyAverage,
projection,
};
},
});
export const getPromptContext = internalQuery({ export const getPromptContext = internalQuery({
args: contextArgsValidator, args: contextArgsValidator,
returns: v.object({ returns: v.object({
@@ -308,6 +703,117 @@ export const getPromptContext = internalQuery({
}, },
}); });
function unknownRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function maybeString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function maybeNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function summarizeToolInput(input: unknown) {
const record = unknownRecord(input);
const parts = [];
const from = maybeString(record.from);
const to = maybeString(record.to);
const search = maybeString(record.search);
const limit = maybeNumber(record.limit);
const horizonMonths = maybeNumber(record.horizonMonths);
const type = maybeString(record.type);
const categoryNames = Array.isArray(record.categoryNames)
? record.categoryNames.filter((name): name is string => typeof name === "string")
: [];
if (from || to) parts.push(`${from ?? "?"} bis ${to ?? "?"}`);
if (search) parts.push(`Suche "${search}"`);
if (categoryNames.length > 0) parts.push(`Kategorien ${categoryNames.join(", ")}`);
if (type) parts.push(type === "income" ? "Einnahmen" : "Ausgaben");
if (horizonMonths) parts.push(`${horizonMonths} Monate Prognose`);
if (limit) parts.push(`Limit ${limit}`);
return parts.length > 0 ? parts.join(", ") : "Standard-Kontext";
}
function totalsFromOutput(output: Record<string, unknown>) {
const totals = unknownRecord(output.totals);
return {
count: maybeNumber(output.totalCount) ?? maybeNumber(totals.transactionCount) ?? 0,
balance: maybeNumber(totals.balance) ?? 0,
};
}
function summarizeToolOutput(toolName: string, output: unknown) {
const record = unknownRecord(output);
if (toolName === "get_transactions") {
const totals = totalsFromOutput(record);
const hasMore = record.hasMore === true;
return `${totals.count} Umsätze, Saldo ${formatEuro(totals.balance)}, ${
hasMore ? "weitere vorhanden" : "vollständig"
}`;
}
if (toolName === "summarize_spending") {
const totals = totalsFromOutput(record);
const categoryCount = Array.isArray(record.categoryBreakdown)
? record.categoryBreakdown.length
: 0;
return `${totals.count} Umsätze, Saldo ${formatEuro(totals.balance)}, ${categoryCount} Kategorien`;
}
if (toolName === "forecast_cashflow") {
const projectionCount = Array.isArray(record.projection) ? record.projection.length : 0;
const average = unknownRecord(record.monthlyAverage);
const balance = maybeNumber(average.balance) ?? 0;
return `Prognose ${projectionCount} Monate, durchschnittlicher Saldo ${formatEuro(balance)}`;
}
return "Werkzeug ausgeführt";
}
export function buildToolTraceFromSteps(steps: unknown[]): ToolTrace[] {
const trace: ToolTrace[] = [];
for (const step of steps) {
const stepRecord = unknownRecord(step);
const toolResults = Array.isArray(stepRecord.toolResults) ? stepRecord.toolResults : [];
for (const result of toolResults) {
const resultRecord = unknownRecord(result);
const name = maybeString(resultRecord.toolName);
if (!name) continue;
trace.push({
name,
inputSummary: summarizeToolInput(resultRecord.input),
resultSummary: summarizeToolOutput(name, resultRecord.output),
});
}
}
return trace;
}
const transactionToolInputSchema = z.object({
from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."),
to: z.string().optional().describe("Optionales Enddatum im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
categoryNames: z.array(z.string()).optional().describe("Optionale Kategorienamen wie Lebensmittel oder Miete."),
search: z.string().optional().describe("Optionaler Suchtext für Beschreibung, Gegenpartei, Konto oder Kategorie."),
type: z.enum(["income", "expense"]).optional().describe("Optional nur Einnahmen oder Ausgaben abrufen."),
limit: z.number().int().min(1).max(MAX_TOOL_ROW_LIMIT).optional().describe("Maximale Anzahl Umsatzzeilen."),
});
const summaryToolInputSchema = transactionToolInputSchema.omit({ limit: true });
const forecastToolInputSchema = z.object({
from: z.string().optional().describe("Optionales Startdatum der historischen Basis im Format YYYY-MM-DD."),
to: z.string().optional().describe("Optionales Enddatum der historischen Basis im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
horizonMonths: z.number().int().min(1).max(3).optional().describe("Anzahl der zu prognostizierenden Monate, 1 bis 3."),
});
export const ask = action({ export const ask = action({
args: { args: {
messages: v.array(chatMessageValidator), messages: v.array(chatMessageValidator),
@@ -325,6 +831,7 @@ export const ask = action({
expenses: v.number(), expenses: v.number(),
balance: v.number(), balance: v.number(),
}), }),
toolTrace: v.array(toolTraceValidator),
}), }),
handler: async (ctx, args): Promise<ChatAskResult> => { handler: async (ctx, args): Promise<ChatAskResult> => {
if (args.messages.length === 0) { if (args.messages.length === 0) {
@@ -338,26 +845,65 @@ export const ask = action({
} }
await requireUserId(ctx); await requireUserId(ctx);
const scope: AgentToolScope = {
const context: ChatPromptContext = await ctx.runQuery(internal.savingsChat.getPromptContext, {
from: args.from, from: args.from,
to: args.to, to: args.to,
accountId: args.accountId, accountId: args.accountId,
basis: args.basis, basis: args.basis,
};
const selectedSummary: {
totalCount: number;
totals: { income: number; expenses: number; balance: number; transactionCount: number };
accountName?: string;
} = await ctx.runQuery(internal.savingsChat.getTransactionsTool, {
scope,
limit: 1,
}); });
const lastMessages = args.messages const lastMessages = args.messages
.map((message): ChatMessage => ({ role: normalizeRole(message.role), content: message.content })) .map((message): ChatMessage => ({ role: normalizeRole(message.role), content: message.content }))
.slice(-MAX_CONVERSATION_MESSAGES); .slice(-MAX_CONVERSATION_MESSAGES);
const prompt = buildPrompt(context, lastMessages); const system = buildSystemPrompt({
if (prompt.length > MAX_PROMPT_CHARACTERS) { from: args.from,
throw new Error( to: args.to,
"Der ausgewählte Zeitraum enthält zu viele Umsatzdetails für eine vollständige KI-Anfrage. Bitte Zeitraum oder Konto eingrenzen.", basis: args.basis,
); accountName: selectedSummary.accountName,
} });
const system = buildSystemPrompt(context); const savingsTools = {
get_transactions: tool({
description:
"Ruft passende Umsätze read-only ab. Nutze dieses Tool für Detailfragen, Suche nach Gegenparteien/Beschreibungen oder Belege einzelner Aussagen. Es liefert exakte Summen und nur begrenzte, sanitizte Zeilen.",
inputSchema: transactionToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.getTransactionsTool, {
scope,
...input,
}),
}),
summarize_spending: tool({
description:
"Berechnet read-only exakte Summen, Monatsverläufe, Kategorien sowie fixe und variable Ausgaben für den ausgewählten oder angegebenen Zeitraum.",
inputSchema: summaryToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.summarizeSpendingTool, {
scope,
...input,
}),
}),
forecast_cashflow: tool({
description:
"Erstellt eine deterministische Cashflow-Prognose für 1 bis 3 kommende Monate aus vollständigen historischen Monaten. Nutze es für Sparrate, Monatsüberschuss und kurzfristige Prognosen.",
inputSchema: forecastToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.forecastCashflowTool, {
scope,
...input,
}),
}),
};
const envModel = process.env.SAVINGS_CHAT_MODEL?.trim(); const envModel = process.env.SAVINGS_CHAT_MODEL?.trim();
const candidates = [ const candidates = [
@@ -373,17 +919,20 @@ export const ask = action({
const result = await generateText({ const result = await generateText({
model: openai(modelName), model: openai(modelName),
system, system,
prompt, messages: lastMessages,
tools: savingsTools,
stopWhen: stepCountIs(5),
}); });
return { return {
model: modelName, model: modelName,
answer: result.text, answer: result.text,
usedTransactions: context.totals.transactionCount, usedTransactions: selectedSummary.totals.transactionCount,
usedBalance: { usedBalance: {
income: context.totals.income, income: selectedSummary.totals.income,
expenses: context.totals.expenses, expenses: selectedSummary.totals.expenses,
balance: context.totals.balance, balance: selectedSummary.totals.balance,
}, },
toolTrace: buildToolTraceFromSteps(result.steps),
}; };
} catch (error) { } catch (error) {
lastError = error; lastError = error;

View File

@@ -11,7 +11,18 @@ import { Separator } from "@/components/ui/separator";
import { ChatHistory, type ChatHistoryItem } from "@/components/chat/ChatHistory"; import { ChatHistory, type ChatHistoryItem } from "@/components/chat/ChatHistory";
import { toast } from "sonner"; import { toast } from "sonner";
type ChatMessage = { role: "user" | "assistant"; content: string }; type ToolTrace = {
name: string;
inputSummary: string;
resultSummary: string;
};
type UserChatMessage = { role: "user"; content: string };
type AssistantChatMessage = {
role: "assistant";
content: string;
toolTrace?: ToolTrace[];
};
type ChatMessage = UserChatMessage | AssistantChatMessage;
type ChatSession = { type ChatSession = {
id: string; id: string;
title: string; title: string;
@@ -27,6 +38,77 @@ const initialAssistantMessage: ChatMessage = {
}; };
const fallbackMessages = [initialAssistantMessage]; const fallbackMessages = [initialAssistantMessage];
function normalizeToolTrace(value: unknown): ToolTrace[] | undefined {
if (!Array.isArray(value)) return undefined;
const trace = value.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const candidate = item as Record<string, unknown>;
if (
typeof candidate.name !== "string" ||
typeof candidate.inputSummary !== "string" ||
typeof candidate.resultSummary !== "string"
) {
return [];
}
return [
{
name: candidate.name,
inputSummary: candidate.inputSummary,
resultSummary: candidate.resultSummary,
},
];
});
return trace.length > 0 ? trace : undefined;
}
function normalizeMessage(value: unknown): ChatMessage | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Record<string, unknown>;
if (typeof candidate.content !== "string") return null;
if (candidate.role === "user") {
return { role: "user", content: candidate.content };
}
if (candidate.role === "assistant") {
const toolTrace = normalizeToolTrace(candidate.toolTrace);
return toolTrace
? { role: "assistant", content: candidate.content, toolTrace }
: { role: "assistant", content: candidate.content };
}
return null;
}
function isChatMessage(value: ChatMessage | null): value is ChatMessage {
return value !== null;
}
function normalizeSession(value: unknown): ChatSession | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Record<string, unknown>;
if (
typeof candidate.id !== "string" ||
typeof candidate.title !== "string" ||
typeof candidate.createdAt !== "number" ||
typeof candidate.updatedAt !== "number" ||
!Array.isArray(candidate.messages)
) {
return null;
}
const messages = candidate.messages.map(normalizeMessage);
if (!messages.every(isChatMessage)) return null;
return {
id: candidate.id,
title: candidate.title,
createdAt: candidate.createdAt,
updatedAt: candidate.updatedAt,
messages,
};
}
function createSession(): ChatSession { function createSession(): ChatSession {
const now = Date.now(); const now = Date.now();
const randomId = const randomId =
@@ -47,9 +129,11 @@ function loadSessions(): ChatSession[] {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [createSession()]; if (!raw) return [createSession()];
const parsed = JSON.parse(raw) as ChatSession[]; const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length === 0) return [createSession()]; if (!Array.isArray(parsed) || parsed.length === 0) return [createSession()];
return parsed; const sessions = parsed.map(normalizeSession);
if (sessions.some((session) => session === null)) return [createSession()];
return sessions as ChatSession[];
} catch { } catch {
return [createSession()]; return [createSession()];
} }
@@ -156,16 +240,23 @@ export function SavingsChatPage() {
try { try {
const response = await ask({ const response = await ask({
messages: typedNextMessages, messages: typedNextMessages.map((message) => ({
role: message.role,
content: message.content,
})),
from, from,
to, to,
accountId, accountId,
basis: monthBasis, basis: monthBasis,
}); }) as { answer: string; toolTrace?: unknown };
updateSession(submittedSessionId, [ updateSession(submittedSessionId, [
...typedNextMessages, ...typedNextMessages,
{ role: "assistant", content: response.answer }, {
role: "assistant",
content: response.answer,
toolTrace: normalizeToolTrace(response.toolTrace),
},
]); ]);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -228,6 +319,21 @@ export function SavingsChatPage() {
> >
<p className="text-xs uppercase text-muted-foreground">{message.role}</p> <p className="text-xs uppercase text-muted-foreground">{message.role}</p>
<p className="whitespace-pre-wrap text-sm">{message.content}</p> <p className="whitespace-pre-wrap text-sm">{message.content}</p>
{message.role === "assistant" && message.toolTrace && message.toolTrace.length > 0 && (
<div className="mt-3 rounded-md border bg-muted/30 p-2">
<p className="text-xs font-medium text-muted-foreground">
Verwendete Werkzeuge
</p>
<div className="mt-2 space-y-2">
{message.toolTrace.map((tool, toolIndex) => (
<div key={`${tool.name}-${toolIndex}`} className="text-xs">
<p className="font-medium">{tool.name}</p>
<p className="text-muted-foreground">{tool.resultSummary}</p>
</div>
))}
</div>
</div>
)}
</div> </div>
))} ))}
{isSubmitting && ( {isSubmitting && (