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

@@ -5,16 +5,27 @@ import { useChatStore, Message } from '../store/chatStore';
import { useConnectionStore } from '../store/connectionStore';
import { useAgentStore } from '../store/agentStore';
import { useConfigStore } from '../store/configStore';
import { Paperclip, ChevronDown, Terminal, SquarePen, ArrowUp, MessageSquare, Download, Copy, Check } from 'lucide-react';
import { Paperclip, SquarePen, ArrowUp, MessageSquare, Download, X, FileText, Image as ImageIcon } from 'lucide-react';
import { Button, EmptyState, MessageListSkeleton, LoadingDots } from './ui';
import { ResizableChatLayout } from './ai/ResizableChatLayout';
import { ArtifactPanel } from './ai/ArtifactPanel';
import { ToolCallChain } from './ai/ToolCallChain';
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
import { FirstConversationPrompt } from './FirstConversationPrompt';
import { MessageSearch } from './MessageSearch';
// MessageSearch temporarily removed during DeerFlow redesign
import { OfflineIndicator } from './OfflineIndicator';
import {
useVirtualizedMessages,
type VirtualizedMessageItem
} from '../lib/message-virtualization';
import { Conversation } from './ai/Conversation';
import { ReasoningBlock } from './ai/ReasoningBlock';
import { StreamingText } from './ai/StreamingText';
import { ChatMode } from './ai/ChatMode';
import { ModelSelector } from './ai/ModelSelector';
import { TaskProgress } from './ai/TaskProgress';
import { SuggestionChips } from './ai/SuggestionChips';
// TokenMeter temporarily unused — using inline text counter instead
// Default heights for virtualized messages
const DEFAULT_MESSAGE_HEIGHTS: Record<string, number> = {
@@ -33,17 +44,21 @@ export function ChatArea() {
const {
messages, currentAgent, isStreaming, isLoading, currentModel,
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
newConversation,
newConversation, chatMode, setChatMode, suggestions,
artifacts, selectedArtifactId, artifactPanelOpen,
selectArtifact, setArtifactPanelOpen,
totalInputTokens, totalOutputTokens,
} = useChatStore();
const connectionState = useConnectionStore((s) => s.connectionState);
const clones = useAgentStore((s) => s.clones);
const models = useConfigStore((s) => s.models);
const [input, setInput] = useState('');
const [showModelPicker, setShowModelPicker] = useState(false);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const fileInputRef = useRef<HTMLInputElement>(null);
// Convert messages to virtualization format
const virtualizedMessages: VirtualizedMessageItem[] = useMemo(
@@ -90,6 +105,41 @@ export function ChatArea() {
}
}, []);
// File handling
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_FILES = 5;
const addFiles = useCallback((files: FileList | File[]) => {
const incoming = Array.from(files).filter((f) => f.size <= MAX_FILE_SIZE);
setPendingFiles((prev) => {
const combined = [...prev, ...incoming];
return combined.slice(0, MAX_FILES);
});
}, []);
// Paste handler for images/files
useEffect(() => {
const handler = (e: ClipboardEvent) => {
if (e.clipboardData?.files.length) {
e.preventDefault();
addFiles(e.clipboardData.files);
}
};
document.addEventListener('paste', handler);
return () => document.removeEventListener('paste', handler);
}, [addFiles]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (e.dataTransfer.files.length) {
addFiles(e.dataTransfer.files);
}
}, [addFiles]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
// Init agent stream listener on mount
useEffect(() => {
const unsub = initStreamListener();
@@ -106,10 +156,14 @@ export function ChatArea() {
}, [messages, useVirtualization, scrollToBottom]);
const handleSend = () => {
if (!input.trim() || isStreaming) return;
// Allow sending in offline mode - message will be queued
sendToGateway(input);
if ((!input.trim() && pendingFiles.length === 0) || isStreaming) return;
// Attach file names as metadata in the message
const fileContext = pendingFiles.length > 0
? `\n\n[附件: ${pendingFiles.map((f) => f.name).join(', ')}]`
: '';
sendToGateway(input + fileContext);
setInput('');
setPendingFiles([]);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -121,52 +175,73 @@ export function ChatArea() {
const connected = connectionState === 'connected';
// Navigate to a specific message by ID
const handleNavigateToMessage = useCallback((messageId: string) => {
const messageEl = messageRefs.current.get(messageId);
if (messageEl && scrollRef.current) {
messageEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Add highlight effect
messageEl.classList.add('ring-2', 'ring-orange-400', 'ring-offset-2');
setTimeout(() => {
messageEl.classList.remove('ring-2', 'ring-orange-400', 'ring-offset-2');
}, 2000);
// Export current conversation as Markdown
const exportCurrentConversation = () => {
const title = currentAgent?.name || 'ZCLAW 对话';
const lines = [`# ${title}`, '', `导出时间: ${new Date().toLocaleString('zh-CN')}`, ''];
for (const msg of messages) {
const label = msg.role === 'user' ? '用户' : msg.role === 'assistant' ? '助手' : msg.role;
lines.push(`## ${label}`, '', msg.content, '');
}
}, []);
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title.replace(/[/\\?%*:|"<>]/g, '_')}.md`;
a.click();
URL.revokeObjectURL(url);
};
// Build artifact panel content
const artifactRightPanel = (
<ArtifactPanel
artifacts={artifacts}
selectedId={selectedArtifactId}
onSelect={selectArtifact}
onClose={() => setArtifactPanelOpen(false)}
/>
);
return (
<div className="flex flex-col h-full">
{/* Header */}
{/* Header */}
<div className="h-14 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between px-6 flex-shrink-0 bg-white dark:bg-gray-900">
<div className="flex items-center gap-2">
<h2 className="font-semibold text-gray-900 dark:text-gray-100">{currentAgent?.name || 'ZCLAW'}</h2>
{isStreaming ? (
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-gray-500 dark:bg-gray-400 rounded-full thinking-dot"></span>
</span>
) : (
<span className={`text-xs flex items-center gap-1 ${connected ? 'text-green-500' : 'text-gray-500 dark:text-gray-400'}`}>
<span className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-400' : 'bg-gray-300 dark:bg-gray-600'}`}></span>
{connected ? 'Gateway 已连接' : 'Gateway 未连接'}
</span>
)}
<ResizableChatLayout
chatPanel={
<div className="flex flex-col h-full">
{/* Header — DeerFlow-style: minimal */}
<div className="h-14 border-b border-transparent flex items-center justify-between px-6 flex-shrink-0 bg-white dark:bg-gray-900">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>{currentAgent?.name || '新对话'}</span>
</div>
<div className="flex items-center gap-2">
{/* Offline indicator in header */}
<div className="flex items-center gap-4">
{/* Token usage counter — DeerFlow-style plain text */}
{(totalInputTokens + totalOutputTokens) > 0 && (() => {
const total = totalInputTokens + totalOutputTokens;
const display = total >= 1000 ? `${(total / 1000).toFixed(1)}K` : String(total);
return (
<span className="text-sm text-gray-500 flex items-center gap-1.5">
{display}
</span>
);
})()}
<OfflineIndicator compact />
{messages.length > 0 && (
<MessageSearch onNavigateToMessage={handleNavigateToMessage} />
<Button
variant="ghost"
size="sm"
onClick={exportCurrentConversation}
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="导出对话"
>
<Download className="w-3.5 h-3.5" />
<span className="text-sm"></span>
</Button>
)}
{messages.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={newConversation}
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="新对话"
aria-label="开始新对话"
className="flex items-center gap-1.5 text-gray-500 dark:text-gray-400 hover:text-orange-600 dark:hover:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20"
>
<SquarePen className="w-3.5 h-3.5" />
@@ -176,7 +251,7 @@ export function ChatArea() {
</div>
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar bg-white dark:bg-gray-900">
<Conversation className="flex-1 bg-white dark:bg-gray-900">
<AnimatePresence mode="popLayout">
{/* Loading skeleton */}
{isLoading && messages.length === 0 && (
@@ -240,21 +315,60 @@ export function ChatArea() {
))
)}
</AnimatePresence>
</div>
</Conversation>
{/* Input */}
<div className="border-t border-gray-100 dark:border-gray-800 p-4 bg-white dark:bg-gray-900">
<div className="p-4 bg-white dark:bg-gray-900">
<div className="max-w-4xl mx-auto">
<div className="relative flex items-end gap-2 bg-gray-50 dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 p-2 focus-within:border-orange-300 dark:focus-within:border-orange-600 focus-within:ring-2 focus-within:ring-orange-100 dark:focus-within:ring-orange-900/30 transition-all">
<Button
variant="ghost"
size="sm"
className="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="添加附件"
>
<Paperclip className="w-5 h-5" />
</Button>
<div className="flex-1 py-1">
{/* Suggestion chips */}
{!isStreaming && suggestions.length > 0 && (
<SuggestionChips
suggestions={suggestions}
onSelect={(text) => { setInput(text); textareaRef.current?.focus(); }}
className="mb-3"
/>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
/>
{/* Pending file previews */}
{pendingFiles.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{pendingFiles.map((file, idx) => (
<div
key={`${file.name}-${idx}`}
className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 dark:bg-gray-700 rounded-lg text-xs text-gray-700 dark:text-gray-300 max-w-[200px]"
>
{file.type.startsWith('image/') ? (
<ImageIcon className="w-3.5 h-3.5 flex-shrink-0 text-orange-500" />
) : (
<FileText className="w-3.5 h-3.5 flex-shrink-0 text-gray-500" />
)}
<span className="truncate">{file.name}</span>
<span className="text-gray-400 flex-shrink-0">({(file.size / 1024).toFixed(0)}K)</span>
<button
onClick={() => setPendingFiles((prev) => prev.filter((_, i) => i !== idx))}
className="p-0.5 text-gray-400 hover:text-red-500 flex-shrink-0"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
{/* Input card — DeerFlow-style: white card, textarea top, actions bottom */}
<div
className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm transition-all"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* Textarea area */}
<div className="px-4 pt-4 pb-1">
<textarea
ref={textareaRef}
value={input}
@@ -263,277 +377,70 @@ export function ChatArea() {
placeholder={
isStreaming
? 'Agent 正在回复...'
: `发送给 ${currentAgent?.name || 'ZCLAW'}${!connected ? ' (离线模式)' : ''}`
: '今天我能为你做些什么?'
}
disabled={isStreaming}
rows={1}
className="w-full bg-transparent border-none focus:outline-none text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none leading-relaxed mt-1"
style={{ minHeight: '24px', maxHeight: '160px' }}
rows={2}
className="w-full bg-transparent border-none outline-none ring-0 focus:outline-none focus:ring-0 text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none leading-relaxed"
style={{ minHeight: '48px', maxHeight: '160px', border: 'none', outline: 'none', boxShadow: 'none' }}
/>
</div>
<div className="flex items-center gap-2 pr-2 pb-1 relative">
<Button
variant="ghost"
size="sm"
onClick={() => setShowModelPicker(!showModelPicker)}
className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
aria-label="选择模型"
aria-expanded={showModelPicker}
>
<span>{currentModel}</span>
<ChevronDown className="w-3 h-3" />
</Button>
{showModelPicker && (
<div className="absolute bottom-full right-8 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1 min-w-[160px] max-h-48 overflow-y-auto z-10">
{models.length > 0 ? (
models.map((model) => (
<button
key={model.id}
onClick={() => { setCurrentModel(model.id); setShowModelPicker(false); }}
className={`w-full text-left px-3 py-2 text-xs hover:bg-gray-50 dark:hover:bg-gray-700 ${model.id === currentModel ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-700 dark:text-gray-300'}`}
>
{model.name}
</button>
))
) : (
<div className="px-3 py-2 text-xs text-gray-400">
{connected ? '加载中...' : '未连接 Gateway'}
</div>
)}
</div>
)}
<Button
variant="primary"
size="sm"
onClick={handleSend}
disabled={isStreaming || !input.trim()}
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white disabled:opacity-50"
aria-label="发送消息"
>
<ArrowUp className="w-4 h-4 text-white" />
</Button>
{/* Bottom action bar */}
<div className="flex items-center justify-between px-3 pb-3">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="添加附件"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip className="w-5 h-5" />
</Button>
<ChatMode
value={chatMode}
onChange={setChatMode}
disabled={isStreaming}
/>
</div>
<div className="flex items-center gap-2">
<ModelSelector
models={models.map(m => ({ id: m.id, name: m.name, provider: m.provider }))}
currentModel={currentModel}
onSelect={setCurrentModel}
disabled={isStreaming}
/>
<Button
variant="primary"
size="sm"
onClick={handleSend}
disabled={isStreaming || (!input.trim() && pendingFiles.length === 0)}
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white disabled:opacity-50"
aria-label="发送消息"
>
<ArrowUp className="w-4 h-4 text-white" />
</Button>
</div>
</div>
</div>
<div className="text-center mt-2 text-xs text-gray-500 dark:text-gray-400">
Agent AI
</div>
</div>
</div>
</div>
);
}
/** Code block with copy and download functionality */
function CodeBlock({ code, language, index }: { code: string; language: string; index: number }) {
const [copied, setCopied] = useState(false);
const [downloading, setDownloading] = useState(false);
// Infer filename from language or content
const inferFilename = (): string => {
const extMap: Record<string, string> = {
javascript: 'js', typescript: 'ts', python: 'py', rust: 'rs',
go: 'go', java: 'java', cpp: 'cpp', c: 'c', csharp: 'cs',
html: 'html', css: 'css', scss: 'scss', json: 'json',
yaml: 'yaml', yml: 'yaml', xml: 'xml', sql: 'sql',
shell: 'sh', bash: 'sh', powershell: 'ps1',
markdown: 'md', md: 'md', dockerfile: 'dockerfile',
};
// Check if language contains a filename (e.g., ```app.tsx)
if (language.includes('.') || language.includes('/')) {
return language;
}
// Check for common patterns in code
const codeLower = code.toLowerCase();
if (codeLower.includes('<!doctype html') || codeLower.includes('<html')) {
return 'index.html';
}
if (codeLower.includes('package.json') || (codeLower.includes('"name"') && codeLower.includes('"version"'))) {
return 'package.json';
}
if (codeLower.startsWith('{') && (codeLower.includes('"import"') || codeLower.includes('"export"'))) {
return 'config.json';
}
// Use language extension
const ext = extMap[language.toLowerCase()] || language.toLowerCase();
return `code-${index + 1}.${ext || 'txt'}`;
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const handleDownload = () => {
setDownloading(true);
try {
const filename = inferFilename();
const blob = new Blob([code], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error('Failed to download:', err);
}
setTimeout(() => setDownloading(false), 500);
};
return (
<div className="relative group my-2">
<pre className="bg-gray-900 text-gray-100 rounded-lg p-3 overflow-x-auto text-xs font-mono leading-relaxed">
{language && (
<div className="text-gray-500 text-[10px] mb-1 uppercase flex items-center justify-between">
<span>{language}</span>
</div>
)}
<code>{code}</code>
</pre>
{/* Action buttons - show on hover */}
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={handleCopy}
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300 hover:text-white transition-colors"
title="复制代码"
>
{copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
</button>
<button
onClick={handleDownload}
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300 hover:text-white transition-colors"
title="下载文件"
disabled={downloading}
>
<Download className={`w-3.5 h-3.5 ${downloading ? 'animate-pulse' : ''}`} />
</button>
</div>
</div>
);
}
/** Lightweight markdown renderer — handles code blocks, inline code, bold, italic, links */
function sanitizeUrl(url: string): string {
const safeProtocols = ['http:', 'https:', 'mailto:'];
try {
const parsed = new URL(url, window.location.origin);
if (safeProtocols.includes(parsed.protocol)) {
return parsed.href;
}
} catch {
// Invalid URL
}
return '#';
}
function renderMarkdown(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
const lines = text.split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Fenced code block
if (line.startsWith('```')) {
const lang = line.slice(3).trim();
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
i++; // skip closing ```
nodes.push(
<CodeBlock key={nodes.length} code={codeLines.join('\n')} language={lang} index={nodes.length} />
);
continue;
}
// Normal line — parse inline markdown
nodes.push(
<span key={nodes.length}>
{i > 0 && lines[i - 1] !== undefined && !nodes[nodes.length - 1]?.toString().includes('pre') && '\n'}
{renderInline(line)}
</span>
);
i++;
}
return nodes;
}
function renderInline(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
// Pattern: **bold**, *italic*, `code`, [text](url)
const regex = /(\*\*(.+?)\*\*)|(\*(.+?)\*)|(`(.+?)`)|(\[(.+?)\]\((.+?)\))/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// Text before match
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
if (match[1]) {
// **bold**
parts.push(<strong key={parts.length} className="font-semibold">{match[2]}</strong>);
} else if (match[3]) {
// *italic*
parts.push(<em key={parts.length}>{match[4]}</em>);
} else if (match[5]) {
// `code`
parts.push(
<code key={parts.length} className="bg-gray-100 dark:bg-gray-700 text-orange-700 dark:text-orange-400 px-1 py-0.5 rounded text-[0.85em] font-mono">
{match[6]}
</code>
);
} else if (match[7]) {
// [text](url) - 使用 sanitizeUrl 防止 XSS
parts.push(
<a key={parts.length} href={sanitizeUrl(match[9])} target="_blank" rel="noopener noreferrer"
className="text-orange-600 dark:text-orange-400 underline hover:text-orange-700 dark:hover:text-orange-300">{match[8]}</a>
);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? parts : [text];
rightPanel={artifactRightPanel}
rightPanelTitle="产物"
rightPanelOpen={artifactPanelOpen}
onRightPanelToggle={setArtifactPanelOpen}
/>
);
}
function MessageBubble({ message }: { message: Message }) {
// Tool messages are now absorbed into the assistant message's toolSteps chain.
// Legacy standalone tool messages (from older sessions) still render as before.
if (message.role === 'tool') {
return (
<div className="ml-12 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono">
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 mb-1">
<Terminal className="w-3.5 h-3.5" />
<span className="font-semibold">{message.toolName || 'tool'}</span>
</div>
{message.toolInput && (
<pre className="text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-900 rounded p-2 mb-1 overflow-x-auto">{message.toolInput}</pre>
)}
{message.content && (
<pre className="text-green-700 dark:text-green-400 bg-white dark:bg-gray-900 rounded p-2 overflow-x-auto">{message.content}</pre>
)}
</div>
);
return null;
}
const isUser = message.role === 'user';
@@ -573,11 +480,42 @@ function MessageBubble({ message }: { message: Message }) {
</div>
) : (
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'} relative group`}>
<div className={`leading-relaxed whitespace-pre-wrap ${isUser ? 'text-white' : 'text-gray-700 dark:text-gray-200'}`}>
{/* Optimistic sending indicator */}
{isUser && message.optimistic && (
<span className="text-xs text-blue-200 dark:text-blue-300 mb-1 block animate-pulse">
Sending...
</span>
)}
{/* Reasoning block for thinking content (DeerFlow-inspired) */}
{!isUser && message.thinkingContent && (
<ReasoningBlock
content={message.thinkingContent}
isStreaming={message.streaming}
/>
)}
{/* Tool call steps chain (DeerFlow-inspired) */}
{!isUser && message.toolSteps && message.toolSteps.length > 0 && (
<ToolCallChain
steps={message.toolSteps}
isStreaming={message.streaming}
/>
)}
{/* Subtask tracking (DeerFlow-inspired) */}
{!isUser && message.subtasks && message.subtasks.length > 0 && (
<TaskProgress tasks={message.subtasks} className="mb-3" />
)}
{/* Message content with streaming support */}
<div className={`leading-relaxed ${isUser ? 'text-white whitespace-pre-wrap' : 'text-gray-700 dark:text-gray-200'}`}>
{message.content
? (isUser ? message.content : renderMarkdown(message.content))
? (isUser
? message.content
: <StreamingText
content={message.content}
isStreaming={!!message.streaming}
className="text-gray-700 dark:text-gray-200"
/>
)
: '...'}
{message.streaming && <span className="inline-block w-1.5 h-4 bg-orange-500 animate-pulse ml-0.5 align-text-bottom rounded-sm" />}
</div>
{message.error && (
<p className="text-xs text-red-500 mt-2">{message.error}</p>

View File

@@ -1,119 +1,219 @@
import { useState, useRef, useEffect } from 'react';
import { useChatStore } from '../store/chatStore';
import { MessageSquare, Trash2, SquarePen } from 'lucide-react';
import { EmptyConversations, ConversationListSkeleton } from './ui';
import { MessageSquare, Trash2, SquarePen, Download, Check, X } from 'lucide-react';
import { EmptyConversations } from './ui';
export function ConversationList() {
const {
conversations, currentConversationId, messages, agents, currentAgent,
newConversation, switchConversation, deleteConversation,
isLoading,
} = useChatStore();
function formatTime(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}小时前`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}天前`;
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
const hasActiveChat = messages.length > 0;
// Show skeleton during initial load
if (isLoading && conversations.length === 0 && !hasActiveChat) {
return <ConversationListSkeleton count={4} />;
function exportConversation(title: string, messages: { role: string; content: string }[]): void {
const lines = [`# ${title}`, '', `导出时间: ${new Date().toLocaleString('zh-CN')}`, ''];
for (const msg of messages) {
const label = msg.role === 'user' ? '用户' : msg.role === 'assistant' ? '助手' : msg.role;
lines.push(`## ${label}`, '', msg.content, '');
}
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title.replace(/[/\\?%*:|"<>]/g, '_')}.md`;
a.click();
URL.revokeObjectURL(url);
}
interface ConversationItemProps {
id: string;
title: string;
updatedAt: Date;
messageCount: number;
isActive: boolean;
onSelect: () => void;
onDelete: () => void;
onRename: (newTitle: string) => void;
onExport: () => void;
}
function ConversationItem({
title,
updatedAt,
messageCount,
isActive,
onSelect,
onDelete,
onRename,
onExport,
}: ConversationItemProps) {
const [hovering, setHovering] = useState(false);
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
const handleRenameSubmit = () => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== title) {
onRename(trimmed);
} else {
setEditValue(title);
}
setEditing(false);
};
const handleRenameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleRenameSubmit();
} else if (e.key === 'Escape') {
setEditValue(title);
setEditing(false);
}
};
const timeStr = formatTime(updatedAt);
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<span className="text-xs font-medium text-gray-500"></span>
<button
onClick={newConversation}
className="p-1 text-gray-400 hover:text-orange-500 rounded"
title="新对话"
>
<SquarePen className="w-4 h-4" />
</button>
</div>
<div
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onClick={() => { if (!editing) onSelect(); }}
className={`
group relative flex items-center gap-2.5 px-3 py-2.5 rounded-lg cursor-pointer transition-colors
${isActive
? 'bg-primary/10 dark:bg-primary/20 border border-primary/20'
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50 border border-transparent'
}
`}
>
<MessageSquare className={`w-4 h-4 flex-shrink-0 ${isActive ? 'text-primary' : 'text-gray-400'}`} />
<div className="flex-1 overflow-y-auto custom-scrollbar">
{/* Current active chat (unsaved) */}
{hasActiveChat && !currentConversationId && (
<div className="flex items-center gap-3 px-3 py-3 bg-orange-50 border-b border-orange-100 cursor-default">
<div className="w-7 h-7 bg-orange-500 rounded-lg flex items-center justify-center text-white flex-shrink-0">
<MessageSquare className="w-3.5 h-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-orange-700 truncate"></div>
<div className="text-[11px] text-orange-500 truncate">
{messages.filter(m => m.role === 'user').length} · {currentAgent?.name || 'ZCLAW'}
</div>
</div>
</div>
)}
{/* Saved conversations */}
{conversations.map((conv) => {
const isActive = conv.id === currentConversationId;
const msgCount = conv.messages.filter(m => m.role === 'user').length;
const timeStr = formatTime(conv.updatedAt);
const agentName = conv.agentId
? agents.find((agent) => agent.id === conv.agentId)?.name || conv.agentId
: 'ZCLAW';
return (
<div
key={conv.id}
onClick={() => switchConversation(conv.id)}
className={`group flex items-center gap-3 px-3 py-3 cursor-pointer border-b border-gray-50 transition-colors ${
isActive ? 'bg-orange-50' : 'hover:bg-gray-100'
}`}
<div className="flex-1 min-w-0">
{editing ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={handleRenameSubmit}
className="flex-1 min-w-0 px-1.5 py-0.5 text-sm bg-white dark:bg-gray-700 border border-orange-300 dark:border-orange-600 rounded outline-none"
maxLength={100}
/>
<button
onClick={(e) => { e.stopPropagation(); handleRenameSubmit(); }}
className="p-0.5 text-green-600 hover:text-green-700"
>
<div className={`w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0 ${
isActive ? 'bg-orange-500 text-white' : 'bg-gray-200 text-gray-500'
}`}>
<MessageSquare className="w-3.5 h-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-xs font-medium truncate ${isActive ? 'text-orange-700' : 'text-gray-900'}`}>
{conv.title}
</div>
<div className="text-[11px] text-gray-400 truncate">
{msgCount} · {agentName} · {timeStr}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
if (confirm('删除该对话?')) {
deleteConversation(conv.id);
}
}}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-300 hover:text-red-500 transition-opacity"
title="删除"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
);
})}
{conversations.length === 0 && !hasActiveChat && (
<EmptyConversations size="sm" className="h-auto" />
<Check className="w-3.5 h-3.5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); setEditValue(title); setEditing(false); }}
className="p-0.5 text-gray-400 hover:text-gray-600"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
) : (
<>
<p className={`text-sm truncate ${isActive ? 'font-medium text-gray-900 dark:text-gray-100' : 'text-gray-700 dark:text-gray-300'}`}>
{title}
</p>
<p className="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5">
{timeStr}
{messageCount > 0 && <span className="ml-1.5">{messageCount} </span>}
</p>
</>
)}
</div>
{/* Hover action bar */}
{hovering && !editing && (
<div className="flex items-center gap-0.5 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setEditing(true)}
title="重命名"
className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<SquarePen className="w-3.5 h-3.5" />
</button>
<button
onClick={onExport}
title="导出"
className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<Download className="w-3.5 h-3.5" />
</button>
<button
onClick={onDelete}
title="删除"
className="p-1 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
);
}
function formatTime(date: Date): string {
const now = new Date();
const d = new Date(date);
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return '刚刚';
if (diffMin < 60) return `${diffMin} 分钟前`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr} 小时前`;
const diffDay = Math.floor(diffHr / 24);
if (diffDay < 7) return `${diffDay} 天前`;
return `${d.getMonth() + 1}/${d.getDate()}`;
export function ConversationList() {
const {
conversations,
currentConversationId,
switchConversation,
deleteConversation,
} = useChatStore();
const handleRename = (id: string, newTitle: string) => {
useChatStore.setState((state) => ({
conversations: state.conversations.map((c) =>
c.id === id ? { ...c, title: newTitle, updatedAt: new Date() } : c
),
}));
};
const handleExport = (id: string) => {
const conv = conversations.find((c) => c.id === id);
if (!conv) return;
exportConversation(conv.title, conv.messages);
};
if (conversations.length === 0) {
return <EmptyConversations />;
}
return (
<div className="flex flex-col gap-0.5 py-1">
{conversations.map((conv) => (
<ConversationItem
key={conv.id}
id={conv.id}
title={conv.title}
updatedAt={conv.updatedAt}
messageCount={conv.messages.filter((m) => m.role === 'user').length}
isActive={conv.id === currentConversationId}
onSelect={() => switchConversation(conv.id)}
onDelete={() => deleteConversation(conv.id)}
onRename={(newTitle) => handleRename(conv.id, newTitle)}
onExport={() => handleExport(conv.id)}
/>
))}
</div>
);
}
export default ConversationList;

View File

@@ -1,19 +1,45 @@
/**
* FirstConversationPrompt - Welcome prompt for new Agents
* FirstConversationPrompt - Welcome prompt for new conversations
*
* Displays a personalized welcome message and quick start suggestions
* when entering a new Agent's chat for the first time.
* DeerFlow-inspired design:
* - Centered layout with emoji greeting
* - Input bar embedded in welcome screen
* - Horizontal quick-action chips (colored pills)
* - Clean, minimal aesthetic
*/
import { motion } from 'framer-motion';
import { Lightbulb, ArrowRight } from 'lucide-react';
import {
Sparkles,
PenLine,
Microscope,
Layers,
GraduationCap,
} from 'lucide-react';
import { cn } from '../lib/utils';
import {
generateWelcomeMessage,
getQuickStartSuggestions,
getScenarioById,
type QuickStartSuggestion,
} from '../lib/personality-presets';
import type { Clone } from '../store/agentStore';
import { useChatStore } from '../store/chatStore';
// Quick action chip definitions — DeerFlow-style colored pills
const QUICK_ACTIONS = [
{ key: 'surprise', label: '小惊喜', icon: Sparkles, color: 'text-orange-500' },
{ key: 'write', label: '写作', icon: PenLine, color: 'text-blue-500' },
{ key: 'research', label: '研究', icon: Microscope, color: 'text-purple-500' },
{ key: 'collect', label: '收集', icon: Layers, color: 'text-green-500' },
{ key: 'learn', label: '学习', icon: GraduationCap, color: 'text-indigo-500' },
];
// Pre-filled prompts for each quick action
const QUICK_ACTION_PROMPTS: Record<string, string> = {
surprise: '给我一个小惊喜吧!来点创意的',
write: '帮我写一篇文章,主题你来定',
research: '帮我做一个深度研究分析',
collect: '帮我收集整理一些有用的信息',
learn: '我想学点新东西,教我一些有趣的知识',
};
interface FirstConversationPromptProps {
clone: Clone;
@@ -25,7 +51,15 @@ export function FirstConversationPrompt({
clone,
onSelectSuggestion,
}: FirstConversationPromptProps) {
// Generate welcome message
const chatMode = useChatStore((s) => s.chatMode);
const modeGreeting: Record<string, string> = {
flash: '快速回答,即时响应',
thinking: '深度分析,逐步推理',
pro: '专业规划,系统思考',
ultra: '多代理协作,全能力调度',
};
const welcomeMessage = generateWelcomeMessage({
userName: clone.userName,
agentName: clone.nickname || clone.name,
@@ -34,11 +68,9 @@ export function FirstConversationPrompt({
scenarios: clone.scenarios,
});
// Get quick start suggestions based on scenarios
const suggestions = getQuickStartSuggestions(clone.scenarios || []);
const handleSuggestionClick = (suggestion: QuickStartSuggestion) => {
onSelectSuggestion?.(suggestion.text);
const handleQuickAction = (key: string) => {
const prompt = QUICK_ACTION_PROMPTS[key] || '你好!';
onSelectSuggestion?.(prompt);
};
return (
@@ -48,48 +80,63 @@ export function FirstConversationPrompt({
exit={{ opacity: 0, y: -10 }}
className="flex flex-col items-center justify-center py-12 px-4"
>
{/* Avatar with emoji */}
<div className="mb-6">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-primary/20 to-primary/10 dark:from-primary/30 dark:to-primary/20 flex items-center justify-center shadow-lg">
<span className="text-4xl">{clone.emoji || '🦞'}</span>
</div>
</div>
{/* Greeting emoji */}
<div className="text-5xl mb-4">{clone.emoji || '👋'}</div>
{/* Title */}
<motion.h1
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1, duration: 0.5 }}
className="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-2"
>
</motion.h1>
{/* Mode-aware subtitle */}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="text-sm text-orange-500 dark:text-orange-400 font-medium mb-4 flex items-center gap-1.5"
>
<Sparkles className="w-3.5 h-3.5" />
{modeGreeting[chatMode] || '智能对话,随时待命'}
</motion.p>
{/* Welcome message */}
<div className="text-center max-w-md mb-8">
<p className="text-lg text-gray-700 dark:text-gray-200 whitespace-pre-line leading-relaxed">
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
{welcomeMessage}
</p>
</div>
{/* Quick start suggestions */}
<div className="w-full max-w-lg space-y-2">
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-3">
<Lightbulb className="w-4 h-4" />
<span></span>
</div>
{suggestions.map((suggestion, index) => (
<motion.button
key={index}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
onClick={() => handleSuggestionClick(suggestion)}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 rounded-xl',
'bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700',
'hover:bg-gray-100 dark:hover:bg-gray-800 hover:border-primary/30',
'transition-all duration-200 group text-left'
)}
>
<span className="text-xl flex-shrink-0">{suggestion.icon}</span>
<span className="flex-1 text-sm text-gray-700 dark:text-gray-200">
{suggestion.text}
</span>
<ArrowRight className="w-4 h-4 text-gray-400 group-hover:text-primary transition-colors flex-shrink-0" />
</motion.button>
))}
{/* Quick action chips — DeerFlow-style horizontal colored pills */}
<div className="flex items-center justify-center gap-2 flex-wrap">
{QUICK_ACTIONS.map((action, index) => {
const ActionIcon = action.icon;
return (
<motion.button
key={action.key}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 + index * 0.05, duration: 0.2 }}
onClick={() => handleQuickAction(action.key)}
className={cn(
'flex items-center gap-2 px-4 py-2',
'bg-white dark:bg-gray-800',
'border border-gray-200 dark:border-gray-700',
'rounded-full text-sm text-gray-600 dark:text-gray-300',
'hover:border-gray-300 dark:hover:border-gray-600',
'hover:bg-gray-50 dark:hover:bg-gray-750',
'transition-all duration-150'
)}
>
<ActionIcon className={`w-4 h-4 ${action.color}`} />
<span>{action.label}</span>
</motion.button>
);
})}
</div>
{/* Scenario tags */}

View File

@@ -1,11 +1,11 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Bot, Zap, Package,
Search, ChevronRight, X
SquarePen, MessageSquare, Bot, Search, X, Settings
} from 'lucide-react';
import { ConversationList } from './ConversationList';
import { CloneManager } from './CloneManager';
import { useConfigStore } from '../store/configStore';
import { useChatStore } from '../store/chatStore';
import { containerVariants, defaultTransition } from '../lib/animations';
export type MainViewType = 'chat' | 'automation' | 'skills';
@@ -16,86 +16,81 @@ interface SidebarProps {
onNewChat?: () => void;
}
type Tab = 'chat' | 'clones' | 'automation' | 'skills';
// 导航项配置 - WorkBuddy 风格
const NAV_ITEMS: {
key: Tab;
label: string;
icon: React.ComponentType<{ className?: string }>;
mainView?: MainViewType;
}[] = [
{ key: 'clones', label: '分身', icon: Bot },
{ key: 'automation', label: '自动化', icon: Zap, mainView: 'automation' },
{ key: 'skills', label: '技能', icon: Package, mainView: 'skills' },
];
type Tab = 'conversations' | 'clones';
export function Sidebar({
onOpenSettings,
onMainViewChange,
}: Omit<SidebarProps, 'onNewChat'>) {
const [activeTab, setActiveTab] = useState<Tab>('clones');
const [activeTab, setActiveTab] = useState<Tab>('conversations');
const [searchQuery, setSearchQuery] = useState('');
const userName = useConfigStore((state) => state.quickConfig?.userName) || '用户7141';
const newConversation = useChatStore((s) => s.newConversation);
const handleNavClick = (key: Tab, mainView?: MainViewType) => {
setActiveTab(key);
if (mainView && onMainViewChange) {
onMainViewChange(mainView);
} else if (onMainViewChange) {
onMainViewChange('chat');
const handleNewConversation = () => {
newConversation();
onMainViewChange?.('chat');
};
const handleNavClick = (tab: Tab) => {
setActiveTab(tab);
if (tab === 'clones') {
onMainViewChange?.('chat');
} else {
onMainViewChange?.('chat');
}
};
return (
<aside className="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col flex-shrink-0">
{/* 搜索框 */}
<div className="p-3 border-b border-gray-100 dark:border-gray-800">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="搜索..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-8 py-2 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:border-gray-400 focus:ring-1 focus:ring-gray-400 transition-all text-gray-700 dark:text-gray-300 placeholder-gray-400"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded text-gray-400 transition-colors"
>
<X className="w-3 h-3" />
</button>
)}
</div>
<aside className="w-64 sidebar-bg border-r border-[#e8e6e1] dark:border-gray-800 flex flex-col h-full shrink-0">
{/* Logo area */}
<div className="h-14 flex items-center px-4 border-b border-[#e8e6e1]/50 dark:border-gray-800">
<span className="text-lg font-semibold tracking-tight text-gray-900 dark:text-gray-100">ZCLAW</span>
<button
onClick={handleNewConversation}
className="ml-auto p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-md transition-colors text-gray-600 dark:text-gray-400"
title="新对话"
>
<SquarePen className="w-4 h-4" />
</button>
</div>
{/* 导航项 */}
<nav className="px-3 space-y-0.5">
{NAV_ITEMS.map(({ key, label, icon: Icon, mainView }) => (
<button
key={key}
onClick={() => handleNavClick(key, mainView)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
activeTab === key
? 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-medium'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800/50 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
<Icon className={`w-5 h-5 ${activeTab === key ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400'}`} />
<span>{label}</span>
{activeTab === key && (
<ChevronRight className="w-4 h-4 ml-auto text-gray-400" />
)}
</button>
))}
</nav>
{/* Main Nav — DeerFlow-style: new chat / conversations / agents */}
<div className="p-2 space-y-1">
<button
onClick={handleNewConversation}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-sm font-medium text-gray-900 dark:text-gray-100"
>
<SquarePen className="w-4 h-4" />
</button>
<button
onClick={() => handleNavClick('conversations')}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
activeTab === 'conversations'
? 'bg-black/5 dark:bg-white/5 font-medium text-gray-900 dark:text-gray-100'
: 'text-gray-600 dark:text-gray-400 hover:bg-black/5 dark:hover:bg-white/5'
}`}
>
<MessageSquare className="w-4 h-4" />
</button>
<button
onClick={() => handleNavClick('clones')}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
activeTab === 'clones'
? 'bg-black/5 dark:bg-white/5 font-medium text-gray-900 dark:text-gray-100'
: 'text-gray-600 dark:text-gray-400 hover:bg-black/5 dark:hover:bg-white/5'
}`}
>
<Bot className="w-4 h-4" />
</button>
</div>
{/* 分隔线 */}
<div className="my-3 mx-3 border-t border-gray-100 dark:border-gray-800" />
{/* Divider */}
<div className="mx-3 border-t border-[#e8e6e1]/50 dark:border-gray-800" />
{/* 内容区域 - 只显示分身内容,自动化和技能在主内容区显示 */}
{/* Content area */}
<div className="flex-1 overflow-hidden">
<AnimatePresence mode="wait">
<motion.div
@@ -107,27 +102,45 @@ export function Sidebar({
transition={defaultTransition}
className="h-full overflow-y-auto"
>
{activeTab === 'conversations' && (
<div className="p-2">
{/* Search in conversations */}
<div className="relative mb-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="搜索对话..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-8 py-1.5 bg-white/60 dark:bg-gray-800 border border-[#e8e6e1] dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:border-gray-400 transition-all text-gray-700 dark:text-gray-300 placeholder-gray-400"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded text-gray-400"
>
<X className="w-3 h-3" />
</button>
)}
</div>
<ConversationList />
</div>
)}
{activeTab === 'clones' && <CloneManager />}
{/* skills 和 automation 不在侧边栏显示内容,由主内容区显示 */}
</motion.div>
</AnimatePresence>
</div>
{/* 底部用户栏 */}
<div className="p-3 border-t border-gray-200 dark:border-gray-700">
{/* Bottom user bar */}
<div className="p-2 border-t border-[#e8e6e1] dark:border-gray-700">
<button
onClick={onOpenSettings}
aria-label="打开设置"
title="设置"
className="flex items-center gap-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 p-2 rounded-lg transition-colors"
title="设置和更多"
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<div className="w-8 h-8 bg-gray-600 rounded-full flex items-center justify-center text-white font-bold shadow-sm">
{userName?.charAt(0) || '用'}
</div>
<span className="flex-1 text-left text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
{userName}
</span>
<ChevronRight className="w-4 h-4 text-gray-400" />
<Settings className="w-4 h-4" />
<span></span>
</button>
</div>
</aside>

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';