feat: sync savings chat history with convex
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
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.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [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
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
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
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
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.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
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.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -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 lib_seedCategories from "../lib/seedCategories.js";
|
||||||
import type * as loans from "../loans.js";
|
import type * as loans from "../loans.js";
|
||||||
import type * as savingsChat from "../savingsChat.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 settings from "../settings.js";
|
||||||
import type * as transactions from "../transactions.js";
|
import type * as transactions from "../transactions.js";
|
||||||
import type * as users from "../users.js";
|
import type * as users from "../users.js";
|
||||||
@@ -73,6 +74,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
"lib/seedCategories": typeof lib_seedCategories;
|
"lib/seedCategories": typeof lib_seedCategories;
|
||||||
loans: typeof loans;
|
loans: typeof loans;
|
||||||
savingsChat: typeof savingsChat;
|
savingsChat: typeof savingsChat;
|
||||||
|
savingsChatHistory: typeof savingsChatHistory;
|
||||||
settings: typeof settings;
|
settings: typeof settings;
|
||||||
transactions: typeof transactions;
|
transactions: typeof transactions;
|
||||||
users: typeof users;
|
users: typeof users;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { convexTest } from "convex-test";
|
import { convexTest } from "convex-test";
|
||||||
import { generateText } from "ai";
|
import { generateText } from "ai";
|
||||||
|
import { makeFunctionReference } from "convex/server";
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
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";
|
||||||
@@ -57,6 +58,18 @@ vi.mock("ai", async (importOriginal) => {
|
|||||||
const modules = import.meta.glob("./**/*.ts");
|
const modules = import.meta.glob("./**/*.ts");
|
||||||
delete modules["./savingsChat.test.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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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", () => {
|
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);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { z } from "zod";
|
|||||||
import { addMonthsToMonthKey, bookingMonth, monthKeyFromBasis } from "./lib/month";
|
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 { ActionCtx, QueryCtx } from "./_generated/server";
|
||||||
|
|
||||||
type ChatRole = "user" | "assistant";
|
type ChatRole = "user" | "assistant";
|
||||||
type ChatMessage = { role: ChatRole; content: string };
|
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."),
|
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<ChatAskResult> {
|
||||||
|
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({
|
export const ask = action({
|
||||||
args: {
|
args: {
|
||||||
messages: v.array(chatMessageValidator),
|
messages: v.array(chatMessageValidator),
|
||||||
@@ -1920,195 +2116,74 @@ export const ask = action({
|
|||||||
toolTrace: v.array(toolTraceValidator),
|
toolTrace: v.array(toolTraceValidator),
|
||||||
}),
|
}),
|
||||||
handler: async (ctx, args): Promise<ChatAskResult> => {
|
handler: async (ctx, args): Promise<ChatAskResult> => {
|
||||||
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<ChatAskResult> => {
|
||||||
|
const content = args.content.trim();
|
||||||
|
if (!content) {
|
||||||
throw new Error("Kein Nutzernachrichttext vorhanden.");
|
throw new Error("Kein Nutzernachrichttext vorhanden.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!process.env.OPENAI_API_KEY) {
|
await ctx.runMutation(internal.savingsChatHistory.appendUserMessage, {
|
||||||
throw new Error(
|
sessionId: args.sessionId,
|
||||||
"OPENAI_API_KEY ist nicht gesetzt. Bitte API-Key in den Convex-Umgebungsvariablen hinterlegen.",
|
content,
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 messages: ChatMessage[] = await ctx.runQuery(
|
||||||
const lastMessages = args.messages
|
internal.savingsChatHistory.getRecentMessagesForPrompt,
|
||||||
.map((message): ChatMessage => ({ role: normalizeRole(message.role), content: message.content }))
|
{
|
||||||
.slice(-MAX_CONVERSATION_MESSAGES);
|
sessionId: args.sessionId,
|
||||||
|
limit: MAX_CONVERSATION_MESSAGES,
|
||||||
const system = buildSystemPrompt({
|
},
|
||||||
from: args.from,
|
);
|
||||||
to: args.to,
|
let response: ChatAskResult;
|
||||||
basis: args.basis,
|
try {
|
||||||
accountName: selectedSummary.accountName,
|
response = await generateSavingsChatResponse(ctx, {
|
||||||
});
|
from: args.from,
|
||||||
|
to: args.to,
|
||||||
const savingsTools = {
|
accountId: args.accountId,
|
||||||
get_transactions: tool({
|
basis: args.basis,
|
||||||
description:
|
messages,
|
||||||
"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,
|
} catch (error) {
|
||||||
execute: async (input) =>
|
await ctx.runMutation(internal.savingsChatHistory.appendAssistantMessage, {
|
||||||
await ctx.runQuery(internal.savingsChat.getTransactionsTool, {
|
sessionId: args.sessionId,
|
||||||
scope,
|
content: "Ich konnte gerade keine Antwort erzeugen. Bitte später erneut versuchen.",
|
||||||
...input,
|
});
|
||||||
}),
|
throw error;
|
||||||
}),
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
await ctx.runMutation(internal.savingsChatHistory.appendAssistantMessage, {
|
||||||
const message =
|
sessionId: args.sessionId,
|
||||||
lastError instanceof Error
|
content: response.answer,
|
||||||
? lastError.message
|
toolTrace: response.toolTrace,
|
||||||
: "Unbekannter Fehler bei der KI-Anfrage";
|
});
|
||||||
throw new Error(`KI-Anfrage fehlgeschlagen: ${message}`);
|
return response;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
266
convex/savingsChatHistory.ts
Normal file
266
convex/savingsChatHistory.ts
Normal file
@@ -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,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -9,6 +9,12 @@ const loanStatus = v.union(
|
|||||||
v.literal("abbezahlt"),
|
v.literal("abbezahlt"),
|
||||||
v.literal("pausiert"),
|
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({
|
export default defineSchema({
|
||||||
...authTables,
|
...authTables,
|
||||||
@@ -175,4 +181,25 @@ export default defineSchema({
|
|||||||
isDecoupled: v.optional(v.boolean()),
|
isDecoupled: v.optional(v.boolean()),
|
||||||
submittedTan: v.optional(v.string()),
|
submittedTan: v.optional(v.string()),
|
||||||
}).index("by_user", ["userId"]),
|
}).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"]),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useAction, useQuery } from "convex/react";
|
import { useAction, useMutation, usePaginatedQuery, useQuery } from "convex/react";
|
||||||
import { MessageCircle, Send, Loader2 } from "lucide-react";
|
import { MessageCircle, Send, Loader2 } from "lucide-react";
|
||||||
import { api } from "../../convex/_generated/api";
|
import { api } from "../../convex/_generated/api";
|
||||||
|
import type { Id } from "../../convex/_generated/dataModel";
|
||||||
import { useAccountFilterId } from "@/components/layout/AccountFilter";
|
import { useAccountFilterId } from "@/components/layout/AccountFilter";
|
||||||
import { useFilters } from "@/context/FilterContext";
|
import { useFilters } from "@/context/FilterContext";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -23,20 +24,22 @@ type AssistantChatMessage = {
|
|||||||
toolTrace?: ToolTrace[];
|
toolTrace?: ToolTrace[];
|
||||||
};
|
};
|
||||||
type ChatMessage = UserChatMessage | AssistantChatMessage;
|
type ChatMessage = UserChatMessage | AssistantChatMessage;
|
||||||
type ChatSession = {
|
type LegacyChatSession = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
};
|
};
|
||||||
|
type DisplayChatMessage = ChatMessage & { _id: string };
|
||||||
|
|
||||||
const STORAGE_KEY = "savings-chat-sessions";
|
const STORAGE_KEY = "savings-chat-sessions";
|
||||||
|
const IMPORTED_KEY = "savings-chat-sessions-imported-v1";
|
||||||
const initialAssistantMessage: ChatMessage = {
|
const initialAssistantMessage: ChatMessage = {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "Frag mich zu deinen Umsätzen – ich werte sie im aktuellen Zeitraum aus.",
|
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 {
|
function normalizeToolTrace(value: unknown): ToolTrace[] | undefined {
|
||||||
if (!Array.isArray(value)) return undefined;
|
if (!Array.isArray(value)) return undefined;
|
||||||
@@ -84,7 +87,7 @@ function isChatMessage(value: ChatMessage | null): value is ChatMessage {
|
|||||||
return value !== null;
|
return value !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSession(value: unknown): ChatSession | null {
|
function normalizeSession(value: unknown): LegacyChatSession | null {
|
||||||
if (!value || typeof value !== "object") return null;
|
if (!value || typeof value !== "object") return null;
|
||||||
const candidate = value as Record<string, unknown>;
|
const candidate = value as Record<string, unknown>;
|
||||||
if (
|
if (
|
||||||
@@ -109,53 +112,57 @@ function normalizeSession(value: unknown): ChatSession | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSession(): ChatSession {
|
function loadLegacySessions(): LegacyChatSession[] {
|
||||||
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[] {
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return [createSession()];
|
if (!raw) return [];
|
||||||
const parsed: unknown = JSON.parse(raw);
|
const parsed: unknown = JSON.parse(raw);
|
||||||
if (!Array.isArray(parsed) || parsed.length === 0) return [createSession()];
|
if (!Array.isArray(parsed)) return [];
|
||||||
const sessions = parsed.map(normalizeSession);
|
return parsed
|
||||||
if (sessions.some((session) => session === null)) return [createSession()];
|
.map(normalizeSession)
|
||||||
return sessions as ChatSession[];
|
.filter((session): session is LegacyChatSession => session !== null)
|
||||||
|
.filter((session) => session.messages.some((message) => message.role === "user"));
|
||||||
} catch {
|
} 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() {
|
export function SavingsChatPage() {
|
||||||
const { from, to, monthBasis } = useFilters();
|
const { from, to, monthBasis } = useFilters();
|
||||||
const accountId = useAccountFilterId();
|
const accountId = useAccountFilterId();
|
||||||
|
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [sessions, setSessions] = useState<ChatSession[]>(loadSessions);
|
const [selectedSessionId, setSelectedSessionId] = useState<Id<"chatSessions"> | undefined>();
|
||||||
const [activeSessionId, setActiveSessionId] = useState(() => sessions[0]?.id ?? "");
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [legacyImportResult, setLegacyImportResult] = useState<{
|
||||||
|
key: string;
|
||||||
|
importedCount: number;
|
||||||
|
} | null>(null);
|
||||||
|
const legacyImportStartedRef = useRef<string | undefined>(undefined);
|
||||||
|
const createInitialStartedRef = useRef(false);
|
||||||
const listRef = useRef<HTMLDivElement | null>(null);
|
const listRef = useRef<HTMLDivElement | null>(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, {
|
const context = useQuery(api.savingsChat.getContext, {
|
||||||
from,
|
from,
|
||||||
@@ -163,9 +170,23 @@ export function SavingsChatPage() {
|
|||||||
accountId,
|
accountId,
|
||||||
basis: monthBasis,
|
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) =>
|
const formatAmount = (amount: number) =>
|
||||||
new Intl.NumberFormat("de-DE", {
|
new Intl.NumberFormat("de-DE", {
|
||||||
@@ -182,92 +203,120 @@ export function SavingsChatPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listRef.current?.lastElementChild?.scrollIntoView({ behavior: "smooth" });
|
listRef.current?.lastElementChild?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [messages]);
|
}, [displayMessages.length, activeSessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
|
if (!importMarkerKey || legacyImportComplete || legacyImportStartedRef.current === importMarkerKey) return;
|
||||||
}, [sessions]);
|
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[]) => {
|
let importedCount = 0;
|
||||||
const now = Date.now();
|
void Promise.all(
|
||||||
setSessions((prev) =>
|
legacySessions.map((session) =>
|
||||||
prev.map((session) =>
|
importLocalSession({
|
||||||
session.id === id
|
legacyLocalId: session.id,
|
||||||
? {
|
title: session.title,
|
||||||
...session,
|
createdAt: session.createdAt,
|
||||||
title: titleFromMessages(nextMessages),
|
updatedAt: session.updatedAt,
|
||||||
updatedAt: now,
|
messages: session.messages,
|
||||||
messages: nextMessages,
|
}),
|
||||||
}
|
|
||||||
: session,
|
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
};
|
.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 createNewChat = () => {
|
||||||
const session = createSession();
|
|
||||||
setSessions((prev) => [session, ...prev]);
|
|
||||||
setActiveSessionId(session.id);
|
|
||||||
setDraft("");
|
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 deleteChat = (id: string) => {
|
||||||
const remaining = sessions.filter((session) => session.id !== id);
|
void deleteSession({ sessionId: id as Id<"chatSessions"> })
|
||||||
const nextSessions = remaining.length > 0 ? remaining : [createSession()];
|
.then(() => {
|
||||||
setSessions(nextSessions);
|
if (id === activeSessionId) {
|
||||||
if (id === activeSessionId) setActiveSessionId(nextSessions[0].id);
|
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
|
const historyItems: ChatHistoryItem[] = useMemo(
|
||||||
.map((session) => ({
|
() =>
|
||||||
id: session.id,
|
sessions.map((session) => ({
|
||||||
title: session.title,
|
id: session._id,
|
||||||
updatedAt: session.updatedAt,
|
title: session.title,
|
||||||
messageCount: session.messages.length,
|
updatedAt: session.updatedAt,
|
||||||
}))
|
messageCount: session.messageCount,
|
||||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
})),
|
||||||
|
[sessions],
|
||||||
|
);
|
||||||
|
|
||||||
const submit = async (event: FormEvent) => {
|
const submit = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const content = draft.trim();
|
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("");
|
setDraft("");
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await ask({
|
await sendMessage({
|
||||||
messages: typedNextMessages.map((message) => ({
|
sessionId: activeSessionId,
|
||||||
role: message.role,
|
content,
|
||||||
content: message.content,
|
|
||||||
})),
|
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
accountId,
|
accountId,
|
||||||
basis: monthBasis,
|
basis: monthBasis,
|
||||||
}) as { answer: string; toolTrace?: unknown };
|
});
|
||||||
|
|
||||||
updateSession(submittedSessionId, [
|
|
||||||
...typedNextMessages,
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: response.answer,
|
|
||||||
toolTrace: normalizeToolTrace(response.toolTrace),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error("Antwort konnte nicht geladen werden.");
|
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 {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -277,8 +326,8 @@ export function SavingsChatPage() {
|
|||||||
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
|
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||||
<ChatHistory
|
<ChatHistory
|
||||||
items={historyItems}
|
items={historyItems}
|
||||||
activeId={activeSessionId}
|
activeId={activeSessionId ?? ""}
|
||||||
onSelect={setActiveSessionId}
|
onSelect={(id) => setSelectedSessionId(id as Id<"chatSessions">)}
|
||||||
onCreate={createNewChat}
|
onCreate={createNewChat}
|
||||||
onDelete={deleteChat}
|
onDelete={deleteChat}
|
||||||
/>
|
/>
|
||||||
@@ -289,82 +338,80 @@ export function SavingsChatPage() {
|
|||||||
<h1 className="text-lg font-semibold">Talk to your savings account</h1>
|
<h1 className="text-lg font-semibold">Talk to your savings account</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Kontext der Auswertung</CardTitle>
|
<CardTitle>Kontext der Auswertung</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-1 text-sm">
|
<CardContent className="space-y-1 text-sm">
|
||||||
<p>Zeitraum: {from} bis {to}</p>
|
<p>Zeitraum: {from} bis {to}</p>
|
||||||
<p>Basis: {monthBasis === "effective" ? "Effektiv" : "Buchungstag"}</p>
|
<p>Basis: {monthBasis === "effective" ? "Effektiv" : "Buchungstag"}</p>
|
||||||
<p>
|
<p>
|
||||||
Konto: {context?.accountName ?? "Alle Konten"} (Basis-Saldo{" "}
|
Konto: {context?.accountName ?? "Alle Konten"} (Basis-Saldo{" "}
|
||||||
{context ? formatAmount(context.totals.balance) : "—"})
|
{context ? formatAmount(context.totals.balance) : "—"})
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>{context ? getContextSummary() : "Lade Kontext…"}</p>
|
||||||
{context ? getContextSummary() : "Lade Kontext…"}
|
</CardContent>
|
||||||
</p>
|
</Card>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="h-[52vh] overflow-y-auto" ref={listRef}>
|
<div className="h-[52vh] overflow-y-auto" ref={listRef}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{messages.map((message, index) => (
|
{displayMessages.map((message) => (
|
||||||
<div
|
<div
|
||||||
key={`${message.role}-${index}`}
|
key={message._id}
|
||||||
className={`rounded-lg border p-3 ${
|
className={`rounded-lg border p-3 ${
|
||||||
message.role === "user" ? "bg-muted/50" : "bg-background"
|
message.role === "user" ? "bg-muted/50" : "bg-background"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<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 && (
|
{message.role === "assistant" && message.toolTrace && message.toolTrace.length > 0 && (
|
||||||
<div className="mt-3 rounded-md border bg-muted/30 p-2">
|
<div className="mt-3 rounded-md border bg-muted/30 p-2">
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
Verwendete Werkzeuge
|
Verwendete Werkzeuge
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
{message.toolTrace.map((tool, toolIndex) => (
|
{message.toolTrace.map((tool, toolIndex) => (
|
||||||
<div key={`${tool.name}-${toolIndex}`} className="text-xs">
|
<div key={`${tool.name}-${toolIndex}`} className="text-xs">
|
||||||
<p className="font-medium">{tool.name}</p>
|
<p className="font-medium">{tool.name}</p>
|
||||||
<p className="text-muted-foreground">{tool.resultSummary}</p>
|
<p className="text-muted-foreground">{tool.resultSummary}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
{isSubmitting && (
|
||||||
{isSubmitting && (
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
Denk mit der KI nach…
|
||||||
Denk mit der KI nach…
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<form className="flex gap-2" onSubmit={submit}>
|
<form className="flex gap-2" onSubmit={submit}>
|
||||||
<Input
|
<Input
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
placeholder="Welche Auswertung soll ich machen?"
|
placeholder={activeSession ? "Welche Auswertung soll ich machen?" : "Chat wird vorbereitet…"}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || !activeSessionId}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button type="submit" disabled={buttonDisabled}>
|
<Button type="submit" disabled={buttonDisabled}>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
Senden
|
Senden
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Antwortmodell: {context ? "Server-seitig bereit" : "…"}
|
Antwortmodell: {context ? "Server-seitig bereit" : "…"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user