feat: add guarded savings agent tools
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user