From 4c31471cd65b367f19f0ea1c98923cc51b0a918b Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 24 Apr 2026 10:59:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(artifact):=20=E4=BA=A7=E7=89=A9=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E4=BC=98=E5=8C=96=20=E2=80=94=20=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=20+=20=E6=95=B0=E6=8D=AE=E6=BA=90=E6=89=A9?= =?UTF-8?q?=E5=B1=95=20+=20=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MarkdownRenderer: 从 StreamingText 提取共享 react-markdown + remark-gfm 组件 - ArtifactPanel: 替换手写 MarkdownPreview 为完整 GFM 渲染,添加文件选择器下拉菜单 - 数据源: file_write/str_replace 双工具 + sendMessage/initStreamListener 双路径 - 持久化: artifactStore 添加 zustand persist + IndexedDB (复用 idb-storage) --- desktop/src/components/ai/ArtifactPanel.tsx | 154 +++++---- .../src/components/ai/MarkdownRenderer.tsx | 123 +++++++ desktop/src/components/ai/StreamingText.tsx | 117 +------ desktop/src/components/ai/index.ts | 1 + desktop/src/store/chat/artifactStore.ts | 49 +-- desktop/src/store/chat/streamStore.ts | 102 ++++-- docs/references/artifact-system-reference.md | 309 ++++++++++++++++++ wiki/chat.md | 4 +- wiki/log.md | 7 + 9 files changed, 627 insertions(+), 239 deletions(-) create mode 100644 desktop/src/components/ai/MarkdownRenderer.tsx create mode 100644 docs/references/artifact-system-reference.md diff --git a/desktop/src/components/ai/ArtifactPanel.tsx b/desktop/src/components/ai/ArtifactPanel.tsx index 2aa0452..d272001 100644 --- a/desktop/src/components/ai/ArtifactPanel.tsx +++ b/desktop/src/components/ai/ArtifactPanel.tsx @@ -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 (
- {/* File header */} + {/* File header with inline file selector */}
- - - - {selected.name} - +
+ + + {/* File selector dropdown */} + {fileMenuOpen && artifacts.length > 1 && ( + <> +
setFileMenuOpen(false)} /> +
+ {artifacts.map((artifact) => { + const ItemIcon = getFileIcon(artifact.type); + return ( + + ); + })} +
+ + )} +
+ +
+ {getTypeLabel(selected.type)} + {selected.language && ( + + {selected.language} + + )}
{/* View mode toggle */} @@ -180,19 +219,7 @@ export function ArtifactPanel({ {/* Content area */}
{viewMode === 'preview' ? ( -
- {selected.type === 'markdown' ? ( - - ) : selected.type === 'code' ? ( -
-                {selected.content}
-              
- ) : ( -
-                {selected.content}
-              
- )} -
+ ) : (
             {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 ;
+  }
+
+  if (artifact.type === 'code') {
+    return (
+      
+ {artifact.language && ( +
+ {artifact.language} +
+ )} +
+          {artifact.content}
+        
+
+ ); + } + + return ( +
+      {artifact.content}
+    
+ ); +} + // --------------------------------------------------------------------------- // 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 ( -
- {lines.map((line, i) => { - // Heading - if (line.startsWith('### ')) { - return

{line.slice(4)}

; - } - if (line.startsWith('## ')) { - return

{line.slice(3)}

; - } - if (line.startsWith('# ')) { - return

{line.slice(2)}

; - } - // Code block (simplified) - if (line.startsWith('```')) return null; - // List item - if (line.startsWith('- ') || line.startsWith('* ')) { - return
  • {renderInline(line.slice(2))}
  • ; - } - // Empty line - if (!line.trim()) return
    ; - // Regular paragraph - return

    {renderInline(line)}

    ; - })} -
    - ); -} - -function renderInline(text: string): React.ReactNode { - // Bold - const parts = text.split(/\*\*(.*?)\*\*/g); - return parts.map((part, i) => - i % 2 === 1 ? {part} : part - ); -} - // --------------------------------------------------------------------------- // Download helper // --------------------------------------------------------------------------- diff --git a/desktop/src/components/ai/MarkdownRenderer.tsx b/desktop/src/components/ai/MarkdownRenderer.tsx new file mode 100644 index 0000000..4cac15e --- /dev/null +++ b/desktop/src/components/ai/MarkdownRenderer.tsx @@ -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 ( +
    +        {children}
    +      
    + ); + }, + code({ className, children, ...props }) { + const isBlock = className?.startsWith('language-'); + if (isBlock) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + }, + table({ children }) { + return ( +
    + + {children} +
    +
    + ); + }, + thead({ children }) { + return {children}; + }, + th({ children }) { + return ( + + {children} + + ); + }, + td({ children }) { + return ( + + {children} + + ); + }, + ul({ children }) { + return
      {children}
    ; + }, + ol({ children }) { + return
      {children}
    ; + }, + li({ children }) { + return
  • {children}
  • ; + }, + h1({ children }) { + return

    {children}

    ; + }, + h2({ children }) { + return

    {children}

    ; + }, + h3({ children }) { + return

    {children}

    ; + }, + blockquote({ children }) { + return ( +
    + {children} +
    + ); + }, + p({ children }) { + return

    {children}

    ; + }, + a({ href, children }) { + return ( + + {children} + + ); + }, + hr() { + return
    ; + }, +}; + +// --------------------------------------------------------------------------- +// Convenience wrapper +// --------------------------------------------------------------------------- + +interface MarkdownRendererProps { + content: string; + className?: string; +} + +export function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) { + return ( +
    + + {content} + +
    + ); +} diff --git a/desktop/src/components/ai/StreamingText.tsx b/desktop/src/components/ai/StreamingText.tsx index 58e5f8a..28b33e1 100644 --- a/desktop/src/components/ai/StreamingText.tsx +++ b/desktop/src/components/ai/StreamingText.tsx @@ -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 ( -
    -        {children}
    -      
    - ); - }, - // 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 ( - - {children} - - ); - } - return ( - - {children} - - ); - }, - // Tables - table({ children }) { - return ( -
    - - {children} -
    -
    - ); - }, - thead({ children }) { - return {children}; - }, - th({ children }) { - return ( - - {children} - - ); - }, - td({ children }) { - return ( - - {children} - - ); - }, - // Unordered lists - ul({ children }) { - return
      {children}
    ; - }, - // Ordered lists - ol({ children }) { - return
      {children}
    ; - }, - // List items - li({ children }) { - return
  • {children}
  • ; - }, - // Headings - h1({ children }) { - return

    {children}

    ; - }, - h2({ children }) { - return

    {children}

    ; - }, - h3({ children }) { - return

    {children}

    ; - }, - // Blockquotes - blockquote({ children }) { - return ( -
    - {children} -
    - ); - }, - // Paragraphs - p({ children }) { - return

    {children}

    ; - }, - // Links - a({ href, children }) { - return ( - - {children} - - ); - }, - // Horizontal rules - hr() { - return
    ; - }, -}; - // --------------------------------------------------------------------------- // 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 ( -
    - - {content} - -
    - ); + return ; } // For streaming messages, use token-by-token animation diff --git a/desktop/src/components/ai/index.ts b/desktop/src/components/ai/index.ts index 419a2a4..3af6306 100644 --- a/desktop/src/components/ai/index.ts +++ b/desktop/src/components/ai/index.ts @@ -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'; diff --git a/desktop/src/store/chat/artifactStore.ts b/desktop/src/store/chat/artifactStore.ts index bd9747c..6564263 100644 --- a/desktop/src/store/chat/artifactStore.ts +++ b/desktop/src/store/chat/artifactStore.ts @@ -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()((set) => ({ - artifacts: [], - selectedArtifactId: null, - artifactPanelOpen: false, +export const useArtifactStore = create()( + 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, + }), + }, + ), +); diff --git a/desktop/src/store/chat/streamStore.ts b/desktop/src/store/chat/streamStore.ts index 945de77..3a220c9 100644 --- a/desktop/src/store/chat/streamStore.ts +++ b/desktop/src/store/chat/streamStore.ts @@ -219,6 +219,67 @@ class DeltaBuffer { } } +// --------------------------------------------------------------------------- +// Artifact creation from tool output (shared between sendMessage & agent stream) +// --------------------------------------------------------------------------- + +const ARTIFACT_TYPE_MAP: Record = { + 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 = { + 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 = { - 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 = { - 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()( 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 = { diff --git a/docs/references/artifact-system-reference.md b/docs/references/artifact-system-reference.md new file mode 100644 index 0000000..5673b7f --- /dev/null +++ b/docs/references/artifact-system-reference.md @@ -0,0 +1,309 @@ +# 产物系统参考文档 + +> 调研 DeerFlow 和 Hermes Agent 的产物/输出面板实现,为 ZCLAW 产物系统重构提供参考。 +> 分析日期:2026-04-24 + +--- + +## 一、DeerFlow 产物系统 + +DeerFlow 有完整的全栈产物管道,是主要参考对象。 + +### 1.1 端到端数据流 + +``` +Agent tool call (write_file / str_replace / present_files) + ↓ +Backend: ThreadState.artifacts (LangGraph annotated list, merge_artifacts reducer 去重) + ↓ 文件写入: {base_dir}/threads/{thread_id}/user-data/outputs/ + ↓ 虚拟路径: /mnt/user-data/outputs/filename.ext + ↓ +Backend API: GET /api/threads/{thread_id}/artifacts/{virtual_path} + ↓ MIME 检测 / .skill ZIP 解压 / download vs inline + ↓ +Frontend: thread.values.artifacts (string[]) → ArtifactsProvider context + ↓ +ChatBox (ResizablePanelGroup) → chat(60%) | artifact panel(40%) + ↓ +ArtifactFileDetail → CodeMirror(代码) / Streamdown(Markdown) / iframe(HTML) +``` + +### 1.2 关键文件 + +#### 前端核心 + +| 文件 | 职责 | +|------|------| +| `frontend/src/core/artifacts/utils.ts` | URL 构建、产物列表提取、路径解析 | +| `frontend/src/core/artifacts/loader.ts` | 从后端 API 获取产物文本;从 tool call args 直接提取内容 | +| `frontend/src/core/artifacts/hooks.ts` | TanStack React Query hook,5 分钟缓存 | +| `frontend/src/components/workspace/artifacts/context.tsx` | ArtifactsProvider + useArtifacts() — 管理列表、选中、开关、自动选中 | +| `frontend/src/components/workspace/artifacts/artifact-file-detail.tsx` | 产物详情视图:头部(文件选择器+code/preview切换) + CodeEditor/Preview | +| `frontend/src/components/workspace/artifacts/artifact-file-list.tsx` | 卡片式列表视图,每个卡片含图标/名称/扩展名/下载/安装按钮 | +| `frontend/src/components/workspace/artifacts/artifact-trigger.tsx` | 头部触发按钮,仅在产物存在时显示 | + +#### 前端渲染 + +| 文件 | 职责 | +|------|------| +| `frontend/src/components/workspace/code-editor.tsx` | CodeMirror 只读编辑器,支持 CSS/HTML/JS/JSON/MD/Python 语法高亮 | +| `frontend/src/components/ai-elements/code-block.tsx` | Shiki 语法高亮代码块,双主题(light/dark) | +| `frontend/src/components/ai-elements/web-preview.tsx` | iframe 网页预览,含地址栏和导航按钮 | +| `frontend/src/components/workspace/messages/markdown-content.tsx` | Streamdown 渲染 Markdown (GFM + Math + Raw HTML + KaTeX) | +| `frontend/src/core/utils/files.tsx` | 140+ 扩展名→语言映射,文件图标/类型判断 | + +#### 后端 + +| 文件 | 职责 | +|------|------| +| `backend/.../thread_state.py` | ThreadState.artifacts 列表 + merge_artifacts 去重 reducer | +| `backend/.../present_file_tool.py` | present_files 工具 — 标准化路径,返回 Command(update) | +| `backend/.../paths.py` | 路径管理:threads/{id}/user-data/{workspace,uploads,outputs} | +| `backend/app/gateway/routers/artifacts.py` | FastAPI 路由:GET 产物文件,MIME 检测,安全处理 | + +### 1.3 支持的内容类型 + +| 类型 | 渲染方式 | +|------|----------| +| 代码文件 (140+ 扩展名) | CodeMirror 只读 + 语法高亮 | +| Markdown (.md) | Streamdown (GFM + Math + KaTeX + Raw HTML) | +| HTML (.html/.htm) | 沙箱 `