Add savings chat analysis feature

This commit is contained in:
Matthias
2026-06-15 18:26:25 +02:00
parent d65e7681ac
commit 4869402d45
26 changed files with 2789 additions and 163 deletions

View File

@@ -0,0 +1,265 @@
import { type FormEvent, useEffect, useRef, useState } from "react";
import { useAction, useQuery } from "convex/react";
import { MessageCircle, Send, Loader2 } from "lucide-react";
import { api } from "../../convex/_generated/api";
import { useAccountFilterId } from "@/components/layout/AccountFilter";
import { useFilters } from "@/context/FilterContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { ChatHistory, type ChatHistoryItem } from "@/components/chat/ChatHistory";
import { toast } from "sonner";
type ChatMessage = { role: "user" | "assistant"; content: string };
type ChatSession = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messages: ChatMessage[];
};
const STORAGE_KEY = "savings-chat-sessions";
const initialAssistantMessage: ChatMessage = {
role: "assistant",
content: "Frag mich zu deinen Umsätzen ich werte sie im aktuellen Zeitraum aus.",
};
const fallbackMessages = [initialAssistantMessage];
function createSession(): ChatSession {
const now = Date.now();
const randomId =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${now}-${Math.random().toString(36).slice(2)}`;
return {
id: randomId,
title: "Neuer Chat",
createdAt: now,
updatedAt: now,
messages: [initialAssistantMessage],
};
}
function loadSessions(): ChatSession[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [createSession()];
const parsed = JSON.parse(raw) as ChatSession[];
if (!Array.isArray(parsed) || parsed.length === 0) return [createSession()];
return parsed;
} catch {
return [createSession()];
}
}
function titleFromMessages(messages: ChatMessage[]) {
const firstUserMessage = messages.find((message) => message.role === "user")?.content.trim();
if (!firstUserMessage) return "Neuer Chat";
return firstUserMessage.length > 44 ? `${firstUserMessage.slice(0, 44)}` : firstUserMessage;
}
export function SavingsChatPage() {
const { from, to, monthBasis } = useFilters();
const accountId = useAccountFilterId();
const [draft, setDraft] = useState("");
const [sessions, setSessions] = useState<ChatSession[]>(loadSessions);
const [activeSessionId, setActiveSessionId] = useState(() => sessions[0]?.id ?? "");
const [isSubmitting, setIsSubmitting] = useState(false);
const listRef = useRef<HTMLDivElement | null>(null);
const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0];
const messages = activeSession?.messages ?? fallbackMessages;
const context = useQuery(api.savingsChat.getContext, {
from,
to,
accountId,
basis: monthBasis,
});
const ask = useAction(api.savingsChat.ask);
const buttonDisabled = isSubmitting || draft.trim().length === 0;
const formatAmount = (amount: number) =>
new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(amount);
const getContextSummary = () => {
if (!context) return "Lade Kontext…";
return `${context.totals.transactionCount} Umsätze · Einnahmen ${formatAmount(
context.totals.income,
)} · Ausgaben ${formatAmount(context.totals.expenses)}`;
};
useEffect(() => {
listRef.current?.lastElementChild?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
}, [sessions]);
const updateSession = (id: string, nextMessages: ChatMessage[]) => {
const now = Date.now();
setSessions((prev) =>
prev.map((session) =>
session.id === id
? {
...session,
title: titleFromMessages(nextMessages),
updatedAt: now,
messages: nextMessages,
}
: session,
),
);
};
const createNewChat = () => {
const session = createSession();
setSessions((prev) => [session, ...prev]);
setActiveSessionId(session.id);
setDraft("");
};
const deleteChat = (id: string) => {
const remaining = sessions.filter((session) => session.id !== id);
const nextSessions = remaining.length > 0 ? remaining : [createSession()];
setSessions(nextSessions);
if (id === activeSessionId) setActiveSessionId(nextSessions[0].id);
};
const historyItems: ChatHistoryItem[] = sessions
.map((session) => ({
id: session.id,
title: session.title,
updatedAt: session.updatedAt,
messageCount: session.messages.length,
}))
.sort((a, b) => b.updatedAt - a.updatedAt);
const submit = async (event: FormEvent) => {
event.preventDefault();
const content = draft.trim();
if (!content || isSubmitting) return;
const submittedSessionId = activeSessionId;
const typedNextMessages: ChatMessage[] = [...messages, { role: "user", content }];
updateSession(submittedSessionId, typedNextMessages);
setDraft("");
setIsSubmitting(true);
try {
const response = await ask({
messages: typedNextMessages,
from,
to,
accountId,
basis: monthBasis,
});
updateSession(submittedSessionId, [
...typedNextMessages,
{ role: "assistant", content: response.answer },
]);
} catch (error) {
console.error(error);
toast.error("Antwort konnte nicht geladen werden.");
updateSession(submittedSessionId, [
...typedNextMessages,
{
role: "assistant",
content: "Ich konnte gerade keine Antwort erzeugen. Bitte später erneut versuchen.",
},
]);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
<ChatHistory
items={historyItems}
activeId={activeSessionId}
onSelect={setActiveSessionId}
onCreate={createNewChat}
onDelete={deleteChat}
/>
<div className="min-w-0 space-y-4">
<div className="mb-2 flex items-center gap-2">
<MessageCircle className="h-5 w-5" />
<h1 className="text-lg font-semibold">Talk to your savings account</h1>
</div>
<Card>
<CardHeader>
<CardTitle>Kontext der Auswertung</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<p>Zeitraum: {from} bis {to}</p>
<p>Basis: {monthBasis === "effective" ? "Effektiv" : "Buchungstag"}</p>
<p>
Konto: {context?.accountName ?? "Alle Konten"} (Basis-Saldo{" "}
{context ? formatAmount(context.totals.balance) : "—"})
</p>
<p>
{context ? getContextSummary() : "Lade Kontext…"}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="h-[52vh] overflow-y-auto" ref={listRef}>
<div className="space-y-3">
{messages.map((message, index) => (
<div
key={`${message.role}-${index}`}
className={`rounded-lg border p-3 ${
message.role === "user" ? "bg-muted/50" : "bg-background"
}`}
>
<p className="text-xs uppercase text-muted-foreground">{message.role}</p>
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
</div>
))}
{isSubmitting && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Denk mit der KI nach
</div>
)}
</div>
</div>
</CardContent>
</Card>
<form className="flex gap-2" onSubmit={submit}>
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Welche Auswertung soll ich machen?"
disabled={isSubmitting}
autoFocus
/>
<Button type="submit" disabled={buttonDisabled}>
<Send className="h-4 w-4" />
Senden
</Button>
</form>
<Separator />
<p className="text-xs text-muted-foreground">
Antwortmodell: {context ? "Server-seitig bereit" : "…"}
</p>
</div>
</div>
);
}