feat(desktop): DeerFlow visual redesign + stream hang fix + intelligence client
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
This commit is contained in:
iven
2026-04-01 22:03:07 +08:00
parent e3b93ff96d
commit 73ff5e8c5e
43 changed files with 4817 additions and 905 deletions

View File

@@ -0,0 +1,302 @@
import { useState, useMemo } from 'react';
import {
FileText,
FileCode2,
Table2,
Image as ImageIcon,
Download,
Copy,
ChevronLeft,
File,
} from 'lucide-react';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ArtifactFile {
id: string;
name: string;
type: 'markdown' | 'code' | 'table' | 'image' | 'text';
content: string;
language?: string;
createdAt: Date;
sourceStepId?: string; // Links to ToolCallStep that created this artifact
}
interface ArtifactPanelProps {
artifacts: ArtifactFile[];
selectedId?: string | null;
onSelect: (id: string) => void;
onClose?: () => void;
className?: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getFileIcon(type: ArtifactFile['type']) {
switch (type) {
case 'markdown': return FileText;
case 'code': return FileCode2;
case 'table': return Table2;
case 'image': return ImageIcon;
default: return File;
}
}
function getTypeLabel(type: ArtifactFile['type']): string {
switch (type) {
case 'markdown': return 'MD';
case 'code': return 'CODE';
case 'table': return 'TABLE';
case 'image': return 'IMG';
default: return 'TXT';
}
}
function getTypeColor(type: ArtifactFile['type']): string {
switch (type) {
case 'markdown': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300';
case 'code': return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300';
case 'table': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300';
case 'image': return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300';
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
}
}
// ---------------------------------------------------------------------------
// ArtifactPanel
// ---------------------------------------------------------------------------
export function ArtifactPanel({
artifacts,
selectedId,
onSelect,
onClose: _onClose,
className = '',
}: ArtifactPanelProps) {
const [viewMode, setViewMode] = useState<'preview' | 'code'>('preview');
const selected = useMemo(
() => artifacts.find((a) => a.id === selectedId),
[artifacts, selectedId]
);
// List view when no artifact is selected
if (!selected) {
return (
<div className={`h-full flex flex-col ${className}`}>
<div className="p-4 flex-1 overflow-y-auto custom-scrollbar">
{artifacts.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-400 dark:text-gray-500">
<FileText className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm"></p>
<p className="text-xs mt-1">Agent </p>
</div>
) : (
<div className="space-y-2">
{artifacts.map((artifact) => {
const Icon = getFileIcon(artifact.type);
return (
<button
key={artifact.id}
onClick={() => onSelect(artifact.id)}
className="w-full flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors text-left group"
>
<Icon className="w-5 h-5 text-gray-400 flex-shrink-0 group-hover:text-orange-500 transition-colors" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 dark:text-gray-200 truncate">
{artifact.name}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${getTypeColor(artifact.type)}`}>
{getTypeLabel(artifact.type)}
</span>
<span className="text-[11px] text-gray-400 dark:text-gray-500">
{new Date(artifact.createdAt).toLocaleTimeString()}
</span>
</div>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
);
}
// Detail view
const Icon = getFileIcon(selected.type);
return (
<div className={`h-full flex flex-col ${className}`}>
{/* File header */}
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2 flex-shrink-0">
<button
onClick={() => onSelect('')}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
title="返回文件列表"
>
<ChevronLeft className="w-4 h-4" />
</button>
<Icon className="w-4 h-4 text-orange-500 flex-shrink-0" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-200 truncate flex-1">
{selected.name}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${getTypeColor(selected.type)}`}>
{getTypeLabel(selected.type)}
</span>
</div>
{/* View mode toggle */}
<div className="px-4 py-1.5 border-b border-gray-100 dark:border-gray-800 flex items-center gap-1 flex-shrink-0">
<button
onClick={() => setViewMode('preview')}
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
viewMode === 'preview'
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
</button>
<button
onClick={() => setViewMode('code')}
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
viewMode === 'code'
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
</button>
</div>
{/* Content area */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-4">
{viewMode === 'preview' ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
{selected.type === 'markdown' ? (
<MarkdownPreview content={selected.content} />
) : selected.type === 'code' ? (
<pre className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 text-xs font-mono overflow-x-auto text-gray-700 dark:text-gray-200">
{selected.content}
</pre>
) : (
<pre className="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-200">
{selected.content}
</pre>
)}
</div>
) : (
<pre className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 text-xs font-mono overflow-x-auto text-gray-700 dark:text-gray-200 leading-relaxed">
{selected.content}
</pre>
)}
</div>
{/* Action bar */}
<div className="px-4 py-2 border-t border-gray-200 dark:border-gray-700 flex items-center gap-2 flex-shrink-0">
<ActionButton
icon={<Copy className="w-3.5 h-3.5" />}
label="复制"
onClick={() => navigator.clipboard.writeText(selected.content)}
/>
<ActionButton
icon={<Download className="w-3.5 h-3.5" />}
label="下载"
onClick={() => downloadArtifact(selected)}
/>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// ActionButton
// ---------------------------------------------------------------------------
function ActionButton({ icon, label, onClick }: { icon: React.ReactNode; label: string; onClick: () => void }) {
const [copied, setCopied] = useState(false);
const handleClick = () => {
onClick();
if (label === '复制') {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
};
return (
<button
onClick={handleClick}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
{copied ? <span className="text-green-500 text-xs"></span> : icon}
{!copied && label}
</button>
);
}
// ---------------------------------------------------------------------------
// Simple Markdown preview (no external deps)
// ---------------------------------------------------------------------------
function MarkdownPreview({ content }: { content: string }) {
// Basic markdown rendering: headings, bold, code blocks, lists
const lines = content.split('\n');
return (
<div className="space-y-2">
{lines.map((line, i) => {
// Heading
if (line.startsWith('### ')) {
return <h3 key={i} className="text-sm font-bold text-gray-800 dark:text-gray-100 mt-3">{line.slice(4)}</h3>;
}
if (line.startsWith('## ')) {
return <h2 key={i} className="text-base font-bold text-gray-800 dark:text-gray-100 mt-4">{line.slice(3)}</h2>;
}
if (line.startsWith('# ')) {
return <h1 key={i} className="text-lg font-bold text-gray-800 dark:text-gray-100">{line.slice(2)}</h1>;
}
// Code block (simplified)
if (line.startsWith('```')) return null;
// List item
if (line.startsWith('- ') || line.startsWith('* ')) {
return <li key={i} className="text-sm text-gray-700 dark:text-gray-300 ml-4">{renderInline(line.slice(2))}</li>;
}
// Empty line
if (!line.trim()) return <div key={i} className="h-2" />;
// Regular paragraph
return <p key={i} className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{renderInline(line)}</p>;
})}
</div>
);
}
function renderInline(text: string): React.ReactNode {
// Bold
const parts = text.split(/\*\*(.*?)\*\*/g);
return parts.map((part, i) =>
i % 2 === 1 ? <strong key={i} className="font-semibold">{part}</strong> : part
);
}
// ---------------------------------------------------------------------------
// Download helper
// ---------------------------------------------------------------------------
function downloadArtifact(artifact: ArtifactFile) {
const blob = new Blob([artifact.content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = artifact.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,133 @@
import { useState, useRef, useEffect } from 'react';
import { Zap, Lightbulb, GraduationCap, Rocket, Check } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
/**
* Chat interaction mode selector — DeerFlow-style dropdown.
*
* A single trigger button in the input bar that opens an upward dropdown
* showing each mode with icon, title, description, and checkmark.
*/
export type ChatModeType = 'flash' | 'thinking' | 'pro' | 'ultra';
export interface ChatModeConfig {
thinking_enabled: boolean;
reasoning_effort?: 'low' | 'medium' | 'high';
plan_mode?: boolean;
subagent_enabled?: boolean;
}
export const CHAT_MODES: Record<ChatModeType, { label: string; icon: typeof Zap; config: ChatModeConfig; description: string }> = {
flash: {
label: '闪速',
icon: Zap,
config: { thinking_enabled: false },
description: '快速且高效的完成任务,但可能不够精准',
},
thinking: {
label: '思考',
icon: Lightbulb,
config: { thinking_enabled: true, reasoning_effort: 'low' },
description: '启用推理,低强度思考',
},
pro: {
label: 'Pro',
icon: GraduationCap,
config: { thinking_enabled: true, reasoning_effort: 'medium', plan_mode: true },
description: '思考、计划再执行,获得更精准的结果,可能需要更多时间',
},
ultra: {
label: 'Ultra',
icon: Rocket,
config: { thinking_enabled: true, reasoning_effort: 'high', plan_mode: true, subagent_enabled: true },
description: '继承自 Pro 模式,可调用子代理分工协作,适合复杂多步骤任务,能力最强',
},
};
interface ChatModeProps {
value: ChatModeType;
onChange: (mode: ChatModeType) => void;
disabled?: boolean;
}
export function ChatMode({ value, onChange, disabled = false }: ChatModeProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const current = CHAT_MODES[value];
const Icon = current.icon;
return (
<div ref={containerRef} className="relative">
{/* Trigger button */}
<button
onClick={() => { if (!disabled) setOpen(!open); }}
disabled={disabled}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
>
<Icon className="w-3.5 h-3.5" />
<span>{current.label}</span>
</button>
{/* Dropdown — pops up above the input bar */}
<AnimatePresence>
{open && !disabled && (
<motion.div
initial={{ opacity: 0, y: 4, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 4, scale: 0.95 }}
transition={{ duration: 0.12 }}
className="absolute bottom-full left-0 mb-2 w-80 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 py-2 z-50"
>
<div className="px-3 py-2 text-xs text-gray-400 font-medium"></div>
<div className="space-y-1">
{(Object.entries(CHAT_MODES) as [ChatModeType, typeof CHAT_MODES.flash][]).map(([mode, def]) => {
const ModeIcon = def.icon;
const isActive = value === mode;
return (
<button
key={mode}
onClick={() => {
onChange(mode);
setOpen(false);
}}
className="w-full text-left px-3 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 flex items-start gap-3 transition-colors"
>
<div className="mt-0.5">
<ModeIcon className={`w-4 h-4 ${isActive ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'}`} />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className={`font-medium text-sm ${isActive ? 'text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300'}`}>
{def.label}
</span>
{isActive && (
<Check className="w-3.5 h-3.5 text-gray-900 dark:text-white" />
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{def.description}</p>
</div>
</button>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { useRef, useEffect, useState, createContext, useContext, useMemo, type ReactNode } from 'react';
// ---------------------------------------------------------------------------
// ConversationContext — shared state for child ai-elements components
// ---------------------------------------------------------------------------
interface ConversationContextValue {
isStreaming: boolean;
setIsStreaming: (v: boolean) => void;
messages: unknown[];
setMessages: (msgs: unknown[]) => void;
}
const ConversationContext = createContext<ConversationContextValue | null>(null);
export function useConversationContext() {
const ctx = useContext(ConversationContext);
if (!ctx) {
throw new Error('useConversationContext must be used within ConversationProvider');
}
return ctx;
}
export function ConversationProvider({ children }: { children: ReactNode }) {
const [isStreaming, setIsStreaming] = useState(false);
const [messages, setMessages] = useState<unknown[]>([]);
const value = useMemo(
() => ({ isStreaming, setIsStreaming, messages, setMessages }),
[isStreaming, messages],
);
return (
<ConversationContext.Provider value={value}>
{children}
</ConversationContext.Provider>
);
}
// ---------------------------------------------------------------------------
// Conversation container with auto-stick-to-bottom scroll behavior
// ---------------------------------------------------------------------------
/**
* Conversation container with auto-stick-to-bottom scroll behavior.
*
* Inspired by DeerFlow's use-stick-to-bottom pattern:
* - Stays pinned to bottom during streaming
* - Remembers user's scroll position when they scroll up
* - Auto-scrolls back to bottom on new content when near the bottom
*/
interface ConversationProps {
children: ReactNode;
className?: string;
}
const SCROLL_THRESHOLD = 80; // px from bottom to consider "at bottom"
export function Conversation({ children, className = '' }: ConversationProps) {
const containerRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
const observerRef = useRef<ResizeObserver | null>(null);
// Track whether user is near the bottom
const handleScroll = () => {
const el = containerRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
isAtBottomRef.current = distanceFromBottom < SCROLL_THRESHOLD;
};
// Auto-scroll to bottom when content changes and user is at bottom
useEffect(() => {
const el = containerRef.current;
if (!el) return;
observerRef.current = new ResizeObserver(() => {
if (isAtBottomRef.current) {
el.scrollTop = el.scrollHeight;
}
});
observerRef.current.observe(el);
return () => {
observerRef.current?.disconnect();
};
}, []);
// Also observe child list changes (new messages)
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const mutationObserver = new MutationObserver(() => {
if (isAtBottomRef.current) {
el.scrollTop = el.scrollHeight;
}
});
mutationObserver.observe(el, { childList: true, subtree: true });
return () => {
mutationObserver.disconnect();
};
}, []);
return (
<div
ref={containerRef}
onScroll={handleScroll}
className={`overflow-y-auto custom-scrollbar ${className}`}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,140 @@
import { useState, useRef, useEffect } from 'react';
import { ChevronDown, Check } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
/**
* Model selector dropdown.
*
* Inspired by DeerFlow's model-selector.tsx:
* - Searchable dropdown with keyboard navigation
* - Shows model provider badge
* - Compact design that fits in the input area
*/
interface ModelOption {
id: string;
name: string;
provider?: string;
}
interface ModelSelectorProps {
models: ModelOption[];
currentModel: string;
onSelect: (modelId: string) => void;
disabled?: boolean;
}
export function ModelSelector({
models,
currentModel,
onSelect,
disabled = false,
}: ModelSelectorProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const selectedModel = models.find(m => m.id === currentModel);
const filteredModels = search
? models.filter(m =>
m.name.toLowerCase().includes(search.toLowerCase()) ||
(m.provider && m.provider.toLowerCase().includes(search.toLowerCase()))
)
: models;
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch('');
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// Focus search on open
useEffect(() => {
if (open && inputRef.current) {
inputRef.current.focus();
}
}, [open]);
return (
<div ref={containerRef} className="relative">
<button
onClick={() => { if (!disabled) setOpen(!open); }}
disabled={disabled}
className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 px-2 py-1 rounded-md transition-colors disabled:opacity-50"
aria-expanded={open}
aria-haspopup="listbox"
>
<span className="max-w-[120px] truncate">{selectedModel?.name || currentModel}</span>
<ChevronDown className={`w-3 h-3 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 4, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 4, scale: 0.95 }}
transition={{ duration: 0.12 }}
className="absolute bottom-full right-0 mb-2 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20 overflow-hidden"
>
{/* Search */}
<div className="p-2 border-b border-gray-100 dark:border-gray-700">
<input
ref={inputRef}
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="搜索模型..."
className="w-full bg-transparent text-xs text-gray-700 dark:text-gray-200 placeholder-gray-400 outline-none"
/>
</div>
{/* Model list */}
<div className="max-h-48 overflow-y-auto py-1" role="listbox">
{filteredModels.length > 0 ? (
filteredModels.map(model => (
<button
key={model.id}
onClick={() => {
onSelect(model.id);
setOpen(false);
setSearch('');
}}
role="option"
aria-selected={model.id === currentModel}
className={`
w-full text-left px-3 py-2 text-xs flex items-center justify-between gap-2 transition-colors
${model.id === currentModel
? 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}
`}
>
<div className="flex flex-col min-w-0">
<span className="truncate font-medium">{model.name}</span>
{model.provider && (
<span className="text-[10px] text-gray-400 dark:text-gray-500">{model.provider}</span>
)}
</div>
{model.id === currentModel && (
<Check className="w-3.5 h-3.5 flex-shrink-0" />
)}
</button>
))
) : (
<div className="px-3 py-2 text-xs text-gray-400"></div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,156 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronRight, Lightbulb } from 'lucide-react';
/**
* Collapsible reasoning/thinking block with timing display.
*
* Inspired by DeerFlow's reasoning display:
* - Shows elapsed time during streaming ("Thinking for 3s...")
* - Shows final time when complete ("Thought for 5 seconds")
* - Animated expand/collapse
* - Auto-collapses 1 second after streaming ends
*/
interface ReasoningBlockProps {
content: string;
isStreaming?: boolean;
defaultExpanded?: boolean;
/** Unix timestamp (ms) when thinking started, for elapsed time display */
startedAt?: number;
}
export function ReasoningBlock({
content,
isStreaming = false,
defaultExpanded = false,
startedAt,
}: ReasoningBlockProps) {
const [expanded, setExpanded] = useState(defaultExpanded || isStreaming);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
// Auto-expand when streaming starts
useEffect(() => {
if (isStreaming) setExpanded(true);
}, [isStreaming]);
// Auto-collapse 1 second after streaming ends
const [prevStreaming, setPrevStreaming] = useState(isStreaming);
useEffect(() => {
if (prevStreaming && !isStreaming && expanded) {
const timer = setTimeout(() => setExpanded(false), 1000);
return () => clearTimeout(timer);
}
setPrevStreaming(isStreaming);
}, [isStreaming, prevStreaming, expanded]);
// Timer for elapsed seconds display
useEffect(() => {
if (!isStreaming || !startedAt) return;
const interval = setInterval(() => {
setElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000));
}, 200);
return () => clearInterval(interval);
}, [isStreaming, startedAt]);
// Final duration (when streaming ends, calculate from startedAt to now)
const durationLabel = (() => {
if (!startedAt) return null;
if (isStreaming) {
return elapsedSeconds > 0 ? `已思考 ${elapsedSeconds}` : '思考中...';
}
// Streaming finished — show "Thought for N seconds"
const totalSec = Math.floor((Date.now() - startedAt) / 1000);
if (totalSec <= 0) return null;
return `思考了 ${totalSec}`;
})();
if (!content) return null;
return (
<div className="my-2">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors group w-full text-left"
aria-expanded={expanded}
>
<motion.span
animate={{ rotate: expanded ? 90 : 0 }}
transition={{ duration: 0.15 }}
>
<ChevronRight className="w-3.5 h-3.5" />
</motion.span>
<Lightbulb className="w-3.5 h-3.5 text-amber-500" />
<span className="font-medium"></span>
{durationLabel && !isStreaming && (
<span className="text-[11px] text-gray-400 dark:text-gray-500 ml-1">
{durationLabel}
</span>
)}
{isStreaming && (
<span className="flex gap-0.5 ml-1">
<span className="w-1 h-1 bg-amber-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1 h-1 bg-amber-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1 h-1 bg-amber-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
)}
</button>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="overflow-hidden"
>
<div className="mt-1.5 ml-5 pl-3 border-l-2 border-amber-300 dark:border-amber-700 text-xs text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-wrap">
{content}
{isStreaming && (
<span className="inline-block w-1 h-3 bg-amber-500 animate-pulse ml-0.5 align-text-bottom rounded-sm" />
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
/**
* Chain of thought step display.
* Shows individual reasoning steps with status indicators.
*/
interface ThoughtStep {
id: string;
content: string;
status: 'thinking' | 'done' | 'error';
}
interface ChainOfThoughtProps {
steps: ThoughtStep[];
className?: string;
}
export function ChainOfThought({ steps, className = '' }: ChainOfThoughtProps) {
return (
<div className={`ml-5 space-y-2 ${className}`}>
{steps.map((step) => (
<div key={step.id} className="flex items-start gap-2">
<div className="mt-1 flex-shrink-0">
{step.status === 'thinking' ? (
<span className="w-2 h-2 bg-amber-400 rounded-full animate-pulse" />
) : step.status === 'done' ? (
<span className="w-2 h-2 bg-green-500 rounded-full" />
) : (
<span className="w-2 h-2 bg-red-500 rounded-full" />
)}
</div>
<span className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
{step.content}
</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,136 @@
import { useCallback, type ReactNode } from 'react';
import { Group, Panel, Separator } from 'react-resizable-panels';
import { X, PanelRightOpen, PanelRightClose } from 'lucide-react';
/**
* Resizable dual-panel layout for chat + artifact/detail panel.
*
* Uses react-resizable-panels v4 API:
* - Left panel: Chat area (always visible)
* - Right panel: Artifact/detail viewer (collapsible)
* - Draggable resize handle between panels
* - Persisted panel sizes via localStorage
*/
interface ResizableChatLayoutProps {
chatPanel: ReactNode;
rightPanel?: ReactNode;
rightPanelTitle?: string;
rightPanelOpen?: boolean;
onRightPanelToggle?: (open: boolean) => void;
}
const STORAGE_KEY = 'zclaw-layout-panels';
const LEFT_PANEL_ID = 'chat-panel';
const RIGHT_PANEL_ID = 'detail-panel';
function loadPanelSizes(): { left: string; right: string } {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (parsed.left && parsed.right) {
return { left: parsed.left, right: parsed.right };
}
}
} catch { /* ignore */ }
return { left: '65%', right: '35%' };
}
function savePanelSizes(layout: Record<string, number>) {
try {
const left = layout[LEFT_PANEL_ID];
const right = layout[RIGHT_PANEL_ID];
if (left !== undefined && right !== undefined) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ left, right }));
}
} catch { /* ignore */ }
}
export function ResizableChatLayout({
chatPanel,
rightPanel,
rightPanelTitle = '详情',
rightPanelOpen = false,
onRightPanelToggle,
}: ResizableChatLayoutProps) {
const sizes = loadPanelSizes();
const handleToggle = useCallback(() => {
onRightPanelToggle?.(!rightPanelOpen);
}, [rightPanelOpen, onRightPanelToggle]);
if (!rightPanelOpen || !rightPanel) {
return (
<div className="flex-1 flex flex-col overflow-hidden relative">
{chatPanel}
<button
onClick={handleToggle}
className="absolute top-3 right-3 z-10 p-1.5 rounded-md bg-white/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-white dark:hover:bg-gray-800 transition-colors shadow-sm"
title="打开侧面板"
>
<PanelRightOpen className="w-4 h-4" />
</button>
</div>
);
}
return (
<div className="flex-1 flex flex-col overflow-hidden">
<Group
orientation="horizontal"
onLayoutChanged={(layout) => savePanelSizes(layout)}
>
{/* Left panel: Chat */}
<Panel
id={LEFT_PANEL_ID}
defaultSize={sizes.left}
minSize="40%"
>
<div className="h-full flex flex-col relative">
{chatPanel}
<button
onClick={handleToggle}
className="absolute top-3 right-3 z-10 p-1.5 rounded-md bg-white/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-white dark:hover:bg-gray-800 transition-colors shadow-sm"
title="关闭侧面板"
>
<PanelRightClose className="w-4 h-4" />
</button>
</div>
</Panel>
{/* Resize handle */}
<Separator className="w-1.5 flex items-center justify-center group cursor-col-resize hover:bg-orange-100 dark:hover:bg-orange-900/20 transition-colors">
<div className="w-0.5 h-8 rounded-full bg-gray-300 dark:bg-gray-600 group-hover:bg-orange-400 dark:group-hover:bg-orange-500 transition-colors" />
</Separator>
{/* Right panel: Artifact/Detail */}
<Panel
id={RIGHT_PANEL_ID}
defaultSize={sizes.right}
minSize="25%"
>
<div className="h-full flex flex-col bg-gray-50 dark:bg-gray-900 border-l border-gray-200 dark:border-gray-800">
{/* Panel header */}
<div className="h-12 flex items-center justify-between px-4 border-b border-gray-200 dark:border-gray-800 flex-shrink-0">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide">
{rightPanelTitle}
</span>
<button
onClick={handleToggle}
className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
title="关闭面板"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Panel content */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{rightPanel}
</div>
</div>
</Panel>
</Group>
</div>
);
}

View File

@@ -0,0 +1,136 @@
import { useMemo, useRef, useEffect, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
/**
* Streaming text with word-by-word reveal animation.
*
* Inspired by DeerFlow's Streamdown library:
* - Splits streaming text into "words" at whitespace and CJK boundaries
* - Each word gets a CSS fade-in animation
* - Historical messages render statically (no animation overhead)
*
* For non-streaming content, falls back to react-markdown for full
* markdown rendering including GFM tables, strikethrough, etc.
*/
interface StreamingTextProps {
content: string;
isStreaming: boolean;
className?: string;
/** Render as markdown for completed messages */
asMarkdown?: boolean;
}
// Split text into words at whitespace and CJK character boundaries
function splitIntoTokens(text: string): string[] {
const tokens: string[] = [];
let current = '';
for (const char of text) {
const code = char.codePointAt(0);
const isCJK = code && (
(code >= 0x4E00 && code <= 0x9FFF) || // CJK Unified Ideographs
(code >= 0x3400 && code <= 0x4DBF) || // CJK Extension A
(code >= 0x3000 && code <= 0x303F) || // CJK Symbols and Punctuation
(code >= 0xFF00 && code <= 0xFFEF) || // Fullwidth Forms
(code >= 0x2E80 && code <= 0x2EFF) || // CJK Radicals Supplement
(code >= 0xF900 && code <= 0xFAFF) // CJK Compatibility Ideographs
);
const isWhitespace = /\s/.test(char);
if (isCJK) {
// CJK chars are individual tokens
if (current) {
tokens.push(current);
current = '';
}
tokens.push(char);
} else if (isWhitespace) {
current += char;
} else {
current += char;
}
}
if (current) {
tokens.push(current);
}
return tokens;
}
export function StreamingText({
content,
isStreaming,
className = '',
asMarkdown = true,
}: StreamingTextProps) {
// For completed messages, use full markdown rendering
if (!isStreaming && asMarkdown) {
return (
<div className={`prose-sm prose-gray dark:prose-invert max-w-none ${className}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{content}
</ReactMarkdown>
</div>
);
}
// For streaming messages, use token-by-token animation
if (isStreaming && content) {
return (
<StreamingTokenText content={content} className={className} />
);
}
// Empty streaming - show nothing
return null;
}
/**
* Token-by-token streaming text with CSS animation.
* Each token (word/CJK char) fades in sequentially.
*/
function StreamingTokenText({ content, className }: { content: string; className: string }) {
const tokens = useMemo(() => splitIntoTokens(content), [content]);
const containerRef = useRef<HTMLSpanElement>(null);
const [visibleCount, setVisibleCount] = useState(0);
// Animate tokens appearing
useEffect(() => {
if (visibleCount >= tokens.length) return;
const remaining = tokens.length - visibleCount;
// Batch reveal: show multiple tokens per frame for fast streaming
const batchSize = Math.min(remaining, 3);
const timer = requestAnimationFrame(() => {
setVisibleCount(prev => Math.min(prev + batchSize, tokens.length));
});
return () => cancelAnimationFrame(timer);
}, [tokens.length, visibleCount]);
// Reset visible count when content changes significantly
useEffect(() => {
setVisibleCount(tokens.length);
}, [tokens.length]);
return (
<span ref={containerRef} className={`whitespace-pre-wrap ${className}`}>
{tokens.map((token, i) => (
<span
key={i}
className="streaming-token"
style={{
opacity: i < visibleCount ? 1 : 0,
transition: 'opacity 0.15s ease-in',
}}
>
{token}
</span>
))}
<span className="inline-block w-1.5 h-4 bg-orange-500 animate-pulse ml-0.5 align-text-bottom rounded-sm" />
</span>
);
}

View File

@@ -0,0 +1,48 @@
import { motion } from 'framer-motion';
/**
* Follow-up suggestion chips.
*
* Inspired by DeerFlow's suggestion.tsx:
* - Horizontal scrollable chip list
* - Click to fill input
* - Animated entrance
*/
interface SuggestionChipsProps {
suggestions: string[];
onSelect: (text: string) => void;
className?: string;
}
export function SuggestionChips({ suggestions, onSelect, className = '' }: SuggestionChipsProps) {
if (suggestions.length === 0) return null;
return (
<div className={`flex flex-wrap gap-2 ${className}`}>
{suggestions.map((text, index) => (
<motion.button
key={index}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05, duration: 0.2 }}
onClick={() => onSelect(text)}
className="
px-3 py-1.5 text-xs rounded-full
bg-gray-50 dark:bg-gray-800
border border-gray-200 dark:border-gray-700
text-gray-600 dark:text-gray-400
hover:bg-orange-50 dark:hover:bg-orange-900/20
hover:text-orange-700 dark:hover:text-orange-300
hover:border-orange-300 dark:hover:border-orange-600
transition-colors
max-w-[280px] truncate
"
title={text}
>
{text}
</motion.button>
))}
</div>
);
}

View File

@@ -0,0 +1,169 @@
import { useState, createContext, useContext, useCallback, type ReactNode } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronRight, CheckCircle2, XCircle, Loader2, Circle } from 'lucide-react';
// ---------------------------------------------------------------------------
// TaskContext — shared task state for sub-agent orchestration
// ---------------------------------------------------------------------------
interface TaskContextValue {
tasks: Subtask[];
updateTask: (id: string, updates: Partial<Subtask>) => void;
}
const TaskContext = createContext<TaskContextValue | null>(null);
export function useTaskContext() {
const ctx = useContext(TaskContext);
if (!ctx) {
throw new Error('useTaskContext must be used within TaskProvider');
}
return ctx;
}
export function TaskProvider({
children,
initialTasks = [],
}: {
children: ReactNode;
initialTasks?: Subtask[];
}) {
const [tasks, setTasks] = useState<Subtask[]>(initialTasks);
const updateTask = useCallback((id: string, updates: Partial<Subtask>) => {
setTasks(prev => prev.map(t => (t.id === id ? { ...t, ...updates } : t)));
}, []);
return (
<TaskContext.Provider value={{ tasks, updateTask }}>
{children}
</TaskContext.Provider>
);
}
/**
* Subtask progress display for sub-agent orchestration.
*
* Inspired by DeerFlow's SubtaskCard + ShineBorder pattern:
* - Shows task status with animated indicators
* - Collapsible details with thinking chain
* - Pulsing border animation for active tasks
* - Status icons: running (pulse), completed (green), failed (red)
*/
export interface Subtask {
id: string;
description: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
result?: string;
error?: string;
steps?: Array<{ content: string; status: 'thinking' | 'done' | 'error' }>;
}
interface TaskProgressProps {
tasks: Subtask[];
className?: string;
}
export function TaskProgress({ tasks, className = '' }: TaskProgressProps) {
if (tasks.length === 0) return null;
return (
<div className={`space-y-2 ${className}`}>
{tasks.map(task => (
<SubtaskCard key={task.id} task={task} />
))}
</div>
);
}
function SubtaskCard({ task }: { task: Subtask }) {
const [expanded, setExpanded] = useState(task.status === 'in_progress');
const isActive = task.status === 'in_progress';
return (
<div
className={`
rounded-lg border transition-all overflow-hidden
${isActive
? 'border-orange-300 dark:border-orange-700 bg-orange-50/50 dark:bg-orange-900/10 shadow-[0_0_15px_-3px_rgba(249,115,22,0.15)] dark:shadow-[0_0_15px_-3px_rgba(249,115,22,0.1)]'
: task.status === 'completed'
? 'border-green-200 dark:border-green-800 bg-green-50/30 dark:bg-green-900/10'
: task.status === 'failed'
? 'border-red-200 dark:border-red-800 bg-red-50/30 dark:bg-red-900/10'
: 'border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50'
}
`}
>
{/* Header */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-2 px-3 py-2 text-left"
>
<motion.span animate={{ rotate: expanded ? 90 : 0 }} transition={{ duration: 0.15 }}>
<ChevronRight className="w-3.5 h-3.5 text-gray-400" />
</motion.span>
{/* Status icon */}
{task.status === 'in_progress' ? (
<Loader2 className="w-4 h-4 text-orange-500 animate-spin" />
) : task.status === 'completed' ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : task.status === 'failed' ? (
<XCircle className="w-4 h-4 text-red-500" />
) : (
<Circle className="w-4 h-4 text-gray-400" />
)}
<span className="flex-1 text-xs font-medium text-gray-700 dark:text-gray-300 truncate">
{task.description}
</span>
</button>
{/* Expanded details */}
<AnimatePresence>
{expanded && (task.result || task.error || (task.steps && task.steps.length > 0)) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-3 pb-2 ml-6 border-l-2 border-gray-200 dark:border-gray-700 space-y-1">
{/* Steps */}
{task.steps?.map((step, i) => (
<div key={i} className="flex items-start gap-2">
{step.status === 'thinking' ? (
<span className="w-1.5 h-1.5 mt-1.5 bg-amber-400 rounded-full animate-pulse flex-shrink-0" />
) : step.status === 'done' ? (
<span className="w-1.5 h-1.5 mt-1.5 bg-green-500 rounded-full flex-shrink-0" />
) : (
<span className="w-1.5 h-1.5 mt-1.5 bg-red-500 rounded-full flex-shrink-0" />
)}
<span className="text-[11px] text-gray-600 dark:text-gray-400 leading-relaxed">
{step.content}
</span>
</div>
))}
{/* Result */}
{task.result && (
<div className="text-xs text-gray-700 dark:text-gray-300 mt-1 whitespace-pre-wrap">
{task.result}
</div>
)}
{/* Error */}
{task.error && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
{task.error}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
// ---------------------------------------------------------------------------
// TokenMeter — circular SVG gauge showing token usage
// Inspired by DeerFlow's token usage display
// ---------------------------------------------------------------------------
interface TokenMeterProps {
inputTokens: number;
outputTokens: number;
model?: string;
className?: string;
}
// Color thresholds
function getUsageColor(percent: number): string {
if (percent >= 80) return '#ef4444'; // red
if (percent >= 50) return '#eab308'; // yellow
return '#22c55e'; // green
}
// Format token count for display
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
export function TokenMeter({ inputTokens, outputTokens, model, className = '' }: TokenMeterProps) {
const [showDetail, setShowDetail] = useState(false);
const total = inputTokens + outputTokens;
// Assume ~128K context window as budget for percentage calculation
const budget = 128_000;
const percent = Math.min(100, (total / budget) * 100);
const color = getUsageColor(percent);
// SVG circular gauge parameters
const size = 28;
const strokeWidth = 3;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percent / 100) * circumference;
if (total === 0) return null;
return (
<div className={`relative ${className}`}>
<button
onClick={() => setShowDetail(!showDetail)}
onMouseEnter={() => setShowDetail(true)}
onMouseLeave={() => setShowDetail(false)}
className="focus:outline-none"
title="Token 用量"
>
<svg width={size} height={size} className="transform -rotate-90">
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-gray-200 dark:text-gray-700"
/>
{/* Usage arc */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-500"
/>
</svg>
{/* Center text */}
<span className="absolute inset-0 flex items-center justify-center text-[9px] font-medium text-gray-500 dark:text-gray-400">
{percent >= 1 ? `${Math.round(percent)}` : '<1'}
</span>
</button>
{/* Hover detail card */}
<AnimatePresence>
{showDetail && (
<motion.div
initial={{ opacity: 0, y: 4, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 4, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="absolute bottom-full right-0 mb-2 w-44 p-3 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg z-50"
>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[11px] text-gray-500 dark:text-gray-400">Input</span>
<span className="text-[11px] font-medium text-gray-700 dark:text-gray-200">{formatTokens(inputTokens)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-[11px] text-gray-500 dark:text-gray-400">Output</span>
<span className="text-[11px] font-medium text-gray-700 dark:text-gray-200">{formatTokens(outputTokens)}</span>
</div>
<div className="border-t border-gray-100 dark:border-gray-700 pt-1.5 flex items-center justify-between">
<span className="text-[11px] text-gray-500 dark:text-gray-400">Total</span>
<span className="text-[11px] font-bold text-gray-800 dark:text-gray-100">{formatTokens(total)}</span>
</div>
{model && (
<div className="border-t border-gray-100 dark:border-gray-700 pt-1.5">
<span className="text-[10px] text-gray-400 dark:text-gray-500 truncate block">{model}</span>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,255 @@
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>
);
}

View File

@@ -0,0 +1,11 @@
export { Conversation, ConversationProvider, useConversationContext } from './Conversation';
export { ReasoningBlock } from './ReasoningBlock';
export { StreamingText } from './StreamingText';
export { ChatMode, type ChatModeType, type ChatModeConfig, CHAT_MODES } from './ChatMode';
export { ModelSelector } from './ModelSelector';
export { TaskProgress, type Subtask, TaskProvider, useTaskContext } from './TaskProgress';
export { SuggestionChips } from './SuggestionChips';
export { ResizableChatLayout } from './ResizableChatLayout';
export { ToolCallChain, type ToolCallStep } from './ToolCallChain';
export { ArtifactPanel, type ArtifactFile } from './ArtifactPanel';
export { TokenMeter } from './TokenMeter';