feat: sync savings chat history with convex
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { useAction, useQuery } from "convex/react";
|
||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAction, useMutation, usePaginatedQuery, useQuery } from "convex/react";
|
||||
import { MessageCircle, Send, Loader2 } from "lucide-react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { useAccountFilterId } from "@/components/layout/AccountFilter";
|
||||
import { useFilters } from "@/context/FilterContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -23,20 +24,22 @@ type AssistantChatMessage = {
|
||||
toolTrace?: ToolTrace[];
|
||||
};
|
||||
type ChatMessage = UserChatMessage | AssistantChatMessage;
|
||||
type ChatSession = {
|
||||
type LegacyChatSession = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
type DisplayChatMessage = ChatMessage & { _id: string };
|
||||
|
||||
const STORAGE_KEY = "savings-chat-sessions";
|
||||
const IMPORTED_KEY = "savings-chat-sessions-imported-v1";
|
||||
const initialAssistantMessage: ChatMessage = {
|
||||
role: "assistant",
|
||||
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 {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
@@ -84,7 +87,7 @@ function isChatMessage(value: ChatMessage | null): value is ChatMessage {
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
function normalizeSession(value: unknown): ChatSession | null {
|
||||
function normalizeSession(value: unknown): LegacyChatSession | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (
|
||||
@@ -109,53 +112,57 @@ function normalizeSession(value: unknown): ChatSession | null {
|
||||
};
|
||||
}
|
||||
|
||||
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[] {
|
||||
function loadLegacySessions(): LegacyChatSession[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [createSession()];
|
||||
if (!raw) return [];
|
||||
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[];
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.map(normalizeSession)
|
||||
.filter((session): session is LegacyChatSession => session !== null)
|
||||
.filter((session) => session.messages.some((message) => message.role === "user"));
|
||||
} 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() {
|
||||
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 [selectedSessionId, setSelectedSessionId] = useState<Id<"chatSessions"> | undefined>();
|
||||
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 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, {
|
||||
from,
|
||||
@@ -163,9 +170,23 @@ export function SavingsChatPage() {
|
||||
accountId,
|
||||
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) =>
|
||||
new Intl.NumberFormat("de-DE", {
|
||||
@@ -182,92 +203,120 @@ export function SavingsChatPage() {
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.lastElementChild?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
}, [displayMessages.length, activeSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
|
||||
}, [sessions]);
|
||||
if (!importMarkerKey || legacyImportComplete || legacyImportStartedRef.current === importMarkerKey) return;
|
||||
legacyImportStartedRef.current = importMarkerKey;
|
||||
const legacySessions = loadLegacySessions();
|
||||
if (legacySessions.length === 0) {
|
||||
localStorage.setItem(importMarkerKey, "true");
|
||||
void Promise.resolve().then(() =>
|
||||
setLegacyImportResult({ key: importMarkerKey, importedCount: 0 }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
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 session = createSession();
|
||||
setSessions((prev) => [session, ...prev]);
|
||||
setActiveSessionId(session.id);
|
||||
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 remaining = sessions.filter((session) => session.id !== id);
|
||||
const nextSessions = remaining.length > 0 ? remaining : [createSession()];
|
||||
setSessions(nextSessions);
|
||||
if (id === activeSessionId) setActiveSessionId(nextSessions[0].id);
|
||||
void deleteSession({ sessionId: id as Id<"chatSessions"> })
|
||||
.then(() => {
|
||||
if (id === activeSessionId) {
|
||||
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
|
||||
.map((session) => ({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
updatedAt: session.updatedAt,
|
||||
messageCount: session.messages.length,
|
||||
}))
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const historyItems: ChatHistoryItem[] = useMemo(
|
||||
() =>
|
||||
sessions.map((session) => ({
|
||||
id: session._id,
|
||||
title: session.title,
|
||||
updatedAt: session.updatedAt,
|
||||
messageCount: session.messageCount,
|
||||
})),
|
||||
[sessions],
|
||||
);
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
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("");
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await ask({
|
||||
messages: typedNextMessages.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
})),
|
||||
await sendMessage({
|
||||
sessionId: activeSessionId,
|
||||
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);
|
||||
}
|
||||
@@ -277,8 +326,8 @@ export function SavingsChatPage() {
|
||||
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<ChatHistory
|
||||
items={historyItems}
|
||||
activeId={activeSessionId}
|
||||
onSelect={setActiveSessionId}
|
||||
activeId={activeSessionId ?? ""}
|
||||
onSelect={(id) => setSelectedSessionId(id as Id<"chatSessions">)}
|
||||
onCreate={createNewChat}
|
||||
onDelete={deleteChat}
|
||||
/>
|
||||
@@ -289,82 +338,80 @@ export function SavingsChatPage() {
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="h-[52vh] overflow-y-auto" ref={listRef}>
|
||||
<div className="space-y-3">
|
||||
{displayMessages.map((message) => (
|
||||
<div
|
||||
key={message._id}
|
||||
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>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
{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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
<form className="flex gap-2" onSubmit={submit}>
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
placeholder={activeSession ? "Welche Auswertung soll ich machen?" : "Chat wird vorbereitet…"}
|
||||
disabled={isSubmitting || !activeSessionId}
|
||||
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>
|
||||
<Separator />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Antwortmodell: {context ? "Server-seitig bereit" : "…"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user