Files
finanzen/src/components/chat/AgentChat.tsx
2026-06-16 10:47:56 +02:00

168 lines
4.9 KiB
TypeScript

import {
type ChangeEvent,
type FormEvent,
type HTMLAttributes,
type Ref,
} from "react";
import { Loader2, Send, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
buildReasoningSteps,
getToolTraceSummary,
type AgentChatMessage,
type AgentReasoningStep,
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 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 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">{message.content}</p>
{!isUser && message.toolTrace && message.toolTrace.length > 0 && (
<AgentToolTracePanel toolTrace={message.toolTrace} />
)}
</div>
</article>
);
}
function AgentToolTracePanel({ toolTrace }: { 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" />
So wurde gearbeitet ({getToolTraceSummary(toolTrace)})
</summary>
<div className="mt-2 space-y-2">
{steps.map((step, stepIndex) => (
<ReasoningStep key={`${step.label}-${stepIndex}`} step={step} />
))}
</div>
</details>
);
}
function ReasoningStep({ step }: { step: AgentReasoningStep }) {
return (
<div className="rounded-md bg-background/80 p-2 text-xs" 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>
);
}