Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
DeerFlow frontend visual overhaul: - Card-style input box (white rounded card, textarea top, actions bottom) - Dropdown mode selector (闪速/思考/Pro/Ultra with icons+descriptions) - Colored quick-action chips (小惊喜/写作/研究/收集/学习) - Minimal top bar (title + token count + export) - Warm gray color system (#faf9f6 bg, #f5f4f1 sidebar, #e8e6e1 border) - DeerFlow-style sidebar (新对话/对话/智能体 nav) - Reasoning block, tool call chain, task progress visualization - Streaming text, model selector, suggestion chips components - Resizable artifact panel with drag handle - Virtualized message list for 100+ messages Bug fixes: - Stream hang: GatewayClient onclose code 1000 now calls onComplete - WebView2 textarea border: CSS !important override for UA styles - Gateway stream event handling (response/phase/tool_call types) Intelligence client: - Unified client with fallback drivers (compactor/heartbeat/identity/memory/reflection) - Gateway API types and type conversions
256 lines
7.9 KiB
TypeScript
256 lines
7.9 KiB
TypeScript
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<string, typeof Search> = {
|
|
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<string, string> = {
|
|
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 (
|
|
<div className={`my-1.5 ${className}`}>
|
|
{/* Collapsed indicator */}
|
|
{hiddenCount > 0 && !showAll && (
|
|
<button
|
|
onClick={() => setShowAll(true)}
|
|
className="flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500 hover:text-orange-500 dark:hover:text-orange-400 transition-colors mb-1.5 ml-0.5 group"
|
|
>
|
|
<ChevronDown className="w-3 h-3 group-hover:text-orange-500 dark:group-hover:text-orange-400 transition-transform" />
|
|
<span>查看其他 {hiddenCount} 个步骤</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Steps list */}
|
|
<div className="space-y-0.5">
|
|
{visibleSteps.map((step, idx) => {
|
|
const globalIdx = showAll ? idx : hiddenCount + idx;
|
|
const isActive = globalIdx === activeStepIdx;
|
|
const isLast = globalIdx === steps.length - 1;
|
|
|
|
return (
|
|
<ToolStepRow
|
|
key={step.id}
|
|
step={step}
|
|
isActive={isActive}
|
|
showConnector={!isLast}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 (
|
|
<div>
|
|
<button
|
|
onClick={() => setExpanded(!expanded)}
|
|
className={`
|
|
flex items-center gap-2 w-full text-left px-2 py-1 rounded-md transition-colors
|
|
${isActive
|
|
? 'bg-orange-50 dark:bg-orange-900/15'
|
|
: 'hover:bg-gray-50 dark:hover:bg-gray-800/60'
|
|
}
|
|
`}
|
|
>
|
|
{/* Status indicator */}
|
|
{isRunning ? (
|
|
<Loader2 className="w-3.5 h-3.5 text-orange-500 animate-spin flex-shrink-0" />
|
|
) : isError ? (
|
|
<XCircle className="w-3.5 h-3.5 text-red-400 flex-shrink-0" />
|
|
) : (
|
|
<CheckCircle2 className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />
|
|
)}
|
|
|
|
{/* Tool icon */}
|
|
<Icon className={`w-3.5 h-3.5 flex-shrink-0 ${isActive ? 'text-orange-500' : 'text-gray-400 dark:text-gray-500'}`} />
|
|
|
|
{/* Tool label */}
|
|
<span className={`text-xs font-medium ${isActive ? 'text-orange-600 dark:text-orange-400' : 'text-gray-600 dark:text-gray-400'}`}>
|
|
{label}
|
|
</span>
|
|
|
|
{/* Input preview */}
|
|
{step.input && !expanded && (
|
|
<span className="text-[11px] text-gray-400 dark:text-gray-500 truncate flex-1">
|
|
{truncate(step.input, 60)}
|
|
</span>
|
|
)}
|
|
|
|
{/* Expand chevron */}
|
|
{(step.input || step.output) && (
|
|
<motion.span
|
|
animate={{ rotate: expanded ? 180 : 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="ml-auto flex-shrink-0"
|
|
>
|
|
<ChevronDown className="w-3 h-3 text-gray-400" />
|
|
</motion.span>
|
|
)}
|
|
</button>
|
|
|
|
{/* Expanded details */}
|
|
<AnimatePresence>
|
|
{expanded && (step.input || step.output) && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="ml-9 mr-2 mb-1 space-y-1">
|
|
{step.input && (
|
|
<div className="text-[11px] text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/80 rounded px-2 py-1 font-mono overflow-x-auto">
|
|
{truncate(step.input, 500)}
|
|
</div>
|
|
)}
|
|
{step.output && (
|
|
<div className={`text-[11px] font-mono rounded px-2 py-1 overflow-x-auto ${isError ? 'text-red-500 bg-red-50 dark:bg-red-900/10' : 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/10'}`}>
|
|
{truncate(step.output, 500)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Vertical connector */}
|
|
{showConnector && (
|
|
<div className="ml-[18px] w-px h-1.5 bg-gray-200 dark:bg-gray-700" />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|