372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|