import { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Search, Globe, Terminal, FileText, FilePlus, FolderOpen, FileEdit, HelpCircle, Code2, Wrench, ChevronDown, Loader2, CheckCircle2, XCircle, } from 'lucide-react'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface ToolCallStep { id: string; toolName: string; input?: string; output?: string; status: 'running' | 'completed' | 'error'; timestamp: Date; } interface ToolCallChainProps { steps: ToolCallStep[]; isStreaming?: boolean; className?: string; } // --------------------------------------------------------------------------- // Icon mapping — each tool type gets a distinctive icon // --------------------------------------------------------------------------- const TOOL_ICONS: Record = { web_search: Search, web_fetch: Globe, bash: Terminal, read_file: FileText, write_file: FilePlus, ls: FolderOpen, str_replace: FileEdit, ask_clarification: HelpCircle, code_execute: Code2, // Default fallback }; const TOOL_LABELS: Record = { web_search: '搜索', web_fetch: '获取网页', bash: '执行命令', read_file: '读取文件', write_file: '写入文件', ls: '列出目录', str_replace: '编辑文件', ask_clarification: '澄清问题', code_execute: '执行代码', }; function getToolIcon(toolName: string): typeof Search { const lower = toolName.toLowerCase(); for (const [key, icon] of Object.entries(TOOL_ICONS)) { if (lower.includes(key)) return icon; } return Wrench; } function getToolLabel(toolName: string): string { const lower = toolName.toLowerCase(); for (const [key, label] of Object.entries(TOOL_LABELS)) { if (lower.includes(key)) return label; } return toolName; } // --------------------------------------------------------------------------- // Truncate helper // --------------------------------------------------------------------------- function truncate(str: string, maxLen: number): string { if (!str) return ''; const oneLine = str.replace(/\n/g, ' ').trim(); return oneLine.length > maxLen ? oneLine.slice(0, maxLen) + '...' : oneLine; } // --------------------------------------------------------------------------- // ToolCallChain — main component // --------------------------------------------------------------------------- /** * Collapsible tool-call step chain. * * Inspired by DeerFlow's message-group.tsx convertToSteps(): * - Each tool call shows a type-specific icon + label * - The latest 2 steps are expanded by default * - Earlier steps collapse into "查看其他 N 个步骤" * - Running steps show a spinner; completed show a checkmark */ const DEFAULT_EXPANDED_COUNT = 2; export function ToolCallChain({ steps, isStreaming = false, className = '' }: ToolCallChainProps) { const [showAll, setShowAll] = useState(false); if (steps.length === 0) return null; const visibleSteps = showAll ? steps : steps.slice(-DEFAULT_EXPANDED_COUNT); const hiddenCount = steps.length - visibleSteps.length; // The last step is "active" during streaming const activeStepIdx = isStreaming ? steps.length - 1 : -1; return (
{/* Collapsed indicator */} {hiddenCount > 0 && !showAll && ( )} {/* Steps list */}
{visibleSteps.map((step, idx) => { const globalIdx = showAll ? idx : hiddenCount + idx; const isActive = globalIdx === activeStepIdx; const isLast = globalIdx === steps.length - 1; return ( ); })}
); } // --------------------------------------------------------------------------- // ToolStepRow — a single step in the chain // --------------------------------------------------------------------------- interface ToolStepRowProps { step: ToolCallStep; isActive: boolean; showConnector: boolean; } function ToolStepRow({ step, isActive, showConnector }: ToolStepRowProps) { const [expanded, setExpanded] = useState(false); const Icon = getToolIcon(step.toolName); const label = getToolLabel(step.toolName); const isRunning = step.status === 'running'; const isError = step.status === 'error'; return (
{/* Expanded details */} {expanded && (step.input || step.output) && (
{step.input && (
{truncate(step.input, 500)}
)} {step.output && (
{truncate(step.output, 500)}
)}
)}
{/* Vertical connector */} {showConnector && (
)}
); }