Add AI Elements-inspired chat primitives
This commit is contained in:
113
src/components/chat/AgentChat.test.tsx
Normal file
113
src/components/chat/AgentChat.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
AgentConversation,
|
||||
AgentMessage,
|
||||
AgentPromptInput,
|
||||
type AgentChatMessage,
|
||||
} from "./AgentChat";
|
||||
import { getToolTraceSummary } from "./agentChatModel";
|
||||
|
||||
const assistantMessage: AgentChatMessage = {
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "Deine groesste Ausgabe war Miete.",
|
||||
toolTrace: [
|
||||
{
|
||||
name: "summarize_transactions",
|
||||
inputSummary: "Mai 2026",
|
||||
resultSummary: "12 Umsaetze zusammengefasst",
|
||||
},
|
||||
{
|
||||
name: "list_transactions",
|
||||
inputSummary: "Miete",
|
||||
resultSummary: "1 Treffer",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("AgentChat phase 1 components", () => {
|
||||
test("summarizes tool traces for compact agent transparency", () => {
|
||||
expect(getToolTraceSummary(undefined)).toBe("Keine Werkzeuge");
|
||||
expect(getToolTraceSummary([])).toBe("Keine Werkzeuge");
|
||||
expect(getToolTraceSummary(assistantMessage.toolTrace)).toBe("2 Werkzeuge verwendet");
|
||||
});
|
||||
|
||||
test("renders conversation messages with assistant tool trace details", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AgentConversation
|
||||
messages={[
|
||||
{ id: "user-1", role: "user", content: "Was war teuer?" },
|
||||
assistantMessage,
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Was war teuer?");
|
||||
expect(markup).toContain("Deine groesste Ausgabe war Miete.");
|
||||
expect(markup).toContain("2 Werkzeuge verwendet");
|
||||
expect(markup).toContain("summarize_transactions");
|
||||
expect(markup).toContain("12 Umsaetze zusammengefasst");
|
||||
});
|
||||
|
||||
test("renders the prompt input with ready and busy states", () => {
|
||||
const readyMarkup = renderToStaticMarkup(
|
||||
<AgentPromptInput
|
||||
value="Bitte analysieren"
|
||||
disabled={false}
|
||||
isSubmitting={false}
|
||||
placeholder="Welche Auswertung soll ich machen?"
|
||||
onChange={() => undefined}
|
||||
onSubmit={() => undefined}
|
||||
/>,
|
||||
);
|
||||
const busyMarkup = renderToStaticMarkup(
|
||||
<AgentPromptInput
|
||||
value="Bitte analysieren"
|
||||
disabled
|
||||
isSubmitting
|
||||
placeholder="Chat wird vorbereitet..."
|
||||
onChange={() => undefined}
|
||||
onSubmit={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(readyMarkup).toContain("Welche Auswertung soll ich machen?");
|
||||
expect(readyMarkup).toContain("Senden");
|
||||
expect(busyMarkup).toContain("Antwort laeuft");
|
||||
expect(busyMarkup).toContain("disabled");
|
||||
});
|
||||
|
||||
test("disables prompt submit for whitespace-only input", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AgentPromptInput
|
||||
value=" "
|
||||
disabled={false}
|
||||
isSubmitting={false}
|
||||
placeholder="Welche Auswertung soll ich machen?"
|
||||
onChange={() => undefined}
|
||||
onSubmit={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("disabled");
|
||||
});
|
||||
|
||||
test("renders a thinking indicator while the agent is submitting", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AgentConversation messages={[assistantMessage]} isSubmitting />,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Denk mit der KI nach...");
|
||||
});
|
||||
|
||||
test("renders a single message without a tool section when no trace exists", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AgentMessage message={{ id: "assistant-empty", role: "assistant", content: "Hallo" }} />,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Assistant");
|
||||
expect(markup).toContain("Hallo");
|
||||
expect(markup).not.toContain("Werkzeuge verwendet");
|
||||
});
|
||||
});
|
||||
162
src/components/chat/AgentChat.tsx
Normal file
162
src/components/chat/AgentChat.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type FormEvent,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { Loader2, Send, Wrench } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getToolTraceSummary,
|
||||
type AgentChatMessage,
|
||||
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[] }) {
|
||||
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" />
|
||||
{getToolTraceSummary(toolTrace)}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{toolTrace.map((tool, toolIndex) => (
|
||||
<div key={`${tool.name}-${toolIndex}`} className="rounded-md bg-background/80 p-2 text-xs">
|
||||
<p className="font-medium text-foreground">{tool.name}</p>
|
||||
<ToolTraceLine label="Eingabe" value={tool.inputSummary} />
|
||||
<ToolTraceLine label="Ergebnis" value={tool.resultSummary} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolTraceLine({ label, value }: { label: ReactNode; value: ReactNode }) {
|
||||
return (
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{label}: </span>
|
||||
{value}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentThinkingIndicator() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Denk mit der KI nach...
|
||||
</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>
|
||||
);
|
||||
}
|
||||
17
src/components/chat/agentChatModel.ts
Normal file
17
src/components/chat/agentChatModel.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type AgentToolTrace = {
|
||||
name: string;
|
||||
inputSummary: string;
|
||||
resultSummary: string;
|
||||
};
|
||||
|
||||
export type AgentChatMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
toolTrace?: AgentToolTrace[];
|
||||
};
|
||||
|
||||
export function getToolTraceSummary(toolTrace: AgentToolTrace[] | undefined) {
|
||||
if (!toolTrace || toolTrace.length === 0) return "Keine Werkzeuge";
|
||||
return `${toolTrace.length} ${toolTrace.length === 1 ? "Werkzeug" : "Werkzeuge"} verwendet`;
|
||||
}
|
||||
Reference in New Issue
Block a user