feat: add guarded savings agent tools

This commit is contained in:
Matthias
2026-06-23 21:13:02 +02:00
parent 6e687dc9b5
commit b7dae31fe1
22 changed files with 3510 additions and 73 deletions

View File

@@ -4,7 +4,7 @@ import {
type HTMLAttributes,
type Ref,
} from "react";
import { Loader2, Send, Wrench } from "lucide-react";
import { Check, Loader2, Send, Wrench, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
@@ -37,6 +37,24 @@ type AgentPromptInputProps = {
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
};
export type AgentActionPlanPreviewRow = {
date?: string;
description: string;
amount?: number;
currentCategoryName?: string;
targetCategoryName?: string;
action?: string;
};
export type AgentActionPlan = {
_id: string;
kind: "bulk_recategory" | "category_changes";
summary: string;
affectedCount: number;
expiresAt: number;
previewRows: AgentActionPlanPreviewRow[];
};
export function AgentConversation({
messages,
isSubmitting = false,
@@ -61,6 +79,92 @@ export function AgentConversation({
);
}
export function AgentActionPlanList({
plans,
isApplying,
onApply,
onDismiss,
}: {
plans: AgentActionPlan[];
isApplying: boolean;
onApply: (planId: string) => void;
onDismiss: (planId: string) => void;
}) {
if (plans.length === 0) return null;
return (
<section className="rounded-md border bg-card p-3" aria-label="Vorschläge zur Bestätigung">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold">Vorschläge zur Bestätigung</p>
<p className="text-xs text-muted-foreground">
Änderungen werden erst nach deiner Bestätigung angewendet.
</p>
</div>
</div>
<div className="space-y-3">
{plans.map((plan) => (
<article className="rounded-md border bg-muted/20 p-3" key={plan._id}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-medium">{plan.summary}</p>
<p className="mt-1 text-xs text-muted-foreground">
{plan.affectedCount} {plan.affectedCount === 1 ? "Änderung" : "Änderungen"} · läuft ab{" "}
{formatActionPlanExpiry(plan.expiresAt)}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button
type="button"
size="sm"
onClick={() => onApply(plan._id)}
disabled={isApplying}
>
{isApplying ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
Anwenden
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => onDismiss(plan._id)}
disabled={isApplying}
>
<X className="h-4 w-4" />
Verwerfen
</Button>
</div>
</div>
{plan.previewRows.length > 0 && (
<div className="mt-3 overflow-hidden rounded-md border bg-background/80">
{plan.previewRows.slice(0, 5).map((row, index) => (
<div
className="grid gap-1 border-b px-2 py-2 text-xs last:border-b-0 sm:grid-cols-[1fr_auto]"
key={`${plan._id}-${row.description}-${index}`}
>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{row.date ? `${row.date} · ` : ""}
{row.description}
</p>
<p className="truncate text-muted-foreground">
{row.action ?? [row.currentCategoryName, row.targetCategoryName].filter(Boolean).join(" → ")}
</p>
</div>
{row.amount !== undefined && (
<p className="font-medium tabular-nums">{formatActionPlanAmount(row.amount)}</p>
)}
</div>
))}
</div>
)}
</article>
))}
</div>
</section>
);
}
export function AgentMessage({ message, className, ...props }: AgentMessageProps) {
const isUser = message.role === "user";
@@ -132,6 +236,22 @@ function MessageContentWithCitations({
);
}
function formatActionPlanAmount(amount: number) {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(amount);
}
function formatActionPlanExpiry(expiresAt: number) {
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(expiresAt));
}
function AgentSources({ sources }: { sources: AgentSource[] }) {
return (
<div className="mt-3 rounded-md border bg-muted/20 p-2">