Compare commits

...

5 Commits

15 changed files with 3166 additions and 308 deletions

View File

@@ -1,10 +1,10 @@
--- ---
id: TASK-1 id: TASK-1
title: Add complete chat transaction context title: Add complete chat transaction context
status: In Progress status: Done
assignee: [] assignee: []
created_date: '2026-06-15 13:52' created_date: '2026-06-15 13:52'
updated_date: '2026-06-15 14:02' updated_date: '2026-06-15 19:54'
labels: [] labels: []
dependencies: [] dependencies: []
priority: high priority: high
@@ -61,3 +61,9 @@ Final review:
- Prior blocker resolved: effective-basis loading now includes rows without effectiveMonth via bookingDate fallback queries. - Prior blocker resolved: effective-basis loading now includes rows without effectiveMonth via bookingDate fallback queries.
- Fresh full npm run lint still fails only on unrelated existing files; no SavingsChatPage issue remains. - Fresh full npm run lint still fails only on unrelated existing files; no SavingsChatPage issue remains.
<!-- SECTION:NOTES:END --> <!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed as Done after explicit user confirmation to mark all tasks done.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
--- ---
id: TASK-2 id: TASK-2
title: Render category expense chart values as positive slices title: Render category expense chart values as positive slices
status: In Progress status: Done
assignee: [] assignee: []
created_date: '2026-06-15 14:34' created_date: '2026-06-15 14:34'
updated_date: '2026-06-15 14:48' updated_date: '2026-06-15 19:54'
labels: [] labels: []
dependencies: [] dependencies: []
priority: high priority: high
@@ -60,3 +60,9 @@ Verification after layout update:
Note: npm run build still emits the existing Vite chunk-size warning for the main bundle. Note: npm run build still emits the existing Vite chunk-size warning for the main bundle.
<!-- SECTION:NOTES:END --> <!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed as Done after explicit user confirmation to mark all tasks done.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
--- ---
id: TASK-3 id: TASK-3
title: Build read-only savings agent with tool calls title: Build read-only savings agent with tool calls
status: In Progress status: Done
assignee: [] assignee: []
created_date: '2026-06-15 19:02' created_date: '2026-06-15 19:02'
updated_date: '2026-06-15 19:11' updated_date: '2026-06-15 19:54'
labels: [] labels: []
dependencies: [] dependencies: []
priority: high priority: high
@@ -51,3 +51,9 @@ Verification:
- PASS npm run build - PASS npm run build
- BLOCKED npm run lint on pre-existing unrelated files: convex/bank/comdirectProvider.ts, convex/bank/config.ts, src/components/import/TanAwaitDialog.tsx, layout/ui fast-refresh exports, SettingsPage.tsx, generated eslint-disable warnings, and existing React compiler warnings. No TASK-3 touched file appears in the full-lint error list. - BLOCKED npm run lint on pre-existing unrelated files: convex/bank/comdirectProvider.ts, convex/bank/config.ts, src/components/import/TanAwaitDialog.tsx, layout/ui fast-refresh exports, SettingsPage.tsx, generated eslint-disable warnings, and existing React compiler warnings. No TASK-3 touched file appears in the full-lint error list.
<!-- SECTION:NOTES:END --> <!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed as Done after explicit user confirmation to mark all tasks done.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
--- ---
id: TASK-4 id: TASK-4
title: Add selection total to transactions title: Add selection total to transactions
status: In Progress status: Done
assignee: [] assignee: []
created_date: '2026-06-15 19:21' created_date: '2026-06-15 19:21'
updated_date: '2026-06-15 19:24' updated_date: '2026-06-15 19:54'
labels: [] labels: []
dependencies: [] dependencies: []
priority: high priority: high
@@ -39,3 +39,9 @@ Add table selection controls on the transactions page so visible/filtered rows c
<!-- SECTION:NOTES:BEGIN --> <!-- SECTION:NOTES:BEGIN -->
Added failing Vitest coverage for visible bulk selection and selected totals, then implemented transactionsSelection helpers and wired TransactionsPage header checkbox plus selected count/sum. Focused test and build pass. Global lint currently fails on pre-existing unrelated lint errors in Convex/generated/UI files. Added failing Vitest coverage for visible bulk selection and selected totals, then implemented transactionsSelection helpers and wired TransactionsPage header checkbox plus selected count/sum. Focused test and build pass. Global lint currently fails on pre-existing unrelated lint errors in Convex/generated/UI files.
<!-- SECTION:NOTES:END --> <!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed as Done after explicit user confirmation to mark all tasks done.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
--- ---
id: TASK-5 id: TASK-5
title: Make transaction filters combinable title: Make transaction filters combinable
status: In Progress status: Done
assignee: [] assignee: []
created_date: '2026-06-15 19:32' created_date: '2026-06-15 19:32'
updated_date: '2026-06-15 19:34' updated_date: '2026-06-15 19:54'
labels: [] labels: []
dependencies: [] dependencies: []
priority: high priority: high
@@ -38,3 +38,9 @@ Fix the transactions page so global timeframe, account, and month-basis filters
<!-- SECTION:NOTES:BEGIN --> <!-- SECTION:NOTES:BEGIN -->
Root cause: TransactionsPage did not pass global from/to/account/monthBasis filters into api.transactions.list, and transactions.list ignored date filters on the search-index path. Added red Convex tests covering combined filters and effective-month basis, then added basis/date filtering in the query and wired global filters from the page. Focused tests and build pass. Root cause: TransactionsPage did not pass global from/to/account/monthBasis filters into api.transactions.list, and transactions.list ignored date filters on the search-index path. Added red Convex tests covering combined filters and effective-month basis, then added basis/date filtering in the query and wired global filters from the page. Focused tests and build pass.
<!-- SECTION:NOTES:END --> <!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed as Done after explicit user confirmation to mark all tasks done.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
--- ---
id: TASK-6 id: TASK-6
title: Add transaction filter reset button title: Add transaction filter reset button
status: In Progress status: Done
assignee: [] assignee: []
created_date: '2026-06-15 19:35' created_date: '2026-06-15 19:35'
updated_date: '2026-06-15 19:37' updated_date: '2026-06-15 19:54'
labels: [] labels: []
dependencies: [] dependencies: []
priority: medium priority: medium
@@ -30,3 +30,9 @@ Add a reset button on the transactions page that clears the active filter combin
<!-- SECTION:NOTES:BEGIN --> <!-- SECTION:NOTES:BEGIN -->
Added a transactions toolbar reset button using shared reset defaults. The button clears global filters (current month, all accounts, no categories, effective month basis), clears page filters (search, type, pending-only), and clears row selection. Added Vitest coverage for reset defaults. Focused tests and build pass. Added a transactions toolbar reset button using shared reset defaults. The button clears global filters (current month, all accounts, no categories, effective month basis), clears page filters (search, type, pending-only), and clears row selection. Added Vitest coverage for reset defaults. Focused tests and build pass.
<!-- SECTION:NOTES:END --> <!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed as Done after explicit user confirmation to mark all tasks done.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,54 @@
---
id: TASK-7
title: Add all read-only savings agent insight tools
status: Done
assignee: []
created_date: '2026-06-15 19:39'
updated_date: '2026-06-15 19:54'
labels: []
dependencies: []
priority: high
ordinal: 7000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Expand Talk to Savings with eight additional read-only AI SDK tools for accounts, categories, recurring transactions, anomalies, uncategorized transactions, period comparison, fixed-cost forecasting, and savings-rate explanations.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Agent registers all eight new read-only tools and returns compact trace summaries without raw/private fields
- [x] #2 Metadata and data-quality tools return scoped, sanitized accounts, categories, and uncategorized transaction insight
- [x] #3 Period, savings-rate, recurring, fixed-cost forecast, and anomaly tools compute deterministic aggregates
- [x] #4 Convex tests cover each new tool and mocked ask() registration
- [x] #5 Focused tests, targeted lint, build, and full lint status are documented
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing coverage for all eight read-only savings insight tools and ask() registry
2. Implement scoped/sanitized internal Convex queries and deterministic finance helpers
3. Register the tools in savingsChat.ask and add compact trace summaries
4. Verify focused tests, targeted lint, build, and document full-lint blockers
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented eight new read-only tools in convex/savingsChat.ts: accounts, categories, recurring patterns, anomalies, uncategorized transactions, period comparison, fixed-cost forecast, and savings-rate explanation.
Verification: npx vitest convex/savingsChat.test.ts --run --reporter=dot passed (17 tests).
Verification: npx eslint convex/savingsChat.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx passed.
Verification: npm run build passed with existing Vite chunk-size warning.
Full npm run lint still fails on unrelated existing files: convex/bank/comdirectProvider.ts, convex/bank/config.ts, src/components/import/TanAwaitDialog.tsx, several react-refresh only-export-components files, and src/pages/SettingsPage.tsx; warnings also remain in generated Convex files and React Hook Form/TanStack Table locations.
Hardened compare_periods snapshots to omit category IDs while keeping existing summarize_spending output compatible. Re-ran focused Vitest, targeted ESLint, and build successfully after this change.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed as Done after explicit user confirmation to mark all tasks done. Implementation and verification notes are already recorded on the task.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,47 @@
---
id: TASK-8
title: Fix savings tool category matching
status: In Progress
assignee: []
created_date: '2026-06-15 20:10'
updated_date: '2026-06-15 20:19'
labels: []
dependencies: []
priority: high
ordinal: 8000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Make Talk to Savings read-only tools resolve user/model category names like Supermärkte or Lebensmittel und Supermarkt to existing categories such as Lebensmittel & Supermarkt, and surface diagnostics instead of misleading silent zero results.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Category filter aliases resolve ampersand/und, casing, punctuation, umlauts, and simple German plural/flexion variants
- [x] #2 All savings tools that accept categoryNames use shared resolution diagnostics
- [x] #3 Unknown or ambiguous category filters return diagnostics and compact trace summaries instead of silently confident zero results
- [x] #4 Regression tests cover Lebensmittel und Supermarkt, Supermärkte, unknown diagnostics, and trace summaries
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing Convex regression tests for tolerant categoryNames and diagnostics
2. Implement shared category-name normalization and resolver in convex/savingsChat.ts
3. Thread diagnostics through every categoryNames-aware tool output and compact traces
4. Update tool/prompt guidance to avoid confident zero answers on unresolved filters
5. Run focused Vitest, targeted ESLint, and production build
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented shared tolerant category resolver for savings tools and added diagnostics to categoryNames-aware outputs.
Verification passed: npx vitest convex/savingsChat.test.ts --run (20 tests), npx eslint convex/savingsChat.ts convex/savingsChat.test.ts, npm run build (with existing Vite chunk-size warning).
Added an additional regression test to preserve the virtual Ohne Kategorie filter while moving categoryNames to resolver-based category IDs. Re-ran focused Vitest, targeted ESLint, and build successfully after this adjustment.
Addressed QA review findings: added ASCII transliteration coverage for Supermaerkte, explicit ambiguous category filter coverage, and multi-diagnostic compact trace summaries. Final verification passed: npx vitest convex/savingsChat.test.ts --run (22 tests), npx eslint convex/savingsChat.ts convex/savingsChat.test.ts, npm run build (with existing Vite chunk-size warning).
<!-- SECTION:NOTES:END -->

View File

@@ -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 -->

View File

@@ -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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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,
}));
},
});

View File

@@ -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"]),
}); });

View File

@@ -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();
const updateSession = (id: string, nextMessages: ChatMessage[]) => { if (legacySessions.length === 0) {
const now = Date.now(); localStorage.setItem(importMarkerKey, "true");
setSessions((prev) => void Promise.resolve().then(() =>
prev.map((session) => setLegacyImportResult({ key: importMarkerKey, importedCount: 0 }),
session.id === id
? {
...session,
title: titleFromMessages(nextMessages),
updatedAt: now,
messages: nextMessages,
}
: session,
),
); );
}; return;
}
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 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) => ({
id: session._id,
title: session.title, title: session.title,
updatedAt: session.updatedAt, updatedAt: session.updatedAt,
messageCount: session.messages.length, 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}
/> />
@@ -300,9 +349,7 @@ export function SavingsChatPage() {
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…"}
</p>
</CardContent> </CardContent>
</Card> </Card>
@@ -310,9 +357,9 @@ export function SavingsChatPage() {
<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"
}`} }`}
@@ -351,8 +398,8 @@ export function SavingsChatPage() {
<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}>