Files
zclaw_openfang/desktop/src/components/ai/ArtifactPanel.tsx
iven 73ff5e8c5e
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
feat(desktop): DeerFlow visual redesign + stream hang fix + intelligence client
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
2026-04-01 22:03:07 +08:00

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);
}