Files
zclaw_openfang/desktop/src/components/ai/ToolCallChain.tsx
iven 73ff5e8c5e
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
feat(desktop): DeerFlow visual redesign + stream hang fix + intelligence client
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
2026-04-01 22:03:07 +08:00

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>
);
}