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
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:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
123
desktop/src/components/ai/MarkdownRenderer.tsx
Normal file
123
desktop/src/components/ai/MarkdownRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
309
docs/references/artifact-system-reference.md
Normal file
309
docs/references/artifact-system-reference.md
Normal file
@@ -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) | 沙箱 `<iframe>` (srcDoc) |
|
||||
| 图片 (.png/.jpg/.svg/.webp) | `<img>` 标签,非代码文件用 iframe |
|
||||
| .skill 压缩包 | ZIP 解压,SKILL.md 渲染为 Markdown |
|
||||
| 二进制文件 (PDF 等) | 后端 inline Content-Disposition |
|
||||
| 文本文件 (.txt/.csv/.log) | CodeMirror 纯文本模式 |
|
||||
|
||||
### 1.4 持久化架构
|
||||
|
||||
**磁盘存储:**
|
||||
```
|
||||
{DEER_FLOW_HOME}/threads/{thread_id}/user-data/outputs/
|
||||
```
|
||||
|
||||
**状态持久化:** artifacts 列表是 LangGraph ThreadState 的一部分,由 checkpoint 系统自动持久化。
|
||||
|
||||
**前端缓存:** TanStack React Query,5 分钟 stale time。
|
||||
|
||||
### 1.5 UI/UX 设计模式
|
||||
|
||||
#### 分栏布局 (chat-box.tsx)
|
||||
- `react-resizable-panels` 水平分栏
|
||||
- 关闭态:chat=100%, artifacts=0%
|
||||
- 打开态:chat=60%, artifacts=40%
|
||||
- 300ms CSS 过渡动画
|
||||
|
||||
#### 自动打开 + 自动选中
|
||||
- 检测到 `write_file` / `str_replace` tool call 时自动打开面板并选中文件
|
||||
- `autoOpen` / `autoSelect` 标志防止用户手动关闭后重复打开
|
||||
|
||||
#### 代码/预览切换
|
||||
- HTML/Markdown 默认 Preview,其他默认 Code
|
||||
- Preview 用 Streamdown(MD) 或 iframe(HTML)
|
||||
|
||||
#### 头部操作栏
|
||||
- 文件选择器下拉菜单(不用返回列表即可切换)
|
||||
- 复制 / 下载 / 新窗口打开 / 关闭
|
||||
|
||||
#### 聊天内嵌展示
|
||||
- `present_files` tool call → 聊天流内渲染卡片网格
|
||||
- 点击卡片 → 侧栏打开该文件
|
||||
|
||||
#### 双路径方案
|
||||
1. **真实文件路径** — 从后端 API 获取,React Query 缓存
|
||||
2. **`write-file:` 虚拟路径** — 直接从 tool call args 提取内容,无需后端请求,支持流式显示
|
||||
|
||||
### 1.6 Provider 层级
|
||||
|
||||
```
|
||||
ArtifactsProvider → 提供useArtifacts() context
|
||||
ChatBox → ResizablePanelGroup
|
||||
Panel(chat) → MessageList → ToolCall 自动打开产物面板
|
||||
Panel(artifacts) → ArtifactFileDetail → useArtifactContent() hook
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、Hermes Agent 产物机制
|
||||
|
||||
> **结论:Hermes Agent 无产物面板、无 Web 前端、无分栏布局。** 它是终端 CLI 工具,所有输出在终端内联渲染。但有值得借鉴的大输出处理机制。
|
||||
|
||||
### 2.1 项目定位
|
||||
|
||||
Hermes Agent 是 **Python CLI/TUI Agent**(类似 Claude Code),通过 prompt_toolkit TUI 运行,同时支持 Telegram/Discord/Slack/WhatsApp 等 IM 平台网关。
|
||||
|
||||
**无 React/Next.js/Web UI。** 暴露 OpenAI 兼容 API 供 Open WebUI/LobeChat 等第三方 UI 接入。
|
||||
|
||||
### 2.2 大输出处理(3 层防御)
|
||||
|
||||
这是唯一接近"产物管理"的机制,值得借鉴。
|
||||
|
||||
**文件:`tools/tool_result_storage.py`**
|
||||
|
||||
| 层级 | 机制 | 说明 |
|
||||
|------|------|------|
|
||||
| Layer 1 | 工具自身截断 | 每个工具限制自己的输出长度 |
|
||||
| Layer 2 | `maybe_persist_tool_result` | 单个结果超阈值 → 写入沙箱临时文件,上下文中替换为 `<persisted-output>` 预览块 |
|
||||
| Layer 3 | `enforce_turn_budget` | 整轮超过 200K 字符 → 最大的几个溢出到磁盘 |
|
||||
|
||||
核心逻辑:
|
||||
```python
|
||||
# 超阈值时:完整内容写入文件,上下文替换为预览
|
||||
remote_path = f"{storage_dir}/{tool_use_id}.txt"
|
||||
_write_to_sandbox(content, remote_path, env)
|
||||
return _build_persisted_message(preview, has_more, len(content), remote_path)
|
||||
# 后续 agent 可用 read_file + offset/limit 读取完整内容
|
||||
```
|
||||
|
||||
### 2.3 预算配置
|
||||
|
||||
**文件:`tools/budget_config.py`**
|
||||
|
||||
| 参数 | 默认值 |
|
||||
|------|--------|
|
||||
| `DEFAULT_RESULT_SIZE_CHARS` | 100,000(单工具阈值)|
|
||||
| `DEFAULT_TURN_BUDGET_CHARS` | 200,000(整轮上限)|
|
||||
| `DEFAULT_PREVIEW_SIZE_CHARS` | 1,500(内联预览长度)|
|
||||
|
||||
### 2.4 CLI 渲染方式
|
||||
|
||||
**文件:`agent/display.py`**
|
||||
|
||||
- **工具进度**:KawaiiSpinner 动画 + 一行摘要
|
||||
- **文件编辑**:内联 colored unified diff(write_file / patch 工具)
|
||||
- **最终响应**:Rich Panel 边框包裹,主题色可换(7 套 skin)
|
||||
|
||||
### 2.5 会话持久化
|
||||
|
||||
**文件:`hermes_state.py`**
|
||||
|
||||
SQLite (`~/.hermes/state.db`) + FTS5 全文搜索:
|
||||
- sessions 表:元数据、模型配置、token 计数、费用、标题
|
||||
- messages 表:role、content、tool_call_id、reasoning、时间戳
|
||||
|
||||
### 2.6 值得借鉴的点
|
||||
|
||||
| 点 | 借鉴价值 |
|
||||
|----|----------|
|
||||
| 大输出溢出到磁盘 + 内联预览 | 解决 context window 溢出问题 |
|
||||
| 3 层递进防御 | 对 ZCLAW 中间件链有参考价值 |
|
||||
| 预算配置化 | 阈值可调,不同场景不同策略 |
|
||||
|
||||
---
|
||||
|
||||
## 三、对比分析:ZCLAW 现状 vs 参考方案
|
||||
|
||||
### 3.1 现状差距
|
||||
|
||||
| 维度 | DeerFlow | ZCLAW 现状 | 差距 |
|
||||
|------|----------|------------|------|
|
||||
| 数据源 | 3 个工具(present_files/write_file/str_replace)主动注册 | 仅 streamStore 解析 tool output 的 filePath | 极窄,几乎不触发 |
|
||||
| 持久化 | 磁盘文件 + LangGraph checkpoint | 纯内存 Zustand | 刷新即丢失 |
|
||||
| 渲染-代码 | CodeMirror 只读 + 语法高亮 (140+ 语言) | 纯 `<pre>` 标签,无高亮 | 无高亮 |
|
||||
| 渲染-Markdown | Streamdown (GFM+Math+KaTeX+RawHTML) | 手写 30 行正则渲染器 | 仅标题/粗体/列表 |
|
||||
| 渲染-HTML | 沙箱 iframe | 不支持 | 无 |
|
||||
| 渲染-图片 | `<img>` + iframe | 类型声明了无实现 | 无 |
|
||||
| 渲染-表格 | GFM 表格 | 纯文本 `<pre>` | 无 |
|
||||
| 面板布局 | react-resizable-panels 60/40 | react-resizable-panels 65/35 | 已有,可复用 |
|
||||
| 自动打开 | write_file/str_replace 触发 | addArtifact 时打开 | 已有 |
|
||||
| 文件选择 | 下拉菜单不离开详情视图 | 必须返回列表再选 | 体验差 |
|
||||
| 聊天内嵌 | present_files → 卡片网格 | 无 | 缺失 |
|
||||
| 缓存 | React Query 5min | 无 | 缺失 |
|
||||
| 双路径 | 真实路径 + write-file: 虚拟路径 | 仅运行时内存 | 缺失 |
|
||||
| 右面板重叠 | 单一面板 | ArtifactPanel + RightPanel"文件"tab 职责交叉 | 架构问题 |
|
||||
|
||||
### 3.2 核心差距总结
|
||||
|
||||
**按优先级排列:**
|
||||
|
||||
1. **P0 数据源断裂** — 产物几乎没有来源,是最根本的问题
|
||||
2. **P0 无持久化** — 产物刷新即丢
|
||||
3. **P1 Markdown 渲染残缺** — 30 行正则 vs 完整 GFM 渲染器
|
||||
4. **P1 代码无语法高亮** — 纯 `<pre>` vs CodeMirror/Shiki
|
||||
5. **P2 双面板职责交叉** — ArtifactPanel vs RightPanel"文件"tab
|
||||
6. **P2 缺少详情内文件切换** — 需返回列表才能切换文件
|
||||
7. **P3 聊天内嵌产物卡片缺失**
|
||||
8. **P3 HTML/图片/表格渲染缺失**
|
||||
|
||||
### 3.3 推荐方案
|
||||
|
||||
#### 方案 A:最小可行(基于现有架构补全)
|
||||
|
||||
在现有 ArtifactPanel + artifactStore 上修补:
|
||||
|
||||
- **数据源**:扩展 streamStore 中的 tool output 解析,覆盖更多工具类型
|
||||
- **持久化**:artifactStore 追加 IndexedDB 写入(复用 messageStore 模式)
|
||||
- **Markdown**:引入 `react-markdown` + `remark-gfm` 替换手写渲染器
|
||||
- **代码高亮**:引入 `shiki` 或 `highlight.js`
|
||||
- **合并面板**:RightPanel "文件"tab 功能合并到 ArtifactPanel,删除 RightPanel 的 files tab
|
||||
|
||||
**工作量**:~2-3 天
|
||||
|
||||
#### 方案 B:参照 DeerFlow 重构(推荐)
|
||||
|
||||
借鉴 DeerFlow 架构但适配 ZCLAW Tauri 本地架构:
|
||||
|
||||
| DeerFlow 组件 | ZCLAW 适配 |
|
||||
|---------------|------------|
|
||||
| FastAPI 产物路由 | Tauri 命令 `artifact_list` / `artifact_read` / `artifact_serve` |
|
||||
| 磁盘 outputs/ 目录 | `{workspace}/artifacts/{session_key}/` |
|
||||
| LangGraph checkpoint | SQLite (已有 zclaw-memory) |
|
||||
| React Query 缓存 | TanStack Query 或 Zustand + stale cache |
|
||||
| CodeMirror 只读 | 引入 @uiw/react-codemirror |
|
||||
| Streamdown MD | react-markdown + remark-gfm + rehype-katex |
|
||||
| iframe HTML 预览 | Tauri webview window (安全隔离) |
|
||||
|
||||
**核心改动清单:**
|
||||
|
||||
1. **Rust 侧**(zclaw-kernel):
|
||||
- 新增 `artifact_create` / `artifact_list` / `artifact_read` Tauri 命令
|
||||
- 产物写入 `{workspace}/artifacts/{session_key}/`
|
||||
- 中间件链中 ToolEnd 事件触发产物注册
|
||||
|
||||
2. **前端 Store**:
|
||||
- artifactStore 增加 IndexedDB 持久化
|
||||
- 从 streamStore 解耦产物创建逻辑到独立 hook
|
||||
|
||||
3. **前端组件**:
|
||||
- 替换 MarkdownPreview → react-markdown + GFM
|
||||
- 引入 CodeMirror/shiki 代码高亮
|
||||
- 详情视图增加文件下拉切换
|
||||
- RightPanel "文件" tab 合并或移除
|
||||
|
||||
**工作量**:~5-7 天
|
||||
|
||||
#### 方案 C:借鉴 Hermes 防御机制(附加)
|
||||
|
||||
无论选 A 还是 B,都可叠加 Hermes 的大输出防御:
|
||||
|
||||
- 中间件链 ToolOutputGuard 层增加溢出检测
|
||||
- 超阈值产物自动持久化到磁盘,上下文替换为 `<persisted-output>` 预览
|
||||
- agent 可通过 read_file 回读完整内容
|
||||
|
||||
---
|
||||
|
||||
## 四、关键依赖库参考
|
||||
|
||||
| 库 | 用途 | DeerFlow 使用 | 推荐 |
|
||||
|----|------|--------------|------|
|
||||
| react-markdown | Markdown 渲染 | ✅ (Streamdown) | ✅ |
|
||||
| remark-gfm | GFM 表格/删除线/任务列表 | ✅ | ✅ |
|
||||
| rehype-katex | 数学公式渲染 | ✅ | 按需 |
|
||||
| @uiw/react-codemirror | 代码编辑器/高亮 | ✅ | ✅ |
|
||||
| shiki | 静态代码高亮 | ✅ (chat 内代码块) | ✅ |
|
||||
| react-resizable-panels | 分栏布局 | ✅ | 已有 |
|
||||
| @tanstack/react-query | 数据缓存 | ✅ | 可选 |
|
||||
|
||||
---
|
||||
|
||||
## 五、文件索引
|
||||
|
||||
| 参考项目 | 关键路径 |
|
||||
|----------|----------|
|
||||
| DeerFlow 前端 | `G:/deerflow/frontend/src/components/workspace/artifacts/` |
|
||||
| DeerFlow 前端工具 | `G:/deerflow/frontend/src/core/artifacts/` |
|
||||
| DeerFlow 布局 | `G:/deerflow/frontend/src/components/workspace/chats/chat-box.tsx` |
|
||||
| DeerFlow 代码编辑 | `G:/deerflow/frontend/src/components/workspace/code-editor.tsx` |
|
||||
| DeerFlow 后端路由 | `G:/deerflow/backend/app/gateway/routers/artifacts.py` |
|
||||
| DeerFlow 后端工具 | `G:/deerflow/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py` |
|
||||
| Hermes 输出管理 | `G:/hermes-agent-main/tools/tool_result_storage.py` |
|
||||
| Hermes 预算配置 | `G:/hermes-agent-main/tools/budget_config.py` |
|
||||
@@ -146,10 +146,8 @@ onComplete → createCompleteHandler
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 04-24 | 产物系统优化: MarkdownRenderer 提取共享 + ArtifactPanel react-markdown 渲染 + 文件选择器下拉 + 数据源扩展(file_write/str_replace 两路径) + artifactStore IndexedDB 持久化 |
|
||||
| 04-23 | 建议 prefetch: sendMessage 时启动 context 预取,流结束后立即消费,不等 memory extraction |
|
||||
| 04-23 | 建议 prompt 重写: 1深入追问+1实用行动+1管家关怀,上下文窗口 6→20 条 |
|
||||
| 04-23 | 身份信号: detectAgentNameSuggestion 前端即时检测 + RightPanel 监听 Tauri 事件刷新名称 |
|
||||
| 04-23 | Agent tab 移除: RightPanel 清理 ~280 行 dead code,身份由 soul.md 接管 |
|
||||
| 04-23 | 澄清问题卡片 UX 优化: 去悬空引用 + 默认展开 |
|
||||
| 04-22 | Wiki 重写: 5 节模板,增加集成契约和不变量 |
|
||||
| 04-21 | 上一轮更新 |
|
||||
|
||||
@@ -9,6 +9,13 @@ tags: [log, history]
|
||||
|
||||
> Append-only 操作记录。格式: `## [日期] 类型 | 描述`
|
||||
|
||||
## [2026-04-24] feat(artifact) | 产物系统优化完善
|
||||
- **MarkdownRenderer**: 从 StreamingText 提取共享 Markdown 渲染组件(react-markdown + remark-gfm),ArtifactPanel 复用
|
||||
- **ArtifactPanel**: 替换手写 30 行 MarkdownPreview → 完整 GFM 渲染(表格/代码块/列表/引用);添加文件选择器下拉菜单
|
||||
- **数据源扩展**: 产物创建从 file_write 单工具 → file_write/str_replace/write_file/str_replace_editor;从 sendMessage 单路径 → sendMessage + initStreamListener 双路径
|
||||
- **持久化**: artifactStore 添加 zustand persist + IndexedDB (复用 idb-storage),刷新后产物保留
|
||||
- **验证**: tsc --noEmit PASS, 343 vitest PASS
|
||||
|
||||
## [2026-04-24] perf | Hermes 高价值设计实施 Phase 1-4
|
||||
- **Phase 1**: Anthropic prompt caching — cache_control ephemeral + cache token tracking (CompletionResponse + StreamChunk)
|
||||
- **Phase 2A**: 并行工具执行 — ToolConcurrency 枚举 (ReadOnly/Exclusive/Interactive) + JoinSet + Semaphore(3) + AtomicU32
|
||||
|
||||
Reference in New Issue
Block a user