diff --git a/backlog/tasks/task-9 - Add-Convex-synced-savings-chat-history.md b/backlog/tasks/task-9 - Add-Convex-synced-savings-chat-history.md new file mode 100644 index 0000000..7ee4258 --- /dev/null +++ b/backlog/tasks/task-9 - Add-Convex-synced-savings-chat-history.md @@ -0,0 +1,72 @@ +--- +id: TASK-9 +title: Add Convex-synced savings chat history +status: Done +assignee: [] +created_date: '2026-06-16 07:58' +updated_date: '2026-06-16 08:23' +labels: [] +dependencies: [] +priority: high +ordinal: 9000 +--- + +## Description + + +Persist savings chat sessions and messages in Convex per authenticated user so chat history syncs across devices, with automatic one-time import of existing localStorage chats. + + +## Acceptance Criteria + +- [x] #1 Chat sessions and messages are stored in Convex per authenticated user and synchronize across devices +- [x] #2 Existing localStorage savings chats are imported once, deduplicated by legacy local id, and old local data is not deleted +- [x] #3 Sending a chat message stores both user and assistant messages, including assistant tool traces +- [x] #4 Deleting a chat hides it account-wide and unauthorized users cannot read or mutate another user's chats +- [x] #5 Focused Convex tests, focused ESLint, and build verification are run and recorded + + +## Implementation Plan + + +1. Add failing Convex tests for user-scoped chat history, legacy import, sendMessage persistence, and deletion +2. Add chatSessions/chatMessages schema tables +3. Implement savingsChatHistory queries and mutations +4. Refactor savingsChat AI generation helper and add sendMessage action +5. Move SavingsChatPage to Convex-backed state and automatic legacy import +6. Run focused tests, focused lint, build, and record verification notes + + +## Implementation Notes + + +Implementation completed on branch codex/convex-chat-history-sync. + +Verification: +- RED verified first: npx vitest convex/savingsChat.test.ts --run failed with 3 missing chat-history/sendMessage tests. +- PASS npx vitest convex/savingsChat.test.ts --run (25 tests). +- PASS npx eslint convex/schema.ts convex/savingsChat.ts convex/savingsChatHistory.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx src/components/chat/ChatHistory.tsx. +- PASS npm run build (Vite chunk-size warning remains). +- FULL LINT npm run lint still fails on pre-existing unrelated files: convex/bank/comdirectProvider.ts, convex/bank/config.ts, src/components/import/TanAwaitDialog.tsx, layout/ui fast-refresh exports, src/main.tsx, src/pages/SettingsPage.tsx, plus existing React Compiler warnings. No changed feature file appears in the full-lint error list. + +Notes: +- Ran npx convex codegen; first sandboxed attempt failed on DNS/fetch to Sentry, rerun outside sandbox succeeded and updated convex/_generated/api.d.ts. +- Backlog task remains In Progress pending explicit user confirmation after manual testing. + +Review follow-up: +- Addressed code-review finding: legacy import marker is now scoped by current Convex user id instead of only browser-global. +- Addressed code-review finding: sendMessage now persists an assistant failure message if AI generation fails after saving the user message. +- Added regression coverage for unauthorized listMessages, unauthorized sendMessage, and failed-generation persistence. + +Final verification after review fixes: +- PASS npx vitest convex/savingsChat.test.ts --run (26 tests). +- PASS npx eslint convex/schema.ts convex/savingsChat.ts convex/savingsChatHistory.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx src/components/chat/ChatHistory.tsx. +- PASS npm run build (Vite chunk-size warning remains). +- FULL LINT npm run lint still fails only on pre-existing unrelated files and generated-file warnings; no changed feature file is in the full-lint error list. + + +## Final Summary + + +Shipped Convex-backed savings chat history sync with user-scoped sessions/messages, automatic per-user legacy localStorage import, server-persisted sendMessage flow, account-wide delete behavior, authorization coverage, and focused verification. Full lint still has unrelated pre-existing failures documented in notes. + diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 7c2835a..951c595 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -35,6 +35,7 @@ import type * as lib_month from "../lib/month.js"; import type * as lib_seedCategories from "../lib/seedCategories.js"; import type * as loans from "../loans.js"; import type * as savingsChat from "../savingsChat.js"; +import type * as savingsChatHistory from "../savingsChatHistory.js"; import type * as settings from "../settings.js"; import type * as transactions from "../transactions.js"; import type * as users from "../users.js"; @@ -73,6 +74,7 @@ declare const fullApi: ApiFromModules<{ "lib/seedCategories": typeof lib_seedCategories; loans: typeof loans; savingsChat: typeof savingsChat; + savingsChatHistory: typeof savingsChatHistory; settings: typeof settings; transactions: typeof transactions; users: typeof users; diff --git a/convex/savingsChat.test.ts b/convex/savingsChat.test.ts index 8d601fd..0b8ba1d 100644 --- a/convex/savingsChat.test.ts +++ b/convex/savingsChat.test.ts @@ -2,6 +2,7 @@ import { convexTest } from "convex-test"; import { generateText } from "ai"; +import { makeFunctionReference } from "convex/server"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { api, internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; @@ -57,6 +58,18 @@ vi.mock("ai", async (importOriginal) => { const modules = import.meta.glob("./**/*.ts"); delete modules["./savingsChat.test.ts"]; +const historyApi = { + listSessions: makeFunctionReference<"query">("savingsChatHistory:listSessions"), + listMessages: makeFunctionReference<"query">("savingsChatHistory:listMessages"), + createSession: makeFunctionReference<"mutation">("savingsChatHistory:createSession"), + deleteSession: makeFunctionReference<"mutation">("savingsChatHistory:deleteSession"), + importLocalSession: makeFunctionReference<"mutation">("savingsChatHistory:importLocalSession"), +}; + +const sendMessageAction = makeFunctionReference<"action">("savingsChat:sendMessage"); + +const paginationOpts = { cursor: null, numItems: 20 }; + beforeEach(() => { vi.clearAllMocks(); }); @@ -205,6 +218,336 @@ async function seedSavingsInsightFixture() { }; } +describe("savingsChatHistory", () => { + test("keeps sessions and messages scoped to the authenticated user and hides deleted sessions", async () => { + const t = convexTest(schema, modules); + const seeded = await t.run(async (ctx) => { + const userId = await ctx.db.insert("users", { name: "Laptop User", email: "laptop@example.com" }); + const otherUserId = await ctx.db.insert("users", { name: "PC User", email: "pc@example.com" }); + return { userId, otherUserId }; + }); + const asUser = t.withIdentity({ + subject: `${seeded.userId}|laptop`, + tokenIdentifier: `test:${seeded.userId}`, + }); + const asOtherUser = t.withIdentity({ + subject: `${seeded.otherUserId}|pc`, + tokenIdentifier: `test:${seeded.otherUserId}`, + }); + + const imported = await asUser.mutation(historyApi.importLocalSession, { + legacyLocalId: "local-laptop-chat", + title: "Laptop Chat", + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_100_000, + messages: [ + { role: "assistant", content: "Frag mich zu deinen Umsätzen." }, + { role: "user", content: "Wie viel habe ich gespart?" }, + { + role: "assistant", + content: "Du hast 100 EUR gespart.", + toolTrace: [ + { + name: "summarize_spending", + inputSummary: "Standard-Kontext", + resultSummary: "2 Umsätze, Saldo 100.00€, 1 Kategorien", + }, + ], + }, + ], + }); + + const userSessions = await asUser.query(historyApi.listSessions, { paginationOpts }); + expect(userSessions.page).toMatchObject([ + { _id: imported.sessionId, title: "Laptop Chat", messageCount: 3 }, + ]); + + const otherSessions = await asOtherUser.query(historyApi.listSessions, { paginationOpts }); + expect(otherSessions.page).toEqual([]); + + const messages = await asUser.query(historyApi.listMessages, { + sessionId: imported.sessionId, + paginationOpts, + }); + expect([...messages.page].reverse()).toMatchObject([ + { role: "assistant", content: "Frag mich zu deinen Umsätzen." }, + { role: "user", content: "Wie viel habe ich gespart?" }, + { + role: "assistant", + content: "Du hast 100 EUR gespart.", + toolTrace: [ + { + name: "summarize_spending", + inputSummary: "Standard-Kontext", + resultSummary: "2 Umsätze, Saldo 100.00€, 1 Kategorien", + }, + ], + }, + ]); + + await expect( + asOtherUser.query(historyApi.listMessages, { + sessionId: imported.sessionId, + paginationOpts, + }), + ).rejects.toThrow("Nicht autorisiert"); + + await expect( + asOtherUser.action(sendMessageAction, { + sessionId: imported.sessionId, + content: "Kann ich diesen Chat nutzen?", + from: "2026-02-01", + to: "2026-02-28", + basis: "effective", + }), + ).rejects.toThrow("Nicht autorisiert"); + + await expect( + asOtherUser.mutation(historyApi.deleteSession, { sessionId: imported.sessionId }), + ).rejects.toThrow("Nicht autorisiert"); + + await asUser.mutation(historyApi.deleteSession, { sessionId: imported.sessionId }); + const sessionsAfterDelete = await asUser.query(historyApi.listSessions, { paginationOpts }); + expect(sessionsAfterDelete.page).toEqual([]); + }); + + test("imports a legacy local session once per legacy id without duplicating messages", 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" }); + return { userId }; + }); + const asUser = t.withIdentity({ + subject: `${seeded.userId}|legacy`, + tokenIdentifier: `test:${seeded.userId}`, + }); + + const input = { + legacyLocalId: "legacy-session-1", + title: "Alter Laptop-Chat", + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_500_000, + messages: [ + { role: "assistant", content: "Hallo" }, + { role: "user", content: "Was waren meine Fixkosten?" }, + ], + }; + + const first = await asUser.mutation(historyApi.importLocalSession, input); + const second = await asUser.mutation(historyApi.importLocalSession, input); + + expect(second.sessionId).toBe(first.sessionId); + + const sessions = await asUser.query(historyApi.listSessions, { paginationOpts }); + expect(sessions.page).toHaveLength(1); + expect(sessions.page[0]).toMatchObject({ + _id: first.sessionId, + title: "Alter Laptop-Chat", + messageCount: 2, + }); + + const messages = await asUser.query(historyApi.listMessages, { + sessionId: first.sessionId, + paginationOpts, + }); + expect(messages.page).toHaveLength(2); + expect([...messages.page].reverse().map((message: { content: string }) => message.content)).toEqual([ + "Hallo", + "Was waren meine Fixkosten?", + ]); + }); +}); + +describe("savingsChat.sendMessage", () => { + test("stores user and assistant messages with tool traces and uses recent Convex history", async () => { + const previousKey = process.env.OPENAI_API_KEY; + const previousModel = process.env.SAVINGS_CHAT_MODEL; + const t = convexTest(schema, modules); + const seeded = await t.run(async (ctx) => { + const userId = await ctx.db.insert("users", { name: "Chat User", email: "chat@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", + }); + await ctx.db.insert("transactions", { + userId, + accountId, + bookingDate: "2026-02-10", + valueDate: "2026-02-10", + description: "Supermarkt", + amount: -120, + isPending: false, + effectiveMonth: "2026-02", + }); + return { userId, accountId }; + }); + const asUser = t.withIdentity({ + subject: `${seeded.userId}|chat`, + tokenIdentifier: `test:${seeded.userId}`, + }); + + try { + process.env.OPENAI_API_KEY = "test-key"; + delete process.env.SAVINGS_CHAT_MODEL; + + const created = await asUser.mutation(historyApi.createSession, { title: "Neuer Chat" }); + for (let index = 0; index < 24; index++) { + await asUser.mutation(historyApi.importLocalSession, { + legacyLocalId: `history-${index}`, + title: `Historie ${index}`, + createdAt: index, + updatedAt: index, + messages: [{ role: "user", content: `Separate legacy message ${index}` }], + }); + } + + await t.run(async (ctx) => { + for (let index = 0; index < 24; index++) { + await ctx.db.insert("chatMessages", { + userId: seeded.userId, + sessionId: created.sessionId, + role: index % 2 === 0 ? "user" : "assistant", + content: `Vorige Nachricht ${index}`, + createdAt: index, + }); + } + await ctx.db.patch(created.sessionId, { + messageCount: 25, + updatedAt: 24, + }); + }); + + const result = await asUser.action(sendMessageAction, { + sessionId: created.sessionId, + 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.toolTrace).toHaveLength(2); + + const generateCall = vi.mocked(generateText).mock.calls[0][0] as { + messages: Array<{ role: string; content: string }>; + }; + expect(generateCall.messages).toHaveLength(20); + expect(generateCall.messages[0].content).toBe("Vorige Nachricht 6"); + expect(generateCall.messages[generateCall.messages.length - 1]).toEqual({ + role: "user", + content: "Wie sieht Februar aus?", + }); + + const messages = await asUser.query(historyApi.listMessages, { + sessionId: created.sessionId, + paginationOpts: { cursor: null, numItems: 40 }, + }); + expect([...messages.page].reverse().slice(-2)).toMatchObject([ + { role: "user", content: "Wie sieht Februar aus?" }, + { + role: "assistant", + content: "Agenten-Antwort", + toolTrace: [ + { + 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", + }, + ], + }, + ]); + } 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; + } + } + }); + + test("stores an assistant failure message when AI generation fails after the user message is saved", async () => { + const previousKey = process.env.OPENAI_API_KEY; + const previousModel = process.env.SAVINGS_CHAT_MODEL; + const t = convexTest(schema, modules); + const seeded = await t.run(async (ctx) => { + const userId = await ctx.db.insert("users", { name: "Failure User", email: "failure@example.com" }); + return { userId }; + }); + const asUser = t.withIdentity({ + subject: `${seeded.userId}|failure`, + tokenIdentifier: `test:${seeded.userId}`, + }); + + try { + process.env.OPENAI_API_KEY = "test-key"; + delete process.env.SAVINGS_CHAT_MODEL; + vi.mocked(generateText) + .mockRejectedValueOnce(new Error("model failed 1")) + .mockRejectedValueOnce(new Error("model failed 2")) + .mockRejectedValueOnce(new Error("model failed 3")); + + const created = await asUser.mutation(historyApi.createSession, { title: "Neuer Chat" }); + await expect( + asUser.action(sendMessageAction, { + sessionId: created.sessionId, + content: "Warum geht das nicht?", + from: "2026-02-01", + to: "2026-02-28", + basis: "effective", + }), + ).rejects.toThrow("KI-Anfrage fehlgeschlagen"); + + const messages = await asUser.query(historyApi.listMessages, { + sessionId: created.sessionId, + paginationOpts: { cursor: null, numItems: 10 }, + }); + expect([...messages.page].reverse().slice(-2)).toMatchObject([ + { role: "user", content: "Warum geht das nicht?" }, + { + role: "assistant", + content: "Ich konnte gerade keine Antwort erzeugen. Bitte später erneut versuchen.", + }, + ]); + } 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; + } + } + }); +}); + describe("savingsChat.getContext", () => { test("counts and sums every matching transaction before applying prompt limits", async () => { const t = convexTest(schema, modules); diff --git a/convex/savingsChat.ts b/convex/savingsChat.ts index 7dc3938..5dc796d 100644 --- a/convex/savingsChat.ts +++ b/convex/savingsChat.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { addMonthsToMonthKey, bookingMonth, monthKeyFromBasis } from "./lib/month"; import { requireUserId } from "./lib/helpers"; import type { Doc, Id } from "./_generated/dataModel"; -import type { QueryCtx } from "./_generated/server"; +import type { ActionCtx, QueryCtx } from "./_generated/server"; type ChatRole = "user" | "assistant"; type ChatMessage = { role: ChatRole; content: string }; @@ -1900,6 +1900,202 @@ const fixedCostsForecastToolInputSchema = z.object({ asOf: z.string().optional().describe("Stichtag für den Start der Prognose im Format YYYY-MM-DD."), }); +async function generateSavingsChatResponse( + ctx: ActionCtx, + args: ChatContextArgs & { messages: ChatMessage[] }, +): Promise { + if (args.messages.length === 0) { + throw new Error("Kein Nutzernachrichttext vorhanden."); + } + + if (!process.env.OPENAI_API_KEY) { + throw new Error( + "OPENAI_API_KEY ist nicht gesetzt. Bitte API-Key in den Convex-Umgebungsvariablen hinterlegen.", + ); + } + + await requireUserId(ctx); + const scope: AgentToolScope = { + from: args.from, + to: args.to, + accountId: args.accountId, + 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 + .map((message): ChatMessage => ({ role: normalizeRole(message.role), content: message.content })) + .slice(-MAX_CONVERSATION_MESSAGES); + + const system = buildSystemPrompt({ + from: args.from, + to: args.to, + basis: args.basis, + accountName: selectedSummary.accountName, + }); + + 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, + }), + }), + get_accounts: tool({ + description: + "Listet read-only Konten mit Typ, Währung, Archivstatus, Startsaldo, Umsatzanzahl und Zeitraumssaldo. Nutze es für Fragen nach Konten, Konto-Scope oder Datenabdeckung.", + inputSchema: accountToolInputSchema, + execute: async (input) => + await ctx.runQuery(internal.savingsChat.getAccountsTool, { + scope, + ...input, + }), + }), + get_categories: tool({ + description: + "Listet read-only Kategorien mit Art, Fix/Variabel-Block, Umsatzanzahl, Summe und Ausgabenanteil im Zeitraum. Nutze es für Kategorie- und Budgetstrukturfragen.", + inputSchema: summaryToolInputSchema, + execute: async (input) => + await ctx.runQuery(internal.savingsChat.getCategoriesTool, { + scope, + ...input, + }), + }), + detect_recurring_transactions: tool({ + description: + "Erkennt deterministisch monatlich wiederkehrende Muster nach Beschreibung, Gegenpartei, Kategorie und stabiler Betragshöhe. Nutze es für Miete, Gehalt, Abos und regelmäßige Abbuchungen.", + inputSchema: recurringToolInputSchema, + execute: async (input) => + await ctx.runQuery(internal.savingsChat.detectRecurringTransactionsTool, { + scope, + ...input, + }), + }), + find_anomalies: tool({ + description: + "Findet read-only auffällige Betragsausreißer und fehlende erwartete wiederkehrende Buchungen gegenüber historischen Mustern.", + inputSchema: anomalyToolInputSchema, + execute: async (input) => + await ctx.runQuery(internal.savingsChat.findAnomaliesTool, { + scope, + ...input, + }), + }), + get_uncategorized_transactions: tool({ + description: + "Ruft bounded und sanitizt unklassifizierte Umsätze mit Summen und Top-Gegenparteien ab. Nutze es für Datenqualität und Fragen nach fehlenden Kategorien.", + inputSchema: uncategorizedToolInputSchema, + execute: async (input) => + await ctx.runQuery(internal.savingsChat.getUncategorizedTransactionsTool, { + scope, + ...input, + }), + }), + compare_periods: tool({ + description: + "Vergleicht zwei Zeiträume deterministisch mit Totals, Monatsverlauf, Kategorie-Deltas und Fix/Variabel-Deltas.", + inputSchema: comparePeriodsToolInputSchema, + execute: async (input) => + await ctx.runQuery(internal.savingsChat.comparePeriodsTool, { + scope, + ...input, + }), + }), + forecast_fixed_costs: tool({ + description: + "Prognostiziert wiederkehrende Fixkosten für 1 bis 6 Monate aus Fixkosten-Kategorien und stabilen historischen Monatsmustern.", + inputSchema: fixedCostsForecastToolInputSchema, + execute: async (input) => + await ctx.runQuery(internal.savingsChat.forecastFixedCostsTool, { + scope, + ...input, + }), + }), + explain_savings_rate: tool({ + description: + "Berechnet Sparquote, gesparten Betrag, fixe und variable Kostenquote, Haupttreiber und konkrete Hebel aus exakten Aggregaten.", + inputSchema: summaryToolInputSchema, + execute: async (input) => + await ctx.runQuery(internal.savingsChat.explainSavingsRateTool, { + scope, + ...input, + }), + }), + }; + + const envModel = process.env.SAVINGS_CHAT_MODEL?.trim(); + const candidates = [ + envModel, + "gpt-5.4-mini", + "gpt-4.1-mini", + "gpt-4.1", + ].filter(Boolean) as string[]; + + let lastError: unknown; + for (const modelName of candidates) { + try { + const result = await generateText({ + model: openai(modelName), + system, + messages: lastMessages, + tools: savingsTools, + stopWhen: stepCountIs(5), + }); + return { + model: modelName, + answer: result.text, + usedTransactions: selectedSummary.totals.transactionCount, + usedBalance: { + income: selectedSummary.totals.income, + expenses: selectedSummary.totals.expenses, + balance: selectedSummary.totals.balance, + }, + toolTrace: buildToolTraceFromSteps(result.steps), + }; + } catch (error) { + lastError = error; + } + } + + const message = + lastError instanceof Error + ? lastError.message + : "Unbekannter Fehler bei der KI-Anfrage"; + throw new Error(`KI-Anfrage fehlgeschlagen: ${message}`); +} + export const ask = action({ args: { messages: v.array(chatMessageValidator), @@ -1920,195 +2116,74 @@ export const ask = action({ toolTrace: v.array(toolTraceValidator), }), handler: async (ctx, args): Promise => { - if (args.messages.length === 0) { + return await generateSavingsChatResponse(ctx, { + ...args, + messages: args.messages.map((message) => ({ + role: normalizeRole(message.role), + content: message.content, + })), + }); + }, +}); + +export const sendMessage = action({ + args: { + sessionId: v.id("chatSessions"), + content: v.string(), + from: v.string(), + to: v.string(), + accountId: v.optional(v.id("accounts")), + basis: v.union(v.literal("effective"), v.literal("booking")), + }, + returns: v.object({ + model: v.string(), + answer: v.string(), + usedTransactions: v.number(), + usedBalance: v.object({ + income: v.number(), + expenses: v.number(), + balance: v.number(), + }), + toolTrace: v.array(toolTraceValidator), + }), + handler: async (ctx, args): Promise => { + const content = args.content.trim(); + if (!content) { throw new Error("Kein Nutzernachrichttext vorhanden."); } - if (!process.env.OPENAI_API_KEY) { - throw new Error( - "OPENAI_API_KEY ist nicht gesetzt. Bitte API-Key in den Convex-Umgebungsvariablen hinterlegen.", - ); - } - - await requireUserId(ctx); - const scope: AgentToolScope = { - from: args.from, - to: args.to, - accountId: args.accountId, - 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, + await ctx.runMutation(internal.savingsChatHistory.appendUserMessage, { + sessionId: args.sessionId, + content, }); - - const lastMessages = args.messages - .map((message): ChatMessage => ({ role: normalizeRole(message.role), content: message.content })) - .slice(-MAX_CONVERSATION_MESSAGES); - - const system = buildSystemPrompt({ - from: args.from, - to: args.to, - basis: args.basis, - accountName: selectedSummary.accountName, - }); - - 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, - }), - }), - get_accounts: tool({ - description: - "Listet read-only Konten mit Typ, Währung, Archivstatus, Startsaldo, Umsatzanzahl und Zeitraumssaldo. Nutze es für Fragen nach Konten, Konto-Scope oder Datenabdeckung.", - inputSchema: accountToolInputSchema, - execute: async (input) => - await ctx.runQuery(internal.savingsChat.getAccountsTool, { - scope, - ...input, - }), - }), - get_categories: tool({ - description: - "Listet read-only Kategorien mit Art, Fix/Variabel-Block, Umsatzanzahl, Summe und Ausgabenanteil im Zeitraum. Nutze es für Kategorie- und Budgetstrukturfragen.", - inputSchema: summaryToolInputSchema, - execute: async (input) => - await ctx.runQuery(internal.savingsChat.getCategoriesTool, { - scope, - ...input, - }), - }), - detect_recurring_transactions: tool({ - description: - "Erkennt deterministisch monatlich wiederkehrende Muster nach Beschreibung, Gegenpartei, Kategorie und stabiler Betragshöhe. Nutze es für Miete, Gehalt, Abos und regelmäßige Abbuchungen.", - inputSchema: recurringToolInputSchema, - execute: async (input) => - await ctx.runQuery(internal.savingsChat.detectRecurringTransactionsTool, { - scope, - ...input, - }), - }), - find_anomalies: tool({ - description: - "Findet read-only auffällige Betragsausreißer und fehlende erwartete wiederkehrende Buchungen gegenüber historischen Mustern.", - inputSchema: anomalyToolInputSchema, - execute: async (input) => - await ctx.runQuery(internal.savingsChat.findAnomaliesTool, { - scope, - ...input, - }), - }), - get_uncategorized_transactions: tool({ - description: - "Ruft bounded und sanitizt unklassifizierte Umsätze mit Summen und Top-Gegenparteien ab. Nutze es für Datenqualität und Fragen nach fehlenden Kategorien.", - inputSchema: uncategorizedToolInputSchema, - execute: async (input) => - await ctx.runQuery(internal.savingsChat.getUncategorizedTransactionsTool, { - scope, - ...input, - }), - }), - compare_periods: tool({ - description: - "Vergleicht zwei Zeiträume deterministisch mit Totals, Monatsverlauf, Kategorie-Deltas und Fix/Variabel-Deltas.", - inputSchema: comparePeriodsToolInputSchema, - execute: async (input) => - await ctx.runQuery(internal.savingsChat.comparePeriodsTool, { - scope, - ...input, - }), - }), - forecast_fixed_costs: tool({ - description: - "Prognostiziert wiederkehrende Fixkosten für 1 bis 6 Monate aus Fixkosten-Kategorien und stabilen historischen Monatsmustern.", - inputSchema: fixedCostsForecastToolInputSchema, - execute: async (input) => - await ctx.runQuery(internal.savingsChat.forecastFixedCostsTool, { - scope, - ...input, - }), - }), - explain_savings_rate: tool({ - description: - "Berechnet Sparquote, gesparten Betrag, fixe und variable Kostenquote, Haupttreiber und konkrete Hebel aus exakten Aggregaten.", - inputSchema: summaryToolInputSchema, - execute: async (input) => - await ctx.runQuery(internal.savingsChat.explainSavingsRateTool, { - scope, - ...input, - }), - }), - }; - - const envModel = process.env.SAVINGS_CHAT_MODEL?.trim(); - const candidates = [ - envModel, - "gpt-5.4-mini", - "gpt-4.1-mini", - "gpt-4.1", - ].filter(Boolean) as string[]; - - let lastError: unknown; - for (const modelName of candidates) { - try { - const result = await generateText({ - model: openai(modelName), - system, - messages: lastMessages, - tools: savingsTools, - stopWhen: stepCountIs(5), - }); - return { - model: modelName, - answer: result.text, - usedTransactions: selectedSummary.totals.transactionCount, - usedBalance: { - income: selectedSummary.totals.income, - expenses: selectedSummary.totals.expenses, - balance: selectedSummary.totals.balance, - }, - toolTrace: buildToolTraceFromSteps(result.steps), - }; - } catch (error) { - lastError = error; - } + const messages: ChatMessage[] = await ctx.runQuery( + internal.savingsChatHistory.getRecentMessagesForPrompt, + { + sessionId: args.sessionId, + limit: MAX_CONVERSATION_MESSAGES, + }, + ); + let response: ChatAskResult; + try { + response = await generateSavingsChatResponse(ctx, { + from: args.from, + to: args.to, + accountId: args.accountId, + basis: args.basis, + messages, + }); + } catch (error) { + await ctx.runMutation(internal.savingsChatHistory.appendAssistantMessage, { + sessionId: args.sessionId, + content: "Ich konnte gerade keine Antwort erzeugen. Bitte später erneut versuchen.", + }); + throw error; } - - const message = - lastError instanceof Error - ? lastError.message - : "Unbekannter Fehler bei der KI-Anfrage"; - throw new Error(`KI-Anfrage fehlgeschlagen: ${message}`); + await ctx.runMutation(internal.savingsChatHistory.appendAssistantMessage, { + sessionId: args.sessionId, + content: response.answer, + toolTrace: response.toolTrace, + }); + return response; }, }); diff --git a/convex/savingsChatHistory.ts b/convex/savingsChatHistory.ts new file mode 100644 index 0000000..0d4d4f5 --- /dev/null +++ b/convex/savingsChatHistory.ts @@ -0,0 +1,266 @@ +import { paginationOptsValidator, paginationResultValidator } from "convex/server"; +import { v } from "convex/values"; +import { internalMutation, internalQuery, mutation, query } from "./_generated/server"; +import type { Id } from "./_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { assertOwned, requireUserId } from "./lib/helpers"; + +const initialAssistantMessage = + "Frag mich zu deinen Umsätzen – ich werte sie im aktuellen Zeitraum aus."; + +const toolTraceValidator = v.object({ + name: v.string(), + inputSummary: v.string(), + resultSummary: v.string(), +}); + +const chatRoleValidator = v.union(v.literal("user"), v.literal("assistant")); + +const importMessageValidator = v.object({ + role: chatRoleValidator, + content: v.string(), + toolTrace: v.optional(v.array(toolTraceValidator)), +}); + +const sessionValidator = v.object({ + _id: v.id("chatSessions"), + _creationTime: v.number(), + userId: v.id("users"), + title: v.string(), + createdAt: v.number(), + updatedAt: v.number(), + messageCount: v.number(), + legacyLocalId: v.optional(v.string()), + isDeleted: v.boolean(), +}); + +const messageValidator = v.object({ + _id: v.id("chatMessages"), + _creationTime: v.number(), + userId: v.id("users"), + sessionId: v.id("chatSessions"), + role: chatRoleValidator, + content: v.string(), + createdAt: v.number(), + toolTrace: v.optional(v.array(toolTraceValidator)), +}); + +const promptMessageValidator = v.object({ + role: chatRoleValidator, + content: v.string(), +}); + +function normalizeTitle(title: string | undefined) { + const trimmed = title?.trim(); + return trimmed ? trimmed : "Neuer Chat"; +} + +function titleFromContent(content: string) { + const trimmed = content.trim(); + if (!trimmed) return "Neuer Chat"; + return trimmed.length > 44 ? `${trimmed.slice(0, 44)}…` : trimmed; +} + +async function requireOwnedSession( + ctx: QueryCtx | MutationCtx, + sessionId: Id<"chatSessions">, + userId: Id<"users">, +) { + const session = await assertOwned(await ctx.db.get(sessionId), userId, "Chat"); + if (session.isDeleted) throw new Error("Chat nicht gefunden"); + return session; +} + +export const listSessions = query({ + args: { paginationOpts: paginationOptsValidator }, + returns: paginationResultValidator(sessionValidator), + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + return await ctx.db + .query("chatSessions") + .withIndex("by_user_deleted_updated", (index) => + index.eq("userId", userId).eq("isDeleted", false), + ) + .order("desc") + .paginate(args.paginationOpts); + }, +}); + +export const listMessages = query({ + args: { + sessionId: v.id("chatSessions"), + paginationOpts: paginationOptsValidator, + }, + returns: paginationResultValidator(messageValidator), + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + await requireOwnedSession(ctx, args.sessionId, userId); + return await ctx.db + .query("chatMessages") + .withIndex("by_user_session_created", (index) => + index.eq("userId", userId).eq("sessionId", args.sessionId), + ) + .order("desc") + .paginate(args.paginationOpts); + }, +}); + +export const createSession = mutation({ + args: { title: v.optional(v.string()) }, + returns: v.object({ sessionId: v.id("chatSessions") }), + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const now = Date.now(); + const sessionId = await ctx.db.insert("chatSessions", { + userId, + title: normalizeTitle(args.title), + createdAt: now, + updatedAt: now, + messageCount: 1, + isDeleted: false, + }); + await ctx.db.insert("chatMessages", { + userId, + sessionId, + role: "assistant", + content: initialAssistantMessage, + createdAt: now, + }); + return { sessionId }; + }, +}); + +export const deleteSession = mutation({ + args: { sessionId: v.id("chatSessions") }, + returns: v.null(), + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + await requireOwnedSession(ctx, args.sessionId, userId); + await ctx.db.patch(args.sessionId, { + isDeleted: true, + updatedAt: Date.now(), + }); + return null; + }, +}); + +export const importLocalSession = mutation({ + args: { + legacyLocalId: v.string(), + title: v.string(), + createdAt: v.number(), + updatedAt: v.number(), + messages: v.array(importMessageValidator), + }, + returns: v.object({ sessionId: v.id("chatSessions") }), + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const existing = await ctx.db + .query("chatSessions") + .withIndex("by_user_legacyLocalId", (index) => + index.eq("userId", userId).eq("legacyLocalId", args.legacyLocalId), + ) + .unique(); + if (existing) return { sessionId: existing._id }; + + const sessionId = await ctx.db.insert("chatSessions", { + userId, + title: normalizeTitle(args.title), + createdAt: args.createdAt, + updatedAt: args.updatedAt, + messageCount: args.messages.length, + legacyLocalId: args.legacyLocalId, + isDeleted: false, + }); + + for (const [index, message] of args.messages.entries()) { + await ctx.db.insert("chatMessages", { + userId, + sessionId, + role: message.role, + content: message.content, + createdAt: args.createdAt + index, + ...(message.toolTrace ? { toolTrace: message.toolTrace } : {}), + }); + } + + return { sessionId }; + }, +}); + +export const appendUserMessage = internalMutation({ + args: { + sessionId: v.id("chatSessions"), + content: v.string(), + }, + returns: v.object({ messageId: v.id("chatMessages") }), + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const session = await requireOwnedSession(ctx, args.sessionId, userId); + const now = Date.now(); + const messageId = await ctx.db.insert("chatMessages", { + userId, + sessionId: args.sessionId, + role: "user", + content: args.content, + createdAt: now, + }); + await ctx.db.patch(args.sessionId, { + title: session.title === "Neuer Chat" ? titleFromContent(args.content) : session.title, + updatedAt: now, + messageCount: session.messageCount + 1, + }); + return { messageId }; + }, +}); + +export const appendAssistantMessage = internalMutation({ + args: { + sessionId: v.id("chatSessions"), + content: v.string(), + toolTrace: v.optional(v.array(toolTraceValidator)), + }, + returns: v.object({ messageId: v.id("chatMessages") }), + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const session = await requireOwnedSession(ctx, args.sessionId, userId); + const now = Date.now(); + const messageId = await ctx.db.insert("chatMessages", { + userId, + sessionId: args.sessionId, + role: "assistant", + content: args.content, + createdAt: now, + ...(args.toolTrace ? { toolTrace: args.toolTrace } : {}), + }); + await ctx.db.patch(args.sessionId, { + updatedAt: now, + messageCount: session.messageCount + 1, + }); + return { messageId }; + }, +}); + +export const getRecentMessagesForPrompt = internalQuery({ + args: { + sessionId: v.id("chatSessions"), + limit: v.number(), + }, + returns: v.array(promptMessageValidator), + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + await requireOwnedSession(ctx, args.sessionId, userId); + const limit = Math.max(1, Math.min(50, Math.floor(args.limit))); + const messages = await ctx.db + .query("chatMessages") + .withIndex("by_user_session_created", (index) => + index.eq("userId", userId).eq("sessionId", args.sessionId), + ) + .order("desc") + .take(limit); + return messages.reverse().map((message) => ({ + role: message.role, + content: message.content, + })); + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index e1177a0..ee30cf6 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -9,6 +9,12 @@ const loanStatus = v.union( v.literal("abbezahlt"), v.literal("pausiert"), ); +const chatRole = v.union(v.literal("user"), v.literal("assistant")); +const chatToolTrace = v.object({ + name: v.string(), + inputSummary: v.string(), + resultSummary: v.string(), +}); export default defineSchema({ ...authTables, @@ -175,4 +181,25 @@ export default defineSchema({ isDecoupled: v.optional(v.boolean()), submittedTan: v.optional(v.string()), }).index("by_user", ["userId"]), + + chatSessions: defineTable({ + userId: v.id("users"), + title: v.string(), + createdAt: v.number(), + updatedAt: v.number(), + messageCount: v.number(), + legacyLocalId: v.optional(v.string()), + isDeleted: v.boolean(), + }) + .index("by_user_deleted_updated", ["userId", "isDeleted", "updatedAt"]) + .index("by_user_legacyLocalId", ["userId", "legacyLocalId"]), + + chatMessages: defineTable({ + userId: v.id("users"), + sessionId: v.id("chatSessions"), + role: chatRole, + content: v.string(), + createdAt: v.number(), + toolTrace: v.optional(v.array(chatToolTrace)), + }).index("by_user_session_created", ["userId", "sessionId", "createdAt"]), }); diff --git a/src/pages/SavingsChatPage.tsx b/src/pages/SavingsChatPage.tsx index 4d15ff1..861d081 100644 --- a/src/pages/SavingsChatPage.tsx +++ b/src/pages/SavingsChatPage.tsx @@ -1,7 +1,8 @@ -import { type FormEvent, useEffect, useRef, useState } from "react"; -import { useAction, useQuery } from "convex/react"; +import { type FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import { useAction, useMutation, usePaginatedQuery, useQuery } from "convex/react"; import { MessageCircle, Send, Loader2 } from "lucide-react"; import { api } from "../../convex/_generated/api"; +import type { Id } from "../../convex/_generated/dataModel"; import { useAccountFilterId } from "@/components/layout/AccountFilter"; import { useFilters } from "@/context/FilterContext"; import { Button } from "@/components/ui/button"; @@ -23,20 +24,22 @@ type AssistantChatMessage = { toolTrace?: ToolTrace[]; }; type ChatMessage = UserChatMessage | AssistantChatMessage; -type ChatSession = { +type LegacyChatSession = { id: string; title: string; createdAt: number; updatedAt: number; messages: ChatMessage[]; }; +type DisplayChatMessage = ChatMessage & { _id: string }; const STORAGE_KEY = "savings-chat-sessions"; +const IMPORTED_KEY = "savings-chat-sessions-imported-v1"; const initialAssistantMessage: ChatMessage = { role: "assistant", content: "Frag mich zu deinen Umsätzen – ich werte sie im aktuellen Zeitraum aus.", }; -const fallbackMessages = [initialAssistantMessage]; +const fallbackMessages: DisplayChatMessage[] = [{ ...initialAssistantMessage, _id: "fallback-0" }]; function normalizeToolTrace(value: unknown): ToolTrace[] | undefined { if (!Array.isArray(value)) return undefined; @@ -84,7 +87,7 @@ function isChatMessage(value: ChatMessage | null): value is ChatMessage { return value !== null; } -function normalizeSession(value: unknown): ChatSession | null { +function normalizeSession(value: unknown): LegacyChatSession | null { if (!value || typeof value !== "object") return null; const candidate = value as Record; if ( @@ -109,53 +112,57 @@ function normalizeSession(value: unknown): ChatSession | null { }; } -function createSession(): ChatSession { - const now = Date.now(); - const randomId = - typeof crypto !== "undefined" && "randomUUID" in crypto - ? crypto.randomUUID() - : `${now}-${Math.random().toString(36).slice(2)}`; - - return { - id: randomId, - title: "Neuer Chat", - createdAt: now, - updatedAt: now, - messages: [initialAssistantMessage], - }; -} - -function loadSessions(): ChatSession[] { +function loadLegacySessions(): LegacyChatSession[] { try { const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return [createSession()]; + if (!raw) return []; const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed) || parsed.length === 0) return [createSession()]; - const sessions = parsed.map(normalizeSession); - if (sessions.some((session) => session === null)) return [createSession()]; - return sessions as ChatSession[]; + if (!Array.isArray(parsed)) return []; + return parsed + .map(normalizeSession) + .filter((session): session is LegacyChatSession => session !== null) + .filter((session) => session.messages.some((message) => message.role === "user")); } catch { - return [createSession()]; + return []; } } -function titleFromMessages(messages: ChatMessage[]) { - const firstUserMessage = messages.find((message) => message.role === "user")?.content.trim(); - if (!firstUserMessage) return "Neuer Chat"; - return firstUserMessage.length > 44 ? `${firstUserMessage.slice(0, 44)}…` : firstUserMessage; -} - export function SavingsChatPage() { const { from, to, monthBasis } = useFilters(); const accountId = useAccountFilterId(); const [draft, setDraft] = useState(""); - const [sessions, setSessions] = useState(loadSessions); - const [activeSessionId, setActiveSessionId] = useState(() => sessions[0]?.id ?? ""); + const [selectedSessionId, setSelectedSessionId] = useState | undefined>(); const [isSubmitting, setIsSubmitting] = useState(false); + const [legacyImportResult, setLegacyImportResult] = useState<{ + key: string; + importedCount: number; + } | null>(null); + const legacyImportStartedRef = useRef(undefined); + const createInitialStartedRef = useRef(false); const listRef = useRef(null); - const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0]; - const messages = activeSession?.messages ?? fallbackMessages; + + const sessionsQuery = usePaginatedQuery( + api.savingsChatHistory.listSessions, + {}, + { initialNumItems: 50 }, + ); + const sessions = sessionsQuery.results; + const activeSessionId = + selectedSessionId && sessions.some((session) => session._id === selectedSessionId) + ? selectedSessionId + : sessions[0]?._id; + const activeSession = sessions.find((session) => session._id === activeSessionId); + const messagesQuery = usePaginatedQuery( + api.savingsChatHistory.listMessages, + activeSessionId ? { sessionId: activeSessionId } : "skip", + { initialNumItems: 100 }, + ); + const messages = useMemo( + () => [...messagesQuery.results].reverse() as DisplayChatMessage[], + [messagesQuery.results], + ); + const displayMessages = activeSessionId && messages.length > 0 ? messages : fallbackMessages; const context = useQuery(api.savingsChat.getContext, { from, @@ -163,9 +170,23 @@ export function SavingsChatPage() { accountId, basis: monthBasis, }); - const ask = useAction(api.savingsChat.ask); + const currentUser = useQuery(api.users.currentUser); + const createSession = useMutation(api.savingsChatHistory.createSession); + const deleteSession = useMutation(api.savingsChatHistory.deleteSession); + const importLocalSession = useMutation(api.savingsChatHistory.importLocalSession); + const sendMessage = useAction(api.savingsChat.sendMessage); + const importMarkerKey = currentUser ? `${IMPORTED_KEY}:${currentUser._id}` : undefined; + const legacyImportComplete = Boolean( + importMarkerKey && + (legacyImportResult?.key === importMarkerKey || + localStorage.getItem(importMarkerKey) === "true"), + ); + const legacyImportedCount = + legacyImportResult && legacyImportResult.key === importMarkerKey + ? legacyImportResult.importedCount + : 0; - const buttonDisabled = isSubmitting || draft.trim().length === 0; + const buttonDisabled = isSubmitting || draft.trim().length === 0 || !activeSessionId; const formatAmount = (amount: number) => new Intl.NumberFormat("de-DE", { @@ -182,92 +203,120 @@ export function SavingsChatPage() { useEffect(() => { listRef.current?.lastElementChild?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); + }, [displayMessages.length, activeSessionId]); useEffect(() => { - localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions)); - }, [sessions]); + if (!importMarkerKey || legacyImportComplete || legacyImportStartedRef.current === importMarkerKey) return; + legacyImportStartedRef.current = importMarkerKey; + const legacySessions = loadLegacySessions(); + if (legacySessions.length === 0) { + localStorage.setItem(importMarkerKey, "true"); + void Promise.resolve().then(() => + setLegacyImportResult({ key: importMarkerKey, importedCount: 0 }), + ); + return; + } - const updateSession = (id: string, nextMessages: ChatMessage[]) => { - const now = Date.now(); - setSessions((prev) => - prev.map((session) => - session.id === id - ? { - ...session, - title: titleFromMessages(nextMessages), - updatedAt: now, - messages: nextMessages, - } - : session, + let importedCount = 0; + void Promise.all( + legacySessions.map((session) => + importLocalSession({ + legacyLocalId: session.id, + title: session.title, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + messages: session.messages, + }), ), - ); - }; + ) + .then(() => { + importedCount = legacySessions.length; + localStorage.setItem(importMarkerKey, "true"); + }) + .catch((error) => { + console.error(error); + toast.error("Lokale Chat-Historie konnte nicht importiert werden."); + }) + .finally(() => { + setLegacyImportResult({ key: importMarkerKey, importedCount }); + }); + }, [importLocalSession, importMarkerKey, legacyImportComplete]); + + useEffect(() => { + if ( + !legacyImportComplete || + legacyImportedCount > 0 || + sessionsQuery.status === "LoadingFirstPage" || + sessions.length > 0 || + createInitialStartedRef.current + ) { + return; + } + + createInitialStartedRef.current = true; + void createSession({ title: "Neuer Chat" }) + .then((session) => setSelectedSessionId(session.sessionId)) + .catch((error) => { + console.error(error); + toast.error("Neuer Chat konnte nicht erstellt werden."); + }); + }, [createSession, legacyImportComplete, legacyImportedCount, sessions.length, sessionsQuery.status]); const createNewChat = () => { - const session = createSession(); - setSessions((prev) => [session, ...prev]); - setActiveSessionId(session.id); setDraft(""); + void createSession({ title: "Neuer Chat" }) + .then((session) => setSelectedSessionId(session.sessionId)) + .catch((error) => { + console.error(error); + toast.error("Neuer Chat konnte nicht erstellt werden."); + }); }; const deleteChat = (id: string) => { - const remaining = sessions.filter((session) => session.id !== id); - const nextSessions = remaining.length > 0 ? remaining : [createSession()]; - setSessions(nextSessions); - if (id === activeSessionId) setActiveSessionId(nextSessions[0].id); + void deleteSession({ sessionId: id as Id<"chatSessions"> }) + .then(() => { + if (id === activeSessionId) { + const nextSession = sessions.find((session) => session._id !== id); + setSelectedSessionId(nextSession?._id); + } + }) + .catch((error) => { + console.error(error); + toast.error("Chat konnte nicht gelöscht werden."); + }); }; - const historyItems: ChatHistoryItem[] = sessions - .map((session) => ({ - id: session.id, - title: session.title, - updatedAt: session.updatedAt, - messageCount: session.messages.length, - })) - .sort((a, b) => b.updatedAt - a.updatedAt); + const historyItems: ChatHistoryItem[] = useMemo( + () => + sessions.map((session) => ({ + id: session._id, + title: session.title, + updatedAt: session.updatedAt, + messageCount: session.messageCount, + })), + [sessions], + ); const submit = async (event: FormEvent) => { event.preventDefault(); const content = draft.trim(); - if (!content || isSubmitting) return; + if (!content || isSubmitting || !activeSessionId) return; - const submittedSessionId = activeSessionId; - const typedNextMessages: ChatMessage[] = [...messages, { role: "user", content }]; - updateSession(submittedSessionId, typedNextMessages); setDraft(""); setIsSubmitting(true); try { - const response = await ask({ - messages: typedNextMessages.map((message) => ({ - role: message.role, - content: message.content, - })), + await sendMessage({ + sessionId: activeSessionId, + content, from, to, accountId, basis: monthBasis, - }) as { answer: string; toolTrace?: unknown }; - - updateSession(submittedSessionId, [ - ...typedNextMessages, - { - role: "assistant", - content: response.answer, - toolTrace: normalizeToolTrace(response.toolTrace), - }, - ]); + }); } catch (error) { console.error(error); toast.error("Antwort konnte nicht geladen werden."); - updateSession(submittedSessionId, [ - ...typedNextMessages, - { - role: "assistant", - content: "Ich konnte gerade keine Antwort erzeugen. Bitte später erneut versuchen.", - }, - ]); } finally { setIsSubmitting(false); } @@ -277,8 +326,8 @@ export function SavingsChatPage() {
setSelectedSessionId(id as Id<"chatSessions">)} onCreate={createNewChat} onDelete={deleteChat} /> @@ -289,82 +338,80 @@ export function SavingsChatPage() {

Talk to your savings account

- - - Kontext der Auswertung - - -

Zeitraum: {from} bis {to}

-

Basis: {monthBasis === "effective" ? "Effektiv" : "Buchungstag"}

-

- Konto: {context?.accountName ?? "Alle Konten"} (Basis-Saldo{" "} - {context ? formatAmount(context.totals.balance) : "—"}) -

-

- {context ? getContextSummary() : "Lade Kontext…"} -

-
-
+ + + Kontext der Auswertung + + +

Zeitraum: {from} bis {to}

+

Basis: {monthBasis === "effective" ? "Effektiv" : "Buchungstag"}

+

+ Konto: {context?.accountName ?? "Alle Konten"} (Basis-Saldo{" "} + {context ? formatAmount(context.totals.balance) : "—"}) +

+

{context ? getContextSummary() : "Lade Kontext…"}

+
+
- - -
-
- {messages.map((message, index) => ( -
-

{message.role}

-

{message.content}

- {message.role === "assistant" && message.toolTrace && message.toolTrace.length > 0 && ( -
-

- Verwendete Werkzeuge -

-
- {message.toolTrace.map((tool, toolIndex) => ( -
-

{tool.name}

-

{tool.resultSummary}

-
- ))} + + +
+
+ {displayMessages.map((message) => ( +
+

{message.role}

+

{message.content}

+ {message.role === "assistant" && message.toolTrace && message.toolTrace.length > 0 && ( +
+

+ Verwendete Werkzeuge +

+
+ {message.toolTrace.map((tool, toolIndex) => ( +
+

{tool.name}

+

{tool.resultSummary}

+
+ ))} +
-
- )} -
- ))} - {isSubmitting && ( -
- - Denk mit der KI nach… -
- )} + )} +
+ ))} + {isSubmitting && ( +
+ + Denk mit der KI nach… +
+ )} +
-
- - + + -
- setDraft(event.target.value)} - placeholder="Welche Auswertung soll ich machen?" - disabled={isSubmitting} - autoFocus - /> - -
+
+ setDraft(event.target.value)} + placeholder={activeSession ? "Welche Auswertung soll ich machen?" : "Chat wird vorbereitet…"} + disabled={isSubmitting || !activeSessionId} + autoFocus + /> + +
- -

- Antwortmodell: {context ? "Server-seitig bereit" : "…"} -

+ +

+ Antwortmodell: {context ? "Server-seitig bereit" : "…"} +

);