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
303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
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);
|
|
}
|