Files
finanzen/src/pages/SavingsChatPage.tsx

372 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ToolTrace = {
name: string;
inputSummary: string;
resultSummary: string;
};
type UserChatMessage = { role: "user"; content: string };
type AssistantChatMessage = {
role: "assistant";
content: string;
toolTrace?: ToolTrace[];
};
type ChatMessage = UserChatMessage | AssistantChatMessage;
type ChatSession = {
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 normalizeToolTrace(value: unknown): ToolTrace[] | undefined {
if (!Array.isArray(value)) return undefined;
const trace = value.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const candidate = item as Record<string, unknown>;
if (
typeof candidate.name !== "string" ||
typeof candidate.inputSummary !== "string" ||
typeof candidate.resultSummary !== "string"
) {
return [];
}
return [
{
name: candidate.name,
inputSummary: candidate.inputSummary,
resultSummary: candidate.resultSummary,
},
];
});
return trace.length > 0 ? trace : undefined;
}
function normalizeMessage(value: unknown): ChatMessage | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Record<string, unknown>;
if (typeof candidate.content !== "string") return null;
if (candidate.role === "user") {
return { role: "user", content: candidate.content };
}
if (candidate.role === "assistant") {
const toolTrace = normalizeToolTrace(candidate.toolTrace);
return toolTrace
? { role: "assistant", content: candidate.content, toolTrace }
: { role: "assistant", content: candidate.content };
}
return null;
}
function isChatMessage(value: ChatMessage | null): value is ChatMessage {
return value !== null;
}
function normalizeSession(value: unknown): ChatSession | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Record<string, unknown>;
if (
typeof candidate.id !== "string" ||
typeof candidate.title !== "string" ||
typeof candidate.createdAt !== "number" ||
typeof candidate.updatedAt !== "number" ||
!Array.isArray(candidate.messages)
) {
return null;
}
const messages = candidate.messages.map(normalizeMessage);
if (!messages.every(isChatMessage)) return null;
return {
id: candidate.id,
title: candidate.title,
createdAt: candidate.createdAt,
updatedAt: candidate.updatedAt,
messages,
};
}
function createSession(): ChatSession {
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: unknown = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length === 0) return [createSession()];
const sessions = parsed.map(normalizeSession);
if (sessions.some((session) => session === null)) return [createSession()];
return sessions as ChatSession[];
} 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.map((message) => ({
role: message.role,
content: message.content,
})),
from,
to,
accountId,
basis: monthBasis,
}) as { answer: string; toolTrace?: unknown };
updateSession(submittedSessionId, [
...typedNextMessages,
{
role: "assistant",
content: response.answer,
toolTrace: normalizeToolTrace(response.toolTrace),
},
]);
} catch (error) {
console.error(error);
toast.error("Antwort konnte nicht geladen werden.");
updateSession(submittedSessionId, [
...typedNextMessages,
{
role: "assistant",
content: "Ich konnte gerade keine Antwort erzeugen. Bitte später erneut versuchen.",
},
]);
} finally {
setIsSubmitting(false);
}
};
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>
{message.role === "assistant" && message.toolTrace && message.toolTrace.length > 0 && (
<div className="mt-3 rounded-md border bg-muted/30 p-2">
<p className="text-xs font-medium text-muted-foreground">
Verwendete Werkzeuge
</p>
<div className="mt-2 space-y-2">
{message.toolTrace.map((tool, toolIndex) => (
<div key={`${tool.name}-${toolIndex}`} className="text-xs">
<p className="font-medium">{tool.name}</p>
<p className="text-muted-foreground">{tool.resultSummary}</p>
</div>
))}
</div>
</div>
)}
</div>
))}
{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>
);
}