373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
import {
|
|
type ChangeEvent,
|
|
type FormEvent,
|
|
type HTMLAttributes,
|
|
type Ref,
|
|
} from "react";
|
|
import { Check, Loader2, Send, Wrench, X } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
buildReasoningSteps,
|
|
getToolTraceSummary,
|
|
type AgentChatMessage,
|
|
type AgentCitation,
|
|
type AgentReasoningStep,
|
|
type AgentSource,
|
|
type AgentToolTrace,
|
|
} from "./agentChatModel";
|
|
export type { AgentChatMessage, AgentToolTrace } from "./agentChatModel";
|
|
|
|
type AgentConversationProps = HTMLAttributes<HTMLDivElement> & {
|
|
messages: AgentChatMessage[];
|
|
isSubmitting?: boolean;
|
|
scrollRef?: Ref<HTMLDivElement>;
|
|
};
|
|
|
|
type AgentMessageProps = HTMLAttributes<HTMLDivElement> & {
|
|
message: AgentChatMessage;
|
|
};
|
|
|
|
type AgentPromptInputProps = {
|
|
value: string;
|
|
placeholder: string;
|
|
disabled: boolean;
|
|
isSubmitting: boolean;
|
|
onChange: (value: string) => void;
|
|
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,
|
|
scrollRef,
|
|
className,
|
|
...props
|
|
}: AgentConversationProps) {
|
|
return (
|
|
<section
|
|
aria-label="Chatverlauf"
|
|
className={cn("h-[52vh] overflow-y-auto rounded-md border bg-background", className)}
|
|
ref={scrollRef}
|
|
{...props}
|
|
>
|
|
<div className="space-y-3 p-3">
|
|
{messages.map((message) => (
|
|
<AgentMessage key={message.id} message={message} />
|
|
))}
|
|
{isSubmitting && <AgentThinkingIndicator />}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
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";
|
|
|
|
return (
|
|
<article
|
|
className={cn(
|
|
"flex w-full",
|
|
isUser ? "justify-end" : "justify-start",
|
|
className,
|
|
)}
|
|
{...props}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"max-w-[min(100%,46rem)] rounded-lg border px-3 py-2 text-sm",
|
|
isUser ? "bg-muted/60" : "bg-card text-card-foreground",
|
|
)}
|
|
>
|
|
<p className="text-[0.7rem] font-medium uppercase text-muted-foreground">
|
|
{isUser ? "User" : "Assistant"}
|
|
</p>
|
|
<p className="mt-1 whitespace-pre-wrap leading-6">
|
|
<MessageContentWithCitations
|
|
content={message.content}
|
|
citations={message.citations}
|
|
/>
|
|
</p>
|
|
{!isUser && message.toolTrace && message.toolTrace.length > 0 && (
|
|
<AgentEvidencePanel sources={message.sources} toolTrace={message.toolTrace} />
|
|
)}
|
|
{!isUser && (!message.toolTrace || message.toolTrace.length === 0) && message.sources && message.sources.length > 0 && (
|
|
<AgentSources sources={message.sources} />
|
|
)}
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function MessageContentWithCitations({
|
|
content,
|
|
citations,
|
|
}: {
|
|
content: string;
|
|
citations?: AgentCitation[];
|
|
}) {
|
|
if (!citations || citations.length === 0) return content;
|
|
const citationByMarker = new Map(citations.map((citation) => [citation.marker, citation]));
|
|
const parts = content.split(/(\[\d+\])/g);
|
|
|
|
return (
|
|
<>
|
|
{parts.map((part, index) => {
|
|
const marker = part.match(/^\[(\d+)\]$/)?.[1];
|
|
const citation = marker ? citationByMarker.get(marker) : undefined;
|
|
if (!citation) return <span key={`${part}-${index}`}>{part}</span>;
|
|
|
|
return (
|
|
<span
|
|
className="font-medium text-foreground"
|
|
aria-label={`Quelle ${citation.marker}: ${citation.sourceId}`}
|
|
data-source-id={citation.sourceId}
|
|
key={`${citation.sourceId}-${index}`}
|
|
>
|
|
{part}
|
|
</span>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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">
|
|
<p className="text-xs font-medium text-muted-foreground">Quellen</p>
|
|
<div className="mt-2 space-y-2">
|
|
{sources.map((source) => (
|
|
<div
|
|
className="rounded-md bg-background/80 p-2 text-xs"
|
|
data-source-id={source.id}
|
|
key={source.id}
|
|
>
|
|
<p className="font-medium text-foreground">{source.title}</p>
|
|
{source.description && (
|
|
<p className="mt-1 text-muted-foreground">{source.description}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AgentEvidencePanel({
|
|
sources,
|
|
toolTrace,
|
|
}: {
|
|
sources?: AgentSource[];
|
|
toolTrace: AgentToolTrace[];
|
|
}) {
|
|
const steps = buildReasoningSteps(toolTrace);
|
|
|
|
return (
|
|
<details className="mt-3 rounded-md border bg-muted/30 px-2 py-1.5">
|
|
<summary className="flex cursor-pointer list-none items-center gap-2 text-xs font-medium text-muted-foreground">
|
|
<Wrench className="h-3.5 w-3.5" />
|
|
Nachweis & Arbeitsweg ({getToolTraceSummary(toolTrace)})
|
|
</summary>
|
|
<div className="mt-2 space-y-2">
|
|
{steps.map((step, stepIndex) => (
|
|
<ReasoningStep
|
|
key={`${step.label}-${stepIndex}`}
|
|
sourceId={sources?.[stepIndex]?.id}
|
|
step={step}
|
|
/>
|
|
))}
|
|
</div>
|
|
</details>
|
|
);
|
|
}
|
|
|
|
function ReasoningStep({
|
|
sourceId,
|
|
step,
|
|
}: {
|
|
sourceId?: string;
|
|
step: AgentReasoningStep;
|
|
}) {
|
|
return (
|
|
<div
|
|
className="rounded-md bg-background/80 p-2 text-xs"
|
|
data-source-id={sourceId}
|
|
data-status={step.status}
|
|
>
|
|
<p className="font-medium text-foreground">{step.label}</p>
|
|
<p className="mt-1 text-muted-foreground">{step.description}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AgentThinkingIndicator() {
|
|
return (
|
|
<div className="space-y-2 px-1 text-sm text-muted-foreground">
|
|
<div className="flex items-center gap-2">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Denk mit der KI nach...
|
|
</div>
|
|
<div className="rounded-md border bg-muted/30 p-2 text-xs" data-status="active">
|
|
<p className="font-medium text-foreground">Antwort wird vorbereitet</p>
|
|
<p className="mt-1">Der Agent prueft den aktuellen Finanzkontext.</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AgentPromptInput({
|
|
value,
|
|
placeholder,
|
|
disabled,
|
|
isSubmitting,
|
|
onChange,
|
|
onSubmit,
|
|
}: AgentPromptInputProps) {
|
|
const submitDisabled = disabled || value.trim().length === 0;
|
|
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value);
|
|
|
|
return (
|
|
<form
|
|
className="rounded-lg border bg-card p-2 shadow-sm"
|
|
aria-label="Chatnachricht senden"
|
|
onSubmit={onSubmit}
|
|
>
|
|
<div className="flex items-end gap-2">
|
|
<textarea
|
|
className="min-h-11 flex-1 resize-none bg-transparent px-2 py-2 text-sm leading-6 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60"
|
|
value={value}
|
|
onChange={handleChange}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
rows={1}
|
|
/>
|
|
<Button type="submit" disabled={submitDisabled} className="shrink-0">
|
|
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
|
{isSubmitting ? "Antwort laeuft" : "Senden"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|