feat(artifact): 产物系统优化 — 共享渲染 + 数据源扩展 + 持久化
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

- MarkdownRenderer: 从 StreamingText 提取共享 react-markdown + remark-gfm 组件
- ArtifactPanel: 替换手写 MarkdownPreview 为完整 GFM 渲染,添加文件选择器下拉菜单
- 数据源: file_write/str_replace 双工具 + sendMessage/initStreamListener 双路径
- 持久化: artifactStore 添加 zustand persist + IndexedDB (复用 idb-storage)
This commit is contained in:
iven
2026-04-24 10:59:27 +08:00
parent b60b96225d
commit 4c31471cd6
9 changed files with 627 additions and 239 deletions

View File

@@ -6,9 +6,10 @@ import {
Image as ImageIcon,
Download,
Copy,
ChevronLeft,
ChevronDown,
File,
} from 'lucide-react';
import { MarkdownRenderer } from './MarkdownRenderer';
// ---------------------------------------------------------------------------
// Types
@@ -76,6 +77,7 @@ export function ArtifactPanel({
className = '',
}: ArtifactPanelProps) {
const [viewMode, setViewMode] = useState<'preview' | 'code'>('preview');
const [fileMenuOpen, setFileMenuOpen] = useState(false);
const selected = useMemo(
() => artifacts.find((a) => a.id === selectedId),
[artifacts, selectedId]
@@ -135,22 +137,59 @@ export function ArtifactPanel({
return (
<div className={`h-full flex flex-col ${className}`}>
{/* File header */}
{/* File header with inline file selector */}
<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>
<div className="relative">
<button
onClick={() => setFileMenuOpen(!fileMenuOpen)}
className="flex items-center gap-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 truncate hover:text-orange-500 transition-colors"
title="切换文件"
>
<Icon className="w-4 h-4 text-orange-500 flex-shrink-0" />
<span className="truncate max-w-[120px]">{selected.name}</span>
{artifacts.length > 1 && (
<ChevronDown className={`w-3.5 h-3.5 text-gray-400 transition-transform ${fileMenuOpen ? 'rotate-180' : ''}`} />
)}
</button>
{/* File selector dropdown */}
{fileMenuOpen && artifacts.length > 1 && (
<>
<div className="fixed inset-0 z-10" onClick={() => setFileMenuOpen(false)} />
<div className="absolute top-full left-0 mt-1 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20 py-1 max-h-60 overflow-y-auto">
{artifacts.map((artifact) => {
const ItemIcon = getFileIcon(artifact.type);
return (
<button
key={artifact.id}
onClick={() => { onSelect(artifact.id); setFileMenuOpen(false); }}
className={`w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${
artifact.id === selected.id ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300' : 'text-gray-700 dark:text-gray-200'
}`}
>
<ItemIcon className="w-4 h-4 flex-shrink-0" />
<span className="truncate flex-1">{artifact.name}</span>
<span className={`text-[10px] px-1 py-0.5 rounded ${getTypeColor(artifact.type)}`}>
{getTypeLabel(artifact.type)}
</span>
</button>
);
})}
</div>
</>
)}
</div>
<div className="flex-1" />
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${getTypeColor(selected.type)}`}>
{getTypeLabel(selected.type)}
</span>
{selected.language && (
<span className="text-[10px] text-gray-400 dark:text-gray-500">
{selected.language}
</span>
)}
</div>
{/* View mode toggle */}
@@ -180,19 +219,7 @@ export function ArtifactPanel({
{/* 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>
<ArtifactContentPreview artifact={selected} />
) : (
<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}
@@ -217,6 +244,37 @@ export function ArtifactPanel({
);
}
// ---------------------------------------------------------------------------
// ArtifactContentPreview — renders artifact based on type
// ---------------------------------------------------------------------------
function ArtifactContentPreview({ artifact }: { artifact: ArtifactFile }) {
if (artifact.type === 'markdown') {
return <MarkdownRenderer content={artifact.content} />;
}
if (artifact.type === 'code') {
return (
<div className="relative">
{artifact.language && (
<div className="absolute top-2 right-2 text-[10px] text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded">
{artifact.language}
</div>
)}
<pre className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 text-xs font-mono overflow-x-auto text-gray-700 dark:text-gray-200 leading-relaxed border border-gray-200 dark:border-gray-700">
{artifact.content}
</pre>
</div>
);
}
return (
<pre className="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-200">
{artifact.content}
</pre>
);
}
// ---------------------------------------------------------------------------
// ActionButton
// ---------------------------------------------------------------------------
@@ -243,50 +301,6 @@ function ActionButton({ icon, label, onClick }: { icon: React.ReactNode; label:
);
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,123 @@
/**
* MarkdownRenderer — shared Markdown rendering with styled components.
*
* Extracted from StreamingText.tsx so ArtifactPanel and other consumers
* can reuse the same rich rendering (GFM tables, syntax blocks, etc.)
* without duplicating the component overrides.
*/
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { Components } from 'react-markdown';
// ---------------------------------------------------------------------------
// Shared component overrides for react-markdown
// ---------------------------------------------------------------------------
export const markdownComponents: Components = {
pre({ children }) {
return (
<pre className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto text-sm leading-relaxed border border-gray-200 dark:border-gray-700 my-3">
{children}
</pre>
);
},
code({ className, children, ...props }) {
const isBlock = className?.startsWith('language-');
if (isBlock) {
return (
<code className={`${className || ''} text-gray-800 dark:text-gray-200`} {...props}>
{children}
</code>
);
}
return (
<code className="bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 px-1.5 py-0.5 rounded text-[0.9em] font-mono" {...props}>
{children}
</code>
);
},
table({ children }) {
return (
<div className="overflow-x-auto my-3 -mx-1">
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700 rounded-lg text-sm">
{children}
</table>
</div>
);
},
thead({ children }) {
return <thead className="bg-gray-50 dark:bg-gray-800/50">{children}</thead>;
},
th({ children }) {
return (
<th className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">
{children}
</th>
);
},
td({ children }) {
return (
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-gray-600 dark:text-gray-400">
{children}
</td>
);
},
ul({ children }) {
return <ul className="list-disc list-outside ml-5 my-2 space-y-1">{children}</ul>;
},
ol({ children }) {
return <ol className="list-decimal list-outside ml-5 my-2 space-y-1">{children}</ol>;
},
li({ children }) {
return <li className="leading-relaxed">{children}</li>;
},
h1({ children }) {
return <h1 className="text-xl font-bold mt-5 mb-3 text-gray-900 dark:text-gray-100 first:mt-0">{children}</h1>;
},
h2({ children }) {
return <h2 className="text-lg font-bold mt-4 mb-2 text-gray-900 dark:text-gray-100 first:mt-0">{children}</h2>;
},
h3({ children }) {
return <h3 className="text-base font-semibold mt-3 mb-2 text-gray-900 dark:text-gray-100 first:mt-0">{children}</h3>;
},
blockquote({ children }) {
return (
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 py-1 my-3 text-gray-600 dark:text-gray-400 italic bg-gray-50 dark:bg-gray-800/30 rounded-r-lg">
{children}
</blockquote>
);
},
p({ children }) {
return <p className="my-2 leading-relaxed first:mt-0 last:mb-0">{children}</p>;
},
a({ href, children }) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400 underline hover:text-blue-800 dark:hover:text-blue-300">
{children}
</a>
);
},
hr() {
return <hr className="my-4 border-gray-200 dark:border-gray-700" />;
},
};
// ---------------------------------------------------------------------------
// Convenience wrapper
// ---------------------------------------------------------------------------
interface MarkdownRendererProps {
content: string;
className?: string;
}
export function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
return (
<div className={`prose-sm prose-gray dark:prose-invert max-w-none ${className}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{content}
</ReactMarkdown>
</div>
);
}

View File

@@ -1,7 +1,5 @@
import { useMemo, useRef, useEffect, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { Components } from 'react-markdown';
import { MarkdownRenderer } from './MarkdownRenderer';
/**
* Streaming text with word-by-word reveal animation.
@@ -18,111 +16,6 @@ interface StreamingTextProps {
asMarkdown?: boolean;
}
// ---------------------------------------------------------------------------
// Markdown component overrides for rich rendering
// ---------------------------------------------------------------------------
const markdownComponents: Components = {
// Code blocks (```...```)
pre({ children }) {
return (
<pre className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto text-sm leading-relaxed border border-gray-200 dark:border-gray-700 my-3">
{children}
</pre>
);
},
// Inline code (`...`)
code({ className, children, ...props }) {
// If it has a language class, it's inside a code block — render as block
const isBlock = className?.startsWith('language-');
if (isBlock) {
return (
<code className={`${className || ''} text-gray-800 dark:text-gray-200`} {...props}>
{children}
</code>
);
}
return (
<code className="bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 px-1.5 py-0.5 rounded text-[0.9em] font-mono" {...props}>
{children}
</code>
);
},
// Tables
table({ children }) {
return (
<div className="overflow-x-auto my-3 -mx-1">
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700 rounded-lg text-sm">
{children}
</table>
</div>
);
},
thead({ children }) {
return <thead className="bg-gray-50 dark:bg-gray-800/50">{children}</thead>;
},
th({ children }) {
return (
<th className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">
{children}
</th>
);
},
td({ children }) {
return (
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-gray-600 dark:text-gray-400">
{children}
</td>
);
},
// Unordered lists
ul({ children }) {
return <ul className="list-disc list-outside ml-5 my-2 space-y-1">{children}</ul>;
},
// Ordered lists
ol({ children }) {
return <ol className="list-decimal list-outside ml-5 my-2 space-y-1">{children}</ol>;
},
// List items
li({ children }) {
return <li className="leading-relaxed">{children}</li>;
},
// Headings
h1({ children }) {
return <h1 className="text-xl font-bold mt-5 mb-3 text-gray-900 dark:text-gray-100 first:mt-0">{children}</h1>;
},
h2({ children }) {
return <h2 className="text-lg font-bold mt-4 mb-2 text-gray-900 dark:text-gray-100 first:mt-0">{children}</h2>;
},
h3({ children }) {
return <h3 className="text-base font-semibold mt-3 mb-2 text-gray-900 dark:text-gray-100 first:mt-0">{children}</h3>;
},
// Blockquotes
blockquote({ children }) {
return (
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 py-1 my-3 text-gray-600 dark:text-gray-400 italic bg-gray-50 dark:bg-gray-800/30 rounded-r-lg">
{children}
</blockquote>
);
},
// Paragraphs
p({ children }) {
return <p className="my-2 leading-relaxed first:mt-0 last:mb-0">{children}</p>;
},
// Links
a({ href, children }) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400 underline hover:text-blue-800 dark:hover:text-blue-300">
{children}
</a>
);
},
// Horizontal rules
hr() {
return <hr className="my-4 border-gray-200 dark:border-gray-700" />;
},
};
// ---------------------------------------------------------------------------
// Token splitter for streaming animation
// ---------------------------------------------------------------------------
@@ -176,13 +69,7 @@ export function StreamingText({
}: StreamingTextProps) {
// For completed messages, use full markdown rendering with styled components
if (!isStreaming && asMarkdown) {
return (
<div className={`prose-sm prose-gray dark:prose-invert max-w-none ${className}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{content}
</ReactMarkdown>
</div>
);
return <MarkdownRenderer content={content} className={className} />;
}
// For streaming messages, use token-by-token animation

View File

@@ -8,4 +8,5 @@ export { SuggestionChips } from './SuggestionChips';
export { ResizableChatLayout } from './ResizableChatLayout';
export { ToolCallChain, type ToolCallStep } from './ToolCallChain';
export { ArtifactPanel, type ArtifactFile } from './ArtifactPanel';
export { MarkdownRenderer, markdownComponents } from './MarkdownRenderer';
export { TokenMeter } from './TokenMeter';

View File

@@ -1,13 +1,13 @@
/**
* ArtifactStore — manages the artifact panel state.
* ArtifactStore — manages the artifact panel state with IndexedDB persistence.
*
* Extracted from chatStore.ts as part of the structured refactor.
* This store has zero external dependencies — the simplest slice to extract.
*
* @see docs/superpowers/specs/2026-04-02-chatstore-refactor-design.md §3.5
* Uses zustand/middleware persist + idb-storage for persistence across refreshes.
*/
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { createIdbStorageAdapter } from '../../lib/idb-storage';
import type { ArtifactFile } from '../../components/ai/ArtifactPanel';
// ---------------------------------------------------------------------------
@@ -33,22 +33,33 @@ export interface ArtifactState {
// Store
// ---------------------------------------------------------------------------
export const useArtifactStore = create<ArtifactState>()((set) => ({
artifacts: [],
selectedArtifactId: null,
artifactPanelOpen: false,
export const useArtifactStore = create<ArtifactState>()(
persist(
(set) => ({
artifacts: [],
selectedArtifactId: null,
artifactPanelOpen: false,
addArtifact: (artifact: ArtifactFile) =>
set((state) => ({
artifacts: [...state.artifacts, artifact],
selectedArtifactId: artifact.id,
artifactPanelOpen: true,
})),
addArtifact: (artifact: ArtifactFile) =>
set((state) => ({
artifacts: [...state.artifacts, artifact],
selectedArtifactId: artifact.id,
artifactPanelOpen: true,
})),
selectArtifact: (id: string | null) => set({ selectedArtifactId: id }),
selectArtifact: (id: string | null) => set({ selectedArtifactId: id }),
setArtifactPanelOpen: (open: boolean) => set({ artifactPanelOpen: open }),
setArtifactPanelOpen: (open: boolean) => set({ artifactPanelOpen: open }),
clearArtifacts: () =>
set({ artifacts: [], selectedArtifactId: null, artifactPanelOpen: false }),
}));
clearArtifacts: () =>
set({ artifacts: [], selectedArtifactId: null, artifactPanelOpen: false }),
}),
{
name: 'zclaw-artifact-storage',
storage: createJSONStorage(() => createIdbStorageAdapter()),
partialize: (state) => ({
artifacts: state.artifacts,
}),
},
),
);

View File

@@ -219,6 +219,67 @@ class DeltaBuffer {
}
}
// ---------------------------------------------------------------------------
// Artifact creation from tool output (shared between sendMessage & agent stream)
// ---------------------------------------------------------------------------
const ARTIFACT_TYPE_MAP: Record<string, 'code' | 'markdown' | 'text' | 'table' | 'image'> = {
ts: 'code', tsx: 'code', js: 'code', jsx: 'code',
py: 'code', rs: 'code', go: 'code', java: 'code',
md: 'markdown', txt: 'text', json: 'code',
html: 'code', css: 'code', sql: 'code', sh: 'code',
yaml: 'code', yml: 'code', toml: 'code', xml: 'code',
csv: 'table', svg: 'image',
};
const ARTIFACT_LANG_MAP: Record<string, string> = {
ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
py: 'python', rs: 'rust', go: 'go', java: 'java',
html: 'html', css: 'css', sql: 'sql', sh: 'bash',
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
xml: 'xml', csv: 'csv', md: 'markdown', txt: 'text',
};
/** Attempt to create an artifact from a completed tool call. */
function tryCreateArtifactFromToolOutput(toolName: string, toolInput: string, toolOutput: string): void {
if (!toolOutput) return;
const toolsWithArtifacts = ['file_write', 'write_file', 'str_replace', 'str_replace_editor'];
if (!toolsWithArtifacts.includes(toolName)) return;
try {
const parsed = JSON.parse(toolOutput);
const filePath = parsed?.path || parsed?.file_path || '';
let content = parsed?.content || '';
// For str_replace tools, content may be in input
if (!content && toolInput) {
try {
const inputParsed = JSON.parse(toolInput);
content = inputParsed?.new_text || inputParsed?.content || '';
} catch { /* ignore */ }
}
if (!filePath || !content) return;
// Deduplicate: skip if an artifact with the same path already exists
const existing = useArtifactStore.getState().artifacts;
if (existing.some(a => a.name === filePath.split('/').pop())) return;
const fileName = filePath.split('/').pop() || filePath;
const ext = fileName.split('.').pop()?.toLowerCase() || '';
useArtifactStore.getState().addArtifact({
id: `artifact_${Date.now()}`,
name: fileName,
content: typeof content === 'string' ? content : JSON.stringify(content, null, 2),
type: ARTIFACT_TYPE_MAP[ext] || 'text',
language: ARTIFACT_LANG_MAP[ext],
createdAt: new Date(),
});
} catch { /* non-critical: artifact creation from tool output */ }
}
// ---------------------------------------------------------------------------
// Stream event handlers (extracted from sendMessage)
// ---------------------------------------------------------------------------
@@ -241,38 +302,8 @@ function createToolHandler(assistantId: string, chat: ChatStoreAccess) {
})
);
// Auto-create artifact when file_write tool produces output
if (tool === 'file_write') {
try {
const parsed = JSON.parse(output);
const filePath = parsed?.path || parsed?.file_path || '';
const content = parsed?.content || '';
if (filePath && content) {
const fileName = filePath.split('/').pop() || filePath;
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const typeMap: Record<string, 'code' | 'markdown' | 'text'> = {
ts: 'code', tsx: 'code', js: 'code', jsx: 'code',
py: 'code', rs: 'code', go: 'code', java: 'code',
md: 'markdown', txt: 'text', json: 'code',
html: 'code', css: 'code', sql: 'code', sh: 'code',
};
const langMap: Record<string, string> = {
ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
py: 'python', rs: 'rust', go: 'go', java: 'java',
html: 'html', css: 'css', sql: 'sql', sh: 'bash', json: 'json',
};
useArtifactStore.getState().addArtifact({
id: `artifact_${Date.now()}`,
name: fileName,
content: typeof content === 'string' ? content : JSON.stringify(content, null, 2),
type: typeMap[ext] || 'text',
language: langMap[ext],
createdAt: new Date(),
sourceStepId: assistantId,
});
}
} catch { /* non-critical: artifact creation from tool output */ }
}
// Auto-create artifact from tool output
tryCreateArtifactFromToolOutput(tool, input, output);
} else {
// toolStart: create new running step
const step: ToolCallStep = {
@@ -1027,6 +1058,13 @@ export const useStreamStore = create<StreamState>()(
return { ...m, toolSteps: steps };
})
);
// Auto-create artifact from tool output (agent stream path)
tryCreateArtifactFromToolOutput(
delta.tool || 'unknown',
delta.toolInput || '',
delta.toolOutput,
);
} else {
// toolStart: create new running step
const step: ToolCallStep = {