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; 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; 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; 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(loadSessions); const [activeSessionId, setActiveSessionId] = useState(() => sessions[0]?.id ?? ""); const [isSubmitting, setIsSubmitting] = useState(false); const listRef = useRef(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 (

Talk to your savings account

Kontext der Auswertung

Zeitraum: {from} bis {to}

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

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

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

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

{message.role}

{message.content}

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

Verwendete Werkzeuge

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

{tool.name}

{tool.resultSummary}

))}
)}
))} {isSubmitting && (
Denk mit der KI nach…
)}
setDraft(event.target.value)} placeholder="Welche Auswertung soll ich machen?" disabled={isSubmitting} autoFocus />

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

); }