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
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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
302
desktop/src/components/ai/ArtifactPanel.tsx
Normal file
302
desktop/src/components/ai/ArtifactPanel.tsx
Normal 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);
|
||||
}
|
||||
133
desktop/src/components/ai/ChatMode.tsx
Normal file
133
desktop/src/components/ai/ChatMode.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
desktop/src/components/ai/Conversation.tsx
Normal file
117
desktop/src/components/ai/Conversation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
desktop/src/components/ai/ModelSelector.tsx
Normal file
140
desktop/src/components/ai/ModelSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
desktop/src/components/ai/ReasoningBlock.tsx
Normal file
156
desktop/src/components/ai/ReasoningBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
desktop/src/components/ai/ResizableChatLayout.tsx
Normal file
136
desktop/src/components/ai/ResizableChatLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
desktop/src/components/ai/StreamingText.tsx
Normal file
136
desktop/src/components/ai/StreamingText.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
desktop/src/components/ai/SuggestionChips.tsx
Normal file
48
desktop/src/components/ai/SuggestionChips.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
desktop/src/components/ai/TaskProgress.tsx
Normal file
169
desktop/src/components/ai/TaskProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
desktop/src/components/ai/TokenMeter.tsx
Normal file
121
desktop/src/components/ai/TokenMeter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
255
desktop/src/components/ai/ToolCallChain.tsx
Normal file
255
desktop/src/components/ai/ToolCallChain.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
desktop/src/components/ai/index.ts
Normal file
11
desktop/src/components/ai/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user