Files
copilotkit--copilotkit/examples/showcases/deep-agents/src/components/ToolCard.tsx
T
2026-07-13 12:58:18 +08:00

436 lines
13 KiB
TypeScript

"use client";
import { useState } from "react";
import {
ChevronDown,
Pencil,
ClipboardList,
Search,
Save,
BookOpen,
Check,
} from "lucide-react";
/**
* ToolCard - Generative UI for tool call rendering in chat.
*
* Two rendering modes:
* - SpecializedToolCard: Emoji-based cards for known tools with result previews
* - DefaultToolCard: Generic JSON display for unknown tools
*
* Result structures expected from backend:
* - internet_search: Array<{url, title, content, raw_content}>
* - write_todos: { todos: Array<{id, content, status}> }
* - write_file: just args (path, content) - result is confirmation
* - task: completion message
*/
interface ToolCardProps {
name: string;
status: "inProgress" | "executing" | "complete";
args: Record<string, unknown>;
result?: unknown;
}
// Tool configuration mapping
const TOOL_CONFIG: Record<
string,
{
icon: React.ComponentType<{
size?: number;
strokeWidth?: number;
className?: string;
style?: React.CSSProperties;
}>;
getDisplayText: (args: Record<string, unknown>) => string;
getResultSummary?: (
result: unknown,
args: Record<string, unknown>,
) => string | null;
}
> = {
write_todos: {
icon: Pencil,
getDisplayText: () => "Updating research plan...",
// Args contains the todos array (result is a Command with ToolMessage string)
getResultSummary: (result, args) => {
const todos = (args as { todos?: unknown[] })?.todos;
if (Array.isArray(todos)) {
return `${todos.length} todo${todos.length !== 1 ? "s" : ""} updated`;
}
return null;
},
},
read_todos: {
icon: ClipboardList,
getDisplayText: () => "Checking research plan...",
getResultSummary: (result) => {
const todos = (result as { todos?: unknown[] })?.todos;
if (Array.isArray(todos)) {
return `${todos.length} todo${todos.length !== 1 ? "s" : ""} found`;
}
return null;
},
},
research: {
icon: Search,
getDisplayText: (args) =>
`Researching: ${((args.query as string) || "...").slice(0, 50)}${(args.query as string)?.length > 50 ? "..." : ""}`,
// Result is now a dict with summary and sources
getResultSummary: (result) => {
if (result && typeof result === "object" && "sources" in result) {
const { sources } = result as { summary: string; sources: unknown[] };
return `Found ${sources.length} source${sources.length !== 1 ? "s" : ""}`;
}
return "Research complete";
},
},
write_file: {
icon: Save,
getDisplayText: (args) => {
const path = args.path as string | undefined;
const filename =
path?.split("/").pop() || (args.filename as string | undefined);
return `Writing: ${filename || "file"}`;
},
// Show first line preview from args (content is in args, not result)
getResultSummary: (_result, args) => {
const content = args.content as string | undefined;
if (content) {
const firstLine = content.split("\n")[0].slice(0, 50);
return firstLine + (content.length > 50 ? "..." : "");
}
return "File written";
},
},
read_file: {
icon: BookOpen,
getDisplayText: (args) => {
const path = args.path as string | undefined;
const filename =
path?.split("/").pop() || (args.filename as string | undefined);
return `Reading: ${filename || "file"}`;
},
getResultSummary: (result) => {
const content = (result as { content?: string })?.content;
if (content && typeof content === "string") {
const preview = content.slice(0, 50);
return preview + (content.length > 50 ? "..." : "");
}
return null;
},
},
};
export function ToolCard({ name, status, args, result }: ToolCardProps) {
const config = TOOL_CONFIG[name];
if (config) {
return (
<SpecializedToolCard
name={name}
status={status}
args={args}
result={result}
config={config}
/>
);
}
return (
<DefaultToolCard name={name} status={status} args={args} result={result} />
);
}
interface SpecializedToolCardProps extends ToolCardProps {
config: {
icon: React.ComponentType<{
size?: number;
strokeWidth?: number;
className?: string;
style?: React.CSSProperties;
}>;
getDisplayText: (args: Record<string, unknown>) => string;
getResultSummary?: (
result: unknown,
args: Record<string, unknown>,
) => string | null;
};
}
function SpecializedToolCard({
name,
status,
args,
result,
config,
}: SpecializedToolCardProps) {
const [expanded, setExpanded] = useState(false);
const isComplete = status === "complete";
const isExecuting = status === "inProgress" || status === "executing";
// Get result summary for completed tools
const resultSummary =
isComplete && config.getResultSummary
? config.getResultSummary(result, args)
: null;
// Determine if this tool has expandable content
const hasExpandableContent =
isComplete && (name === "research" || name === "write_todos");
return (
<div
className={`
glass-subtle
transition-all duration-200
${isComplete ? "opacity-80" : ""}
${hasExpandableContent ? "cursor-pointer" : ""}
`}
style={{
padding: "var(--space-4)",
marginBottom: "var(--space-2)",
}}
onClick={hasExpandableContent ? () => setExpanded(!expanded) : undefined}
>
<div className="flex items-center" style={{ gap: "var(--space-3)" }}>
<div
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{
background: isComplete
? "rgba(21, 128, 61, 0.1)"
: "rgba(217, 119, 6, 0.1)",
}}
>
{isComplete ? (
<Check
size={16}
strokeWidth={2}
style={{ color: "var(--color-success)" }}
/>
) : (
<config.icon
size={16}
strokeWidth={2}
className={isExecuting ? "animate-spin-slow" : ""}
style={{ color: "var(--color-accent)" }}
/>
)}
</div>
<div className="flex-1 min-w-0">
<p
className={`
text-sm font-medium
${
isComplete
? "text-[var(--color-text-tertiary)]"
: "text-[var(--color-text-primary)]"
}
`}
>
{config.getDisplayText(args)}
</p>
{/* Result summary shown below the display text when complete */}
{resultSummary && (
<p
className="text-xs mt-0.5"
style={{ color: "var(--color-success)" }}
>
{resultSummary}
</p>
)}
</div>
{/* Expand indicator for expandable tools */}
{hasExpandableContent && (
<ChevronDown
className={`w-4 h-4 text-[var(--color-text-tertiary)] transition-transform ${expanded ? "rotate-180" : ""}`}
/>
)}
</div>
{/* Expanded details section */}
{expanded && isComplete && (
<div
style={{ marginTop: "var(--space-3)", paddingTop: "var(--space-3)" }}
className="border-t border-[var(--color-border-glass)]"
>
<ExpandedDetails name={name} result={result} args={args} />
</div>
)}
</div>
);
}
/**
* Renders expanded details based on tool type.
* Each tool has its own structured view of the result.
*/
function ExpandedDetails({
name,
result,
args,
}: {
name: string;
result: unknown;
args: Record<string, unknown>;
}) {
// research: show the full prose summary
if (name === "research") {
// Extract summary from object or use string directly
const summary =
typeof result === "object" && result && "summary" in result
? (result as { summary: string; sources: unknown[] }).summary
: typeof result === "string"
? result
: "";
if (!summary)
return (
<p className="text-xs text-[var(--color-text-tertiary)]">No findings</p>
);
return (
<div className="space-y-2">
<p className="text-xs font-medium text-[var(--color-text-tertiary)]">
Query:
</p>
<p className="text-xs text-[var(--color-text-secondary)]">
{(args.query as string) || "..."}
</p>
<p className="text-xs font-medium text-[var(--color-text-tertiary)] mt-2">
Findings:
</p>
<p className="text-sm text-[var(--color-text-primary)] whitespace-pre-wrap">
{summary}
</p>
</div>
);
}
// write_todos: show todo list (from args, not result)
if (name === "write_todos") {
const todos = (
args as { todos?: Array<{ id: string; content: string; status: string }> }
)?.todos;
if (!todos?.length)
return (
<p className="text-xs text-[var(--color-text-tertiary)]">No todos</p>
);
return (
<div className="space-y-1 max-h-40 overflow-y-auto">
{todos.map((todo, i) => (
<div key={todo.id || i} className="flex items-start gap-2 text-xs">
<span
className="mt-0.5"
style={{
color:
todo.status === "completed"
? "var(--color-success)"
: todo.status === "in_progress"
? "var(--color-accent-dark)"
: "var(--color-text-tertiary)",
}}
>
{todo.status === "completed"
? "✓"
: todo.status === "in_progress"
? "●"
: "○"}
</span>
<span
className={
todo.status === "completed"
? "line-through text-[var(--color-text-tertiary)]"
: ""
}
>
{todo.content}
</span>
</div>
))}
</div>
);
}
// Fallback: JSON display
return (
<pre className="text-xs bg-[var(--color-container)] p-2 rounded-md overflow-auto max-h-32 border border-[var(--color-border)]">
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
</pre>
);
}
function DefaultToolCard({ name, status, args, result }: ToolCardProps) {
const [expanded, setExpanded] = useState(false);
const isComplete = status === "complete";
return (
<div className="glass-subtle p-3 my-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={`
w-8 h-8 rounded-lg flex items-center justify-center
text-lg
${
isComplete
? "bg-[var(--color-mint)]/20"
: "bg-[var(--color-lilac)]/20"
}
`}
>
{isComplete ? "✓" : "⚙️"}
</div>
<div className="flex items-center gap-2">
<code className="text-sm text-[var(--color-text-primary)]">
{name}
</code>
<span
className={`
text-xs px-2 py-0.5 rounded-full
${
isComplete
? "bg-[var(--color-mint)]/20 text-[var(--color-mint-dark)]"
: "bg-[var(--color-lilac)]/20 text-[var(--color-lilac-dark)]"
}
`}
>
{status}
</span>
</div>
</div>
<button
onClick={() => setExpanded(!expanded)}
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
>
<ChevronDown
className={`w-4 h-4 transition-transform ${expanded ? "rotate-180" : ""}`}
/>
</button>
</div>
{expanded && (
<div className="mt-3 space-y-2">
<div>
<p className="text-xs text-[var(--color-text-tertiary)] mb-1">
Arguments:
</p>
<pre className="text-xs bg-[var(--color-container)] p-2 rounded-md overflow-auto max-h-32 border border-[var(--color-border)]">
{JSON.stringify(args, null, 2)}
</pre>
</div>
{result !== undefined && result !== null && (
<div>
<p className="text-xs text-[var(--color-text-tertiary)] mb-1">
Result:
</p>
<pre className="text-xs bg-[var(--color-container)] p-2 rounded-md overflow-auto max-h-32 border border-[var(--color-border)]">
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
}