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:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user