Compare commits
2 Commits
main
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d944ee546a | ||
|
|
63cc0f878d |
@@ -164,7 +164,7 @@ export const EditorStudio = memo(
|
||||
[onSave],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const handleAutoSave = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) {
|
||||
return;
|
||||
@@ -192,7 +192,7 @@ export const EditorStudio = memo(
|
||||
documents={documents}
|
||||
onLoad={handleLoad}
|
||||
onReady={handleEditorReady}
|
||||
onSave={handleSave}
|
||||
onSave={handleAutoSave}
|
||||
onContentChange={handleContentChange}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,101 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { type Change, diffLines } from 'diff';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { List, type RowComponentProps } from 'react-window';
|
||||
import { createHighlighter } from 'shiki';
|
||||
import '~/styles/diff-view.css';
|
||||
import { webBuilderStore } from '~/.client/stores/web-builder';
|
||||
import { LRUCache } from '~/.client/utils/lru-cache';
|
||||
import { formatCode, normalizeContent } from '~/.client/utils/prettier';
|
||||
import { formatCode } from '~/.client/utils/prettier';
|
||||
import { themeStore } from '~/stores/theme';
|
||||
|
||||
const highlightCache = new LRUCache<string, string>(1000);
|
||||
const formatCache = new LRUCache<string, string>(100);
|
||||
const diffCache = new LRUCache<string, ReturnType<typeof processChanges>>(50);
|
||||
// 高亮结果缓存,使用 Map 存储已高亮的代码行
|
||||
const highlightCache = new Map<string, string>();
|
||||
// 格式化结果缓存,使用 Map 存储已格式化的代码
|
||||
const formatCache = new Map<string, string>();
|
||||
|
||||
// 差异计算结果缓存
|
||||
const diffCache = new Map<string, ReturnType<typeof processChanges>>();
|
||||
|
||||
interface VirtualizedListProps<T> {
|
||||
items: T[];
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
itemHeight: number;
|
||||
className?: string;
|
||||
overscan?: number;
|
||||
}
|
||||
|
||||
function VirtualizedList<T>({ items, renderItem, itemHeight, className = '', overscan = 20 }: VirtualizedListProps<T>) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
|
||||
const [renderedItems, setRenderedItems] = useState<React.ReactNode[]>([]);
|
||||
const [isRendering, setIsRendering] = useState(false);
|
||||
const totalHeight = items.length * itemHeight;
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, clientHeight } = containerRef.current;
|
||||
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
|
||||
const end = Math.min(items.length, Math.ceil((scrollTop + clientHeight) / itemHeight) + overscan);
|
||||
|
||||
setVisibleRange({ start, end });
|
||||
}, [items.length, itemHeight, overscan]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
handleScroll();
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
handleScroll();
|
||||
}, [items.length, handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRendering) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRendering(true);
|
||||
|
||||
setTimeout(() => {
|
||||
const visibleItems = items.slice(visibleRange.start, visibleRange.end);
|
||||
const newRenderedItems = visibleItems.map((item, index) => {
|
||||
const actualIndex = visibleRange.start + index;
|
||||
return (
|
||||
<div
|
||||
key={actualIndex}
|
||||
style={{ position: 'absolute', top: actualIndex * itemHeight, height: itemHeight, width: '100%' }}
|
||||
>
|
||||
{renderItem(item, actualIndex)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
setRenderedItems(newRenderedItems);
|
||||
setIsRendering(false);
|
||||
}, 0);
|
||||
}, [items, visibleRange, itemHeight, renderItem, isRendering]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`virtualized-list ${className}`}
|
||||
style={{ position: 'relative', height: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
<div style={{ height: totalHeight, position: 'relative' }}>{renderedItems}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CodeComparisonProps {
|
||||
beforeCode?: string;
|
||||
@@ -64,36 +148,34 @@ const FullscreenOverlay = memo(({ isFullscreen, children }: { isFullscreen: bool
|
||||
|
||||
const processChanges = (beforeCode?: string, afterCode?: string) => {
|
||||
try {
|
||||
const normalizeContent = (content?: string): string[] => {
|
||||
if (!content) {
|
||||
return [];
|
||||
}
|
||||
const normalizeContent = (content: string): string[] => {
|
||||
return content
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd());
|
||||
};
|
||||
|
||||
if (!beforeCode || !afterCode) {
|
||||
return {
|
||||
beforeLines: [],
|
||||
afterLines: [],
|
||||
hasChanges: false,
|
||||
lineChanges: { before: new Set(), after: new Set() },
|
||||
unifiedBlocks: [],
|
||||
};
|
||||
}
|
||||
|
||||
const beforeLines = normalizeContent(beforeCode);
|
||||
const afterLines = normalizeContent(afterCode);
|
||||
|
||||
if (beforeLines.length === afterLines.length) {
|
||||
let isEqual = true;
|
||||
for (let idx = 0; idx < beforeLines.length; idx++) {
|
||||
if (beforeLines[idx] !== afterLines[idx]) {
|
||||
isEqual = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isEqual) {
|
||||
return {
|
||||
beforeLines,
|
||||
afterLines,
|
||||
hasChanges: false,
|
||||
lineChanges: { before: new Set(), after: new Set() },
|
||||
unifiedBlocks: [],
|
||||
};
|
||||
}
|
||||
if (beforeLines.join('\n') === afterLines.join('\n')) {
|
||||
return {
|
||||
beforeLines,
|
||||
afterLines,
|
||||
hasChanges: false,
|
||||
lineChanges: { before: new Set(), after: new Set() },
|
||||
unifiedBlocks: [],
|
||||
};
|
||||
}
|
||||
|
||||
const lineChanges = {
|
||||
@@ -294,19 +376,20 @@ const processChanges = (beforeCode?: string, afterCode?: string) => {
|
||||
|
||||
const lineNumberStyles =
|
||||
'w-9 shrink-0 pl-2 py-1 text-left font-mono text-upage-elements-textTertiary border-r border-upage-elements-borderColor bg-upage-elements-background-depth-1';
|
||||
const lineContentStyles = 'px-1 py-1 font-mono whitespace-pre flex-1 text-upage-elements-textPrimary';
|
||||
const lineContentStyles =
|
||||
'px-1 py-1 font-mono whitespace-pre flex-1 group-hover:bg-upage-elements-background-depth-2 text-upage-elements-textPrimary';
|
||||
const diffPanelStyles = 'h-full overflow-auto diff-panel-content';
|
||||
|
||||
// Updated color styles for better consistency
|
||||
const diffLineStyles = {
|
||||
added: 'bg-green-50/30 dark:bg-green-500/5 border-l-4 border-green-500',
|
||||
removed: 'bg-red-50/30 dark:bg-red-500/5 border-l-4 border-red-500',
|
||||
added: 'bg-green-500/10 dark:bg-green-500/20 border-l-4 border-green-500',
|
||||
removed: 'bg-red-500/10 dark:bg-red-500/20 border-l-4 border-red-500',
|
||||
unchanged: '',
|
||||
};
|
||||
|
||||
const changeColorStyles = {
|
||||
added: 'text-green-800 dark:text-green-400 bg-green-500/35 dark:bg-green-500/45 px-0.5 rounded font-medium',
|
||||
removed: 'text-red-800 dark:text-red-400 bg-red-500/35 dark:bg-red-500/45 px-0.5 rounded font-medium',
|
||||
added: 'text-green-700 dark:text-green-500 bg-green-500/10 dark:bg-green-500/20',
|
||||
removed: 'text-red-700 dark:text-red-500 bg-red-500/10 dark:bg-red-500/20',
|
||||
unchanged: 'text-upage-elements-textPrimary',
|
||||
};
|
||||
|
||||
@@ -344,36 +427,20 @@ const NoChangesView = memo(
|
||||
}));
|
||||
}, [beforeCode]);
|
||||
|
||||
const Row = useCallback(
|
||||
({
|
||||
index,
|
||||
style,
|
||||
codeBlocks,
|
||||
highlighter,
|
||||
language,
|
||||
theme,
|
||||
}: RowComponentProps<{
|
||||
codeBlocks: DiffBlock[];
|
||||
highlighter: any;
|
||||
language: string;
|
||||
theme: string;
|
||||
}>) => {
|
||||
const block = codeBlocks[index];
|
||||
return (
|
||||
<div style={style}>
|
||||
<CodeLine
|
||||
lineNumber={block.lineNumber}
|
||||
content={block.content}
|
||||
type={block.type}
|
||||
highlighter={highlighter}
|
||||
language={language}
|
||||
block={block}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[],
|
||||
const renderCodeLine = useCallback(
|
||||
(block: DiffBlock, index: number) => (
|
||||
<CodeLine
|
||||
key={`unchanged-${index}`}
|
||||
lineNumber={block.lineNumber}
|
||||
content={block.content}
|
||||
type={block.type}
|
||||
highlighter={highlighter}
|
||||
language={language}
|
||||
block={block}
|
||||
theme={theme}
|
||||
/>
|
||||
),
|
||||
[highlighter, language, theme],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -389,14 +456,12 @@ const NoChangesView = memo(
|
||||
</div>
|
||||
<div className="overflow-auto max-h-96">
|
||||
{codeBlocks.length > 0 ? (
|
||||
<List
|
||||
defaultHeight={384}
|
||||
rowCount={codeBlocks.length}
|
||||
rowHeight={24}
|
||||
rowComponent={Row}
|
||||
rowProps={{ codeBlocks, highlighter, language, theme }}
|
||||
className="overflow-x-auto overflow-y-hidden!"
|
||||
overscanCount={10}
|
||||
<VirtualizedList
|
||||
items={codeBlocks}
|
||||
renderItem={renderCodeLine}
|
||||
itemHeight={24}
|
||||
className="overflow-x-auto"
|
||||
overscan={10}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-upage-elements-textTertiary">无内容</div>
|
||||
@@ -408,25 +473,13 @@ const NoChangesView = memo(
|
||||
},
|
||||
);
|
||||
|
||||
const simpleHash = (str: string): string => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return hash.toString(36);
|
||||
};
|
||||
|
||||
const useProcessChanges = (beforeCode?: string, afterCode?: string) => {
|
||||
return useMemo(() => {
|
||||
if (!beforeCode && !afterCode) {
|
||||
if (!beforeCode || !afterCode) {
|
||||
return processChanges(beforeCode, afterCode);
|
||||
}
|
||||
|
||||
const beforeHash = beforeCode ? `${simpleHash(beforeCode)}:${beforeCode.length}` : 'empty';
|
||||
const afterHash = afterCode ? `${simpleHash(afterCode)}:${afterCode.length}` : 'empty';
|
||||
const cacheKey = `${beforeHash}:${afterHash}`;
|
||||
const cacheKey = `${beforeCode}:${afterCode}`;
|
||||
|
||||
if (diffCache.has(cacheKey)) {
|
||||
return diffCache.get(cacheKey)!;
|
||||
@@ -438,58 +491,6 @@ const useProcessChanges = (beforeCode?: string, afterCode?: string) => {
|
||||
}, [beforeCode, afterCode]);
|
||||
};
|
||||
|
||||
const getHighlightedContent = (
|
||||
content: string,
|
||||
language: string,
|
||||
currentTheme: string,
|
||||
highlighter: any,
|
||||
type: 'added' | 'removed' | 'unchanged',
|
||||
charChanges?: Array<{ value: string; type: 'added' | 'removed' | 'unchanged' }>,
|
||||
): string | null => {
|
||||
if (!highlighter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === 'unchanged' || !charChanges) {
|
||||
const cacheKey = `${content}:${language}:${currentTheme}`;
|
||||
|
||||
if (highlightCache.has(cacheKey)) {
|
||||
return highlightCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const highlighted = highlighter
|
||||
.codeToHtml(content, { lang: language, theme: currentTheme })
|
||||
.replace(/<\/?pre[^>]*>/g, '')
|
||||
.replace(/<\/?code[^>]*>/g, '');
|
||||
|
||||
highlightCache.set(cacheKey, highlighted);
|
||||
return highlighted;
|
||||
}
|
||||
|
||||
const fragments: string[] = [];
|
||||
|
||||
for (const change of charChanges) {
|
||||
const changeClass = changeColorStyles[change.type];
|
||||
const cacheKey = `${change.value}:${language}:${currentTheme}:${change.type}`;
|
||||
|
||||
let highlighted;
|
||||
if (highlightCache.has(cacheKey)) {
|
||||
highlighted = highlightCache.get(cacheKey)!;
|
||||
} else {
|
||||
highlighted = highlighter
|
||||
.codeToHtml(change.value, { lang: language, theme: currentTheme })
|
||||
.replace(/<\/?pre[^>]*>/g, '')
|
||||
.replace(/<\/?code[^>]*>/g, '');
|
||||
|
||||
highlightCache.set(cacheKey, highlighted);
|
||||
}
|
||||
|
||||
fragments.push(`<span class="${changeClass}">${highlighted}</span>`);
|
||||
}
|
||||
|
||||
return fragments.join('');
|
||||
};
|
||||
|
||||
const CodeLine = memo(
|
||||
({
|
||||
lineNumber,
|
||||
@@ -511,13 +512,68 @@ const CodeLine = memo(
|
||||
const bgColor = diffLineStyles[type];
|
||||
const currentTheme = theme === 'dark' ? 'github-dark' : 'github-light';
|
||||
|
||||
const highlightedContent = useMemo(
|
||||
() => getHighlightedContent(content, language, currentTheme, highlighter, type, block.charChanges),
|
||||
[content, language, currentTheme, highlighter, type, block.charChanges],
|
||||
);
|
||||
const [isHighlighted, setIsHighlighted] = useState(false);
|
||||
const [highlightedContent, setHighlightedContent] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlighter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (type === 'unchanged' || !block.charChanges) {
|
||||
const cacheKey = `${content}:${language}:${currentTheme}`;
|
||||
|
||||
if (highlightCache.has(cacheKey)) {
|
||||
setHighlightedContent(highlightCache.get(cacheKey)!);
|
||||
setIsHighlighted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const highlighted = highlighter
|
||||
.codeToHtml(content, { lang: language, theme: currentTheme })
|
||||
.replace(/<\/?pre[^>]*>/g, '')
|
||||
.replace(/<\/?code[^>]*>/g, '');
|
||||
|
||||
highlightCache.set(cacheKey, highlighted);
|
||||
setHighlightedContent(highlighted);
|
||||
setIsHighlighted(true);
|
||||
} else {
|
||||
const fragments: string[] = [];
|
||||
|
||||
for (let i = 0; i < block.charChanges!.length; i++) {
|
||||
const change = block.charChanges![i];
|
||||
const changeClass = changeColorStyles[change.type];
|
||||
|
||||
const cacheKey = `${change.value}:${language}:${currentTheme}:${change.type}`;
|
||||
|
||||
let highlighted;
|
||||
if (highlightCache.has(cacheKey)) {
|
||||
highlighted = highlightCache.get(cacheKey);
|
||||
} else {
|
||||
highlighted = highlighter
|
||||
.codeToHtml(change.value, { lang: language, theme: currentTheme })
|
||||
.replace(/<\/?pre[^>]*>/g, '')
|
||||
.replace(/<\/?code[^>]*>/g, '');
|
||||
|
||||
highlightCache.set(cacheKey, highlighted);
|
||||
}
|
||||
|
||||
fragments.push(`<span class="${changeClass}">${highlighted}</span>`);
|
||||
}
|
||||
|
||||
setHighlightedContent(fragments.join(''));
|
||||
setIsHighlighted(true);
|
||||
}
|
||||
}, 10);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [content, language, currentTheme, highlighter, type, block.charChanges]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (highlightedContent) {
|
||||
if (isHighlighted && highlightedContent) {
|
||||
return <span dangerouslySetInnerHTML={{ __html: highlightedContent }} />;
|
||||
}
|
||||
|
||||
@@ -662,50 +718,20 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, pageName, language }
|
||||
}
|
||||
}, [hasChanges, loadHighlighter]);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(600);
|
||||
|
||||
useEffect(() => {
|
||||
const updateHeight = () => {
|
||||
if (containerRef.current) {
|
||||
setContainerHeight(containerRef.current.clientHeight);
|
||||
}
|
||||
};
|
||||
updateHeight();
|
||||
window.addEventListener('resize', updateHeight);
|
||||
return () => window.removeEventListener('resize', updateHeight);
|
||||
}, []);
|
||||
|
||||
const Row = useCallback(
|
||||
({
|
||||
index,
|
||||
style,
|
||||
unifiedBlocks,
|
||||
highlighter,
|
||||
language,
|
||||
theme,
|
||||
}: RowComponentProps<{
|
||||
unifiedBlocks: DiffBlock[];
|
||||
highlighter: any;
|
||||
language: string;
|
||||
theme: string;
|
||||
}>) => {
|
||||
const block = unifiedBlocks[index];
|
||||
return (
|
||||
<div style={style}>
|
||||
<CodeLine
|
||||
lineNumber={block.lineNumber}
|
||||
content={block.content}
|
||||
type={block.type}
|
||||
highlighter={highlighter}
|
||||
language={language}
|
||||
block={block}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[],
|
||||
const renderCodeLine = useCallback(
|
||||
(block: DiffBlock, index: number) => (
|
||||
<CodeLine
|
||||
key={`${block.lineNumber}-${index}`}
|
||||
lineNumber={block.lineNumber}
|
||||
content={block.content}
|
||||
type={block.type}
|
||||
highlighter={highlighter}
|
||||
language={language}
|
||||
block={block}
|
||||
theme={theme}
|
||||
/>
|
||||
),
|
||||
[highlighter, language, theme],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
@@ -723,17 +749,17 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, pageName, language }
|
||||
beforeCode={beforeCode}
|
||||
afterCode={afterCode}
|
||||
/>
|
||||
<div ref={containerRef} className={diffPanelStyles}>
|
||||
<div className={diffPanelStyles}>
|
||||
{hasChanges ? (
|
||||
<List
|
||||
defaultHeight={containerHeight}
|
||||
rowCount={unifiedBlocks.length}
|
||||
rowHeight={24}
|
||||
rowComponent={Row}
|
||||
rowProps={{ unifiedBlocks, highlighter, language, theme }}
|
||||
className="overflow-x-auto"
|
||||
overscanCount={30}
|
||||
/>
|
||||
<div className="overflow-x-auto min-w-full">
|
||||
<VirtualizedList
|
||||
items={unifiedBlocks}
|
||||
renderItem={renderCodeLine}
|
||||
itemHeight={24}
|
||||
className="overflow-x-auto"
|
||||
overscan={30}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<NoChangesView beforeCode={beforeCode} language={language} highlighter={highlighter} theme={theme} />
|
||||
)}
|
||||
@@ -745,13 +771,18 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, pageName, language }
|
||||
|
||||
export const DiffView = memo(() => {
|
||||
const pageHistory = useStore(webBuilderStore.pagesStore.pageHistory);
|
||||
const pages = useStore(webBuilderStore.pagesStore.pages);
|
||||
const selectedPage = useStore(webBuilderStore.pagesStore.activePage);
|
||||
const currentPage = useStore(webBuilderStore.pagesStore.currentPage);
|
||||
const currentView = useStore(webBuilderStore.currentView);
|
||||
|
||||
const [formattedContent, setFormattedContent] = useState<string>('');
|
||||
const [effectiveOriginalContent, setEffectiveOriginalContent] = useState<string>('');
|
||||
const [shouldProcess, setShouldProcess] = useState(false);
|
||||
const [compareVersionContent, setCompareVersionContent] = useState<string>('');
|
||||
const [baselineVersionContent, setBaselineVersionContent] = useState<string>('');
|
||||
|
||||
// 存当前页面内容的键,用于检测内容是否变化缓
|
||||
const contentCacheKey = useRef<string | null>(null);
|
||||
|
||||
// 当视图切换到 diff 时,标记需要处理
|
||||
useEffect(() => {
|
||||
if (currentView === 'diff') {
|
||||
setShouldProcess(true);
|
||||
@@ -759,69 +790,59 @@ export const DiffView = memo(() => {
|
||||
}, [currentView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== 'diff' || !selectedPage || !shouldProcess) {
|
||||
if (!selectedPage || !currentPage || currentView !== 'diff' || !shouldProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
const history = pageHistory[selectedPage];
|
||||
// 使用最新版本
|
||||
const lastVersion = history?.versions.find((version) => version.version === history.latestVersion);
|
||||
const lastVersionContent = normalizeContent(lastVersion?.content);
|
||||
const page = pages[selectedPage];
|
||||
const originalContent = page && 'content' in page ? page.content : '';
|
||||
|
||||
if (lastVersionContent) {
|
||||
const lastVersionCacheKey = `${selectedPage}:${history?.latestVersion}`;
|
||||
if (formatCache.has(lastVersionCacheKey)) {
|
||||
setCompareVersionContent(formatCache.get(lastVersionCacheKey)!);
|
||||
const history = pageHistory[selectedPage];
|
||||
const originalContentToFormat = history?.originalContent || originalContent;
|
||||
|
||||
if (currentPage?.content) {
|
||||
const currentContent = currentPage.content as string;
|
||||
|
||||
if (formatCache.has(currentContent)) {
|
||||
setFormattedContent(formatCache.get(currentContent)!);
|
||||
|
||||
contentCacheKey.current = currentContent;
|
||||
} else {
|
||||
formatCode(lastVersionContent, { parser: 'html' })
|
||||
formatCode(currentContent, { parser: 'html' })
|
||||
.then((formatted) => {
|
||||
formatCache.set(lastVersionCacheKey, formatted);
|
||||
setCompareVersionContent(formatted);
|
||||
formatCache.set(currentContent, formatted);
|
||||
setFormattedContent(formatted);
|
||||
|
||||
contentCacheKey.current = currentContent;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('格式化当前内容失败:', error);
|
||||
setCompareVersionContent(lastVersionContent);
|
||||
setFormattedContent(currentContent);
|
||||
contentCacheKey.current = currentContent;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 获取上一次由聊天所触发的历史版本(不含本次版本),或者第一个初始化版本
|
||||
const autoSaveHistories = history?.versions.filter((version) => version.changeSource === 'auto-save') || [];
|
||||
let lastTimeChatVersionVersion = 0;
|
||||
if (autoSaveHistories.length > 1) {
|
||||
lastTimeChatVersionVersion = autoSaveHistories[autoSaveHistories.length - 2].version;
|
||||
} else {
|
||||
const firstHistory = history?.versions[0];
|
||||
if (firstHistory && firstHistory.changeSource === 'initial') {
|
||||
lastTimeChatVersionVersion = firstHistory.version;
|
||||
if (originalContentToFormat) {
|
||||
if (formatCache.has(originalContentToFormat)) {
|
||||
setEffectiveOriginalContent(formatCache.get(originalContentToFormat)!);
|
||||
} else {
|
||||
lastTimeChatVersionVersion = 0;
|
||||
}
|
||||
}
|
||||
const lastTimeChatVersion = history?.versions.find((version) => version.version === lastTimeChatVersionVersion);
|
||||
const lastTimeChatVersionContent = normalizeContent(lastTimeChatVersion?.content);
|
||||
|
||||
if (lastTimeChatVersionContent) {
|
||||
const lastTimeChatVersionCacheKey = `${selectedPage}:${lastTimeChatVersionVersion}`;
|
||||
if (formatCache.has(lastTimeChatVersionCacheKey)) {
|
||||
setBaselineVersionContent(formatCache.get(lastTimeChatVersionCacheKey)!);
|
||||
} else {
|
||||
formatCode(lastTimeChatVersionContent, { parser: 'html' })
|
||||
formatCode(originalContentToFormat, { parser: 'html' })
|
||||
.then((formatted) => {
|
||||
formatCache.set(lastTimeChatVersionCacheKey, formatted);
|
||||
setBaselineVersionContent(formatted);
|
||||
formatCache.set(originalContentToFormat, formatted);
|
||||
setEffectiveOriginalContent(formatted);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('格式化原始内容失败:', error);
|
||||
setBaselineVersionContent(lastTimeChatVersionContent);
|
||||
setEffectiveOriginalContent(originalContentToFormat);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setShouldProcess(false);
|
||||
}, [selectedPage, currentView, shouldProcess, pageHistory]);
|
||||
}, [currentPage?.content, selectedPage, currentView, shouldProcess, pageHistory, pages]);
|
||||
|
||||
if (!selectedPage) {
|
||||
if (!selectedPage || !currentPage) {
|
||||
return (
|
||||
<div className="flex size-full justify-center items-center bg-upage-elements-background-depth-1 text-upage-elements-textPrimary">
|
||||
选择一个页面来查看差异
|
||||
@@ -833,8 +854,8 @@ export const DiffView = memo(() => {
|
||||
return (
|
||||
<div className="h-full overflow-hidden">
|
||||
<InlineDiffComparison
|
||||
beforeCode={baselineVersionContent}
|
||||
afterCode={compareVersionContent}
|
||||
beforeCode={effectiveOriginalContent}
|
||||
afterCode={formattedContent}
|
||||
language={'html'}
|
||||
pageName={selectedPage}
|
||||
lightTheme="github-light"
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { Popover, Transition } from '@headlessui/react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import classNames from 'classnames';
|
||||
import { type Change, diffLines } from 'diff';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { webBuilderStore } from '~/.client/stores/web-builder';
|
||||
|
||||
interface PageModifiedDropdownProps {
|
||||
onSelectPage: (pageName: string) => void;
|
||||
}
|
||||
|
||||
export const PageModifiedDropdown = memo(({ onSelectPage }: PageModifiedDropdownProps) => {
|
||||
const pageHistory = useStore(webBuilderStore.pagesStore.pageHistory);
|
||||
const currentSelectedPage = useStore(webBuilderStore.editorStore.selectedDocument);
|
||||
const modifiedPages = Object.entries(pageHistory);
|
||||
const hasChanges = modifiedPages.length > 0;
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredPages = useMemo(() => {
|
||||
return modifiedPages.filter(([pageName]) => pageName.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
}, [modifiedPages, searchQuery]);
|
||||
|
||||
const handleSelectPage = useCallback(
|
||||
(pageName: string, close: () => void) => {
|
||||
// 如果是当前已选中的页面,不执行任何操作
|
||||
if (pageName === currentSelectedPage) {
|
||||
return;
|
||||
}
|
||||
onSelectPage(pageName);
|
||||
webBuilderStore.currentView.set('diff');
|
||||
// 关闭下拉菜单
|
||||
close();
|
||||
},
|
||||
[onSelectPage, currentSelectedPage],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover className="relative">
|
||||
{({ open, close }: { open: boolean; close: () => void }) => (
|
||||
<>
|
||||
<Popover.Button className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-upage-elements-background-depth-2 hover:bg-upage-elements-background-depth-3 transition-colors text-upage-elements-textPrimary border border-upage-elements-borderColor">
|
||||
<span className="font-medium">更改页面</span>
|
||||
{hasChanges && (
|
||||
<span className="size-5 rounded-full bg-accent-500/20 text-accent-500 text-xs flex items-center justify-center border border-accent-500/30">
|
||||
{modifiedPages.length}
|
||||
</span>
|
||||
)}
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-20 mt-2 w-80 origin-top-right rounded-xl bg-upage-elements-background-depth-2 shadow-xl border border-upage-elements-borderColor">
|
||||
<div className="p-2">
|
||||
<div className="relative mx-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索页面..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-lg bg-upage-elements-background-depth-1 border border-upage-elements-borderColor focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||
/>
|
||||
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-upage-elements-textTertiary">
|
||||
<div className="i-ph:magnifying-glass" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{filteredPages.length > 0 ? (
|
||||
filteredPages.map(([pageName, history]) => {
|
||||
const isActive = pageName === currentSelectedPage;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pageName}
|
||||
onClick={() => handleSelectPage(pageName, close)}
|
||||
disabled={isActive}
|
||||
className={classNames('w-full px-3 py-2 text-left rounded-md transition-colors group', {
|
||||
'bg-blue-500/10 cursor-default': isActive,
|
||||
'hover:bg-upage-elements-background-depth-1 hover:bg-blue-500/10 bg-transparent cursor-pointer':
|
||||
!isActive,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="shrink-0 size-5 text-upage-elements-textTertiary">
|
||||
<div
|
||||
className={classNames({
|
||||
'i-ph:file-text-duotone text-blue-500': isActive,
|
||||
'i-ph:file-text': !isActive,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={classNames('truncate text-sm font-medium', {
|
||||
'text-blue-600': isActive,
|
||||
'text-upage-elements-textPrimary': !isActive,
|
||||
})}
|
||||
>
|
||||
{pageName.split('/').pop()}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-600 font-medium">
|
||||
当前
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate text-xs text-upage-elements-textTertiary">
|
||||
{pageName}
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
// 计算差异统计
|
||||
const { additions, deletions } = (() => {
|
||||
if (!history.originalContent) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const normalizedOriginal = history.originalContent.replace(/\r\n/g, '\n');
|
||||
const normalizedCurrent =
|
||||
history.versions[history.versions.length - 1]?.content.replace(/\r\n/g, '\n') ||
|
||||
'';
|
||||
|
||||
if (normalizedOriginal === normalizedCurrent) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const changes = diffLines(normalizedOriginal, normalizedCurrent, {
|
||||
newlineIsToken: false,
|
||||
ignoreWhitespace: true,
|
||||
});
|
||||
|
||||
return changes.reduce(
|
||||
(acc: { additions: number; deletions: number }, change: Change) => {
|
||||
if (change.added) {
|
||||
acc.additions += change.value.split('\n').length;
|
||||
}
|
||||
|
||||
if (change.removed) {
|
||||
acc.deletions += change.value.split('\n').length;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
);
|
||||
})();
|
||||
|
||||
const showStats = additions > 0 || deletions > 0;
|
||||
|
||||
return (
|
||||
showStats && (
|
||||
<div className="flex items-center gap-1 text-xs shrink-0">
|
||||
{additions > 0 && <span className="text-green-500">+{additions}</span>}
|
||||
{deletions > 0 && <span className="text-red-500">-{deletions}</span>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center p-4 text-center">
|
||||
<div className="size-12 mb-2 text-upage-elements-textTertiary">
|
||||
<div className="i-ph:file-dashed" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-upage-elements-textPrimary">
|
||||
{searchQuery ? '没有匹配的页面' : '没有修改的页面'}
|
||||
</p>
|
||||
<p className="text-xs text-upage-elements-textTertiary mt-1">
|
||||
{searchQuery ? '尝试其他搜索' : '更改将在此处显示'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PageModifiedDropdown.displayName = 'PageModifiedDropdown';
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Popover, Transition } from '@headlessui/react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import classNames from 'classnames';
|
||||
import { type Change, diffLines } from 'diff';
|
||||
import { type HTMLMotionProps, motion, type Variants } from 'framer-motion';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -24,7 +26,6 @@ import type { PageMap } from '~/types/pages';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
import { DiffView } from './DiffView';
|
||||
import { EditorPanel } from './EditorPanel';
|
||||
import { PageModifiedDropdown } from './PageModifiedDropdown';
|
||||
import { Preview } from './Preview';
|
||||
|
||||
const viewTransition = { ease: cubicEasingFn };
|
||||
@@ -61,6 +62,154 @@ const workbenchVariants = {
|
||||
},
|
||||
} satisfies Variants;
|
||||
|
||||
const PageModifiedDropdown = memo(({ onSelectPage }: { onSelectPage: (pageName: string) => void }) => {
|
||||
const pageHistory = useStore(webBuilderStore.pagesStore.pageHistory);
|
||||
const modifiedPages = Object.entries(pageHistory);
|
||||
const hasChanges = modifiedPages.length > 0;
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredPages = useMemo(() => {
|
||||
return modifiedPages.filter(([pageName]) => pageName.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
}, [modifiedPages, searchQuery]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover className="relative">
|
||||
{({ open }: { open: boolean }) => (
|
||||
<>
|
||||
<Popover.Button className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-upage-elements-background-depth-2 hover:bg-upage-elements-background-depth-3 transition-colors text-upage-elements-textPrimary border border-upage-elements-borderColor">
|
||||
<span className="font-medium">更改页面</span>
|
||||
{hasChanges && (
|
||||
<span className="size-5 rounded-full bg-accent-500/20 text-accent-500 text-xs flex items-center justify-center border border-accent-500/30">
|
||||
{modifiedPages.length}
|
||||
</span>
|
||||
)}
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-20 mt-2 w-80 origin-top-right rounded-xl bg-upage-elements-background-depth-2 shadow-xl border border-upage-elements-borderColor">
|
||||
<div className="p-2">
|
||||
<div className="relative mx-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索页面..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-lg bg-upage-elements-background-depth-1 border border-upage-elements-borderColor focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||
/>
|
||||
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-upage-elements-textTertiary">
|
||||
<div className="i-ph:magnifying-glass" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{filteredPages.length > 0 ? (
|
||||
filteredPages.map(([pageName, history]) => {
|
||||
return (
|
||||
<button
|
||||
key={pageName}
|
||||
onClick={() => onSelectPage(pageName)}
|
||||
className="w-full px-3 py-2 text-left rounded-md hover:bg-upage-elements-background-depth-1 transition-colors group bg-transparent"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="shrink-0 size-5 text-upage-elements-textTertiary">
|
||||
<div className="i-ph:file-text" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate text-sm font-medium text-upage-elements-textPrimary">
|
||||
{pageName.split('/').pop()}
|
||||
</span>
|
||||
<span className="truncate text-xs text-upage-elements-textTertiary">
|
||||
{pageName}
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
// Calculate diff stats
|
||||
const { additions, deletions } = (() => {
|
||||
if (!history.originalContent) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const normalizedOriginal = history.originalContent.replace(/\r\n/g, '\n');
|
||||
const normalizedCurrent =
|
||||
history.versions[history.versions.length - 1]?.content.replace(/\r\n/g, '\n') ||
|
||||
'';
|
||||
|
||||
if (normalizedOriginal === normalizedCurrent) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const changes = diffLines(normalizedOriginal, normalizedCurrent, {
|
||||
newlineIsToken: false,
|
||||
ignoreWhitespace: true,
|
||||
});
|
||||
|
||||
return changes.reduce(
|
||||
(acc: { additions: number; deletions: number }, change: Change) => {
|
||||
if (change.added) {
|
||||
acc.additions += change.value.split('\n').length;
|
||||
}
|
||||
|
||||
if (change.removed) {
|
||||
acc.deletions += change.value.split('\n').length;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
);
|
||||
})();
|
||||
|
||||
const showStats = additions > 0 || deletions > 0;
|
||||
|
||||
return (
|
||||
showStats && (
|
||||
<div className="flex items-center gap-1 text-xs shrink-0">
|
||||
{additions > 0 && <span className="text-green-500">+{additions}</span>}
|
||||
{deletions > 0 && <span className="text-red-500">-{deletions}</span>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center p-4 text-center">
|
||||
<div className="size-12 mb-2 text-upage-elements-textTertiary">
|
||||
<div className="i-ph:file-dashed" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-upage-elements-textPrimary">
|
||||
{searchQuery ? '没有匹配的页面' : '没有修改的页面'}
|
||||
</p>
|
||||
<p className="text-xs text-upage-elements-textTertiary mt-1">
|
||||
{searchQuery ? '尝试其他搜索' : '更改将在此处显示'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const WebBuilder = memo(() => {
|
||||
renderLogger.trace('webBuilder');
|
||||
|
||||
@@ -102,7 +251,7 @@ export const WebBuilder = memo(() => {
|
||||
webBuilderStore.setSelectedPage(pageName);
|
||||
}, []);
|
||||
|
||||
const onPageSave = useCallback<OnSaveCallback>(() => {
|
||||
const onAutoPageSave = useCallback<OnSaveCallback>(() => {
|
||||
if (isStreaming) {
|
||||
return;
|
||||
}
|
||||
@@ -110,7 +259,7 @@ export const WebBuilder = memo(() => {
|
||||
}, [isStreaming]);
|
||||
|
||||
const doPageSave = useCallback(() => {
|
||||
webBuilderStore.saveAllPages('user').catch(() => {
|
||||
webBuilderStore.saveAllPages().catch(() => {
|
||||
toast.error('文件内容更新失败');
|
||||
});
|
||||
const currentMessageId = webBuilderStore.chatStore.currentMessageId.get();
|
||||
@@ -228,7 +377,7 @@ export const WebBuilder = memo(() => {
|
||||
unsavedPages={unsavedPages}
|
||||
onPageSelect={onPageSelect}
|
||||
onEditorChange={onEditorChange}
|
||||
onPageSave={onPageSave}
|
||||
onPageSave={onAutoPageSave}
|
||||
onPageReset={onPageReset}
|
||||
onLoad={onLoad}
|
||||
onReady={onReady}
|
||||
|
||||
@@ -65,11 +65,10 @@ export function useChatMessage({
|
||||
addStoppedProgressMessage('网络连接中断,响应已停止');
|
||||
},
|
||||
onFinish: ({ message }) => {
|
||||
// 增加延迟,避免与当前流式请求冲突
|
||||
setTimeout(() => {
|
||||
// 保存 editor project
|
||||
saveProject(message.id);
|
||||
}, SAVE_PROJECT_DELAY_MS * 2); // 延迟时间加倍到2秒
|
||||
}, SAVE_PROJECT_DELAY_MS);
|
||||
refreshUsageStats();
|
||||
logger.debug('Finished streaming');
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useFetcher } from '@remix-run/react';
|
||||
import { useEditorStorage } from '~/.client/persistence/editor';
|
||||
import { webBuilderStore } from '~/.client/stores/web-builder';
|
||||
import type { ApiResponse } from '~/types/global';
|
||||
@@ -6,6 +7,7 @@ import { createScopedLogger } from '~/utils/logger';
|
||||
const logger = createScopedLogger('useGrapesProject');
|
||||
|
||||
export function useProject() {
|
||||
const fetcher = useFetcher();
|
||||
const { saveEditorProject } = useEditorStorage();
|
||||
|
||||
/**
|
||||
@@ -23,7 +25,7 @@ export function useProject() {
|
||||
}
|
||||
|
||||
// 保存之前,先保存所有页面
|
||||
await webBuilderStore.saveAllPages('auto-save');
|
||||
await webBuilderStore.saveAllPages();
|
||||
const projectPages = Object.values(webBuilderStore.pagesStore.pages.get()).filter((page) => page !== undefined);
|
||||
const projectSections = Object.values(webBuilderStore.pagesStore.sections.get())
|
||||
.filter((section) => section !== undefined)
|
||||
@@ -60,26 +62,18 @@ export function useProject() {
|
||||
// 先保存在本地数据中
|
||||
saveEditorProject(messageId, projectPages, projectSections);
|
||||
// 再调用远程接口保存到后端数据库
|
||||
// 使用原生 fetch 而非 Remix fetcher,避免触发 revalidation 导致流式请求中断
|
||||
const response = await fetch('/api/project', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// 使用fetcher调用API保存项目数据
|
||||
fetcher.submit(
|
||||
{
|
||||
messageId,
|
||||
pages: projectPages,
|
||||
sections: projectSections,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
logger.error('保存项目失败:', errorData.message || '服务器错误');
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info('项目保存成功');
|
||||
pages: JSON.stringify(projectPages),
|
||||
sections: JSON.stringify(projectSections),
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/project',
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('保存GrapesJS项目失败:', error);
|
||||
|
||||
@@ -120,16 +120,15 @@ export class ChatStore {
|
||||
};
|
||||
|
||||
const artifactsByMessageId = this.artifacts.get();
|
||||
const existingArtifactsByPageName = artifactsByMessageId.get(messageId);
|
||||
const artifactsByPageName = existingArtifactsByPageName ? new Map(existingArtifactsByPageName) : new Map();
|
||||
let artifactsByPageName = artifactsByMessageId.get(messageId);
|
||||
if (!artifactsByPageName) {
|
||||
artifactsByPageName = new Map();
|
||||
artifactsByMessageId.set(messageId, artifactsByPageName);
|
||||
}
|
||||
|
||||
artifactsByPageName.set(name, newArtifact);
|
||||
|
||||
// create new outer Map instance to trigger nanostores listener
|
||||
const newArtifactsByMessageId = new Map(artifactsByMessageId);
|
||||
newArtifactsByMessageId.set(messageId, artifactsByPageName);
|
||||
|
||||
this.artifacts.set(newArtifactsByMessageId);
|
||||
this.artifacts.set(artifactsByMessageId);
|
||||
const bridge = await editorBridge;
|
||||
bridge.updatePageAttributes(name, { title });
|
||||
}
|
||||
@@ -141,19 +140,14 @@ export class ChatStore {
|
||||
}
|
||||
|
||||
const artifactsByMessageId = this.artifacts.get();
|
||||
const existingArtifactsByPageName = artifactsByMessageId.get(messageId);
|
||||
if (!existingArtifactsByPageName) {
|
||||
const artifactsByPageName = artifactsByMessageId.get(messageId);
|
||||
if (!artifactsByPageName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const artifactsByPageName = new Map(existingArtifactsByPageName);
|
||||
artifactsByPageName.set(name, { ...artifact, ...state });
|
||||
artifactsByMessageId.set(messageId, artifactsByPageName);
|
||||
|
||||
// create new outer Map instance to trigger nanostores listener
|
||||
const newArtifactsByMessageId = new Map(artifactsByMessageId);
|
||||
newArtifactsByMessageId.set(messageId, artifactsByPageName);
|
||||
|
||||
this.artifacts.set(newArtifactsByMessageId);
|
||||
this.artifacts.set(artifactsByMessageId);
|
||||
}
|
||||
|
||||
private getArtifact(messageId: string, pageName: string) {
|
||||
|
||||
@@ -3,8 +3,7 @@ import { atom, computed, type MapStore, map, type WritableAtom } from 'nanostore
|
||||
import { type EditorBridge, type EventPayload, editorBridge } from '~/.client/bridge';
|
||||
import { computePageModifications, diffPages } from '~/.client/utils/diff';
|
||||
import { isValidContent } from '~/.client/utils/html-parse';
|
||||
import { normalizeContent } from '~/.client/utils/prettier';
|
||||
import type { ChangeSource, Page, PageHistory } from '~/types/actions';
|
||||
import type { Page, PageHistory } from '~/types/actions';
|
||||
import type { PageMap, PageSection, SectionMap } from '~/types/pages';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
@@ -157,67 +156,69 @@ export class PagesStore {
|
||||
this.modifiedPages.clear();
|
||||
}
|
||||
|
||||
async savePage(pageName: string, content: string, changeSource: ChangeSource) {
|
||||
async savePage(pageName: string, content: string) {
|
||||
const page = this.getPage(pageName);
|
||||
if (!page) {
|
||||
return false;
|
||||
}
|
||||
// 保存上一次的页面内容
|
||||
this.savePageHistory(pageName, content);
|
||||
try {
|
||||
this.pages.setKey(pageName, { ...page, content });
|
||||
logger.info('Page updated');
|
||||
// 保存上一次的页面内容
|
||||
this.savePageHistory(pageName, content, changeSource);
|
||||
} catch (error) {
|
||||
logger.error('Failed to update page content\n\n', error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async savePageHistory(pageName: string, newContent: string, changeSource: ChangeSource) {
|
||||
async savePageHistory(pageName: string, newContent: string) {
|
||||
const page = this.getPage(pageName);
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageHistory = this.pageHistory.get()[pageName];
|
||||
// 如果不存在历史记录,则创建一个新的历史记录
|
||||
if (!pageHistory) {
|
||||
const newHistory: PageHistory = {
|
||||
originalContent: newContent,
|
||||
latestVersion: 1,
|
||||
latestModified: Date.now(),
|
||||
versions: [
|
||||
{
|
||||
version: 1,
|
||||
timestamp: Date.now(),
|
||||
content: newContent,
|
||||
changeSource,
|
||||
},
|
||||
],
|
||||
};
|
||||
this.pageHistory.setKey(pageName, newHistory);
|
||||
return;
|
||||
}
|
||||
|
||||
const lastVersion = pageHistory.versions.find((version) => version.version === pageHistory.latestVersion);
|
||||
if (!lastVersion) {
|
||||
return;
|
||||
}
|
||||
// 如果存在历史记录,则检查自上次版本以来是否有实际变化
|
||||
const originalContent = lastVersion?.content || page.content!;
|
||||
const normalizedCurrentContent = newContent?.replace(/\r\n/g, '\n').trim();
|
||||
const originalContent = pageHistory?.originalContent || page.content!;
|
||||
if (!originalContent) {
|
||||
return;
|
||||
}
|
||||
const normalizedCurrentContent = normalizeContent(newContent);
|
||||
const normalizedLastContent = normalizeContent(lastVersion?.content);
|
||||
const normalizedOriginalContent = (pageHistory?.originalContent || page.content!).replace(/\r\n/g, '\n').trim();
|
||||
if (!pageHistory) {
|
||||
if (normalizedCurrentContent !== normalizedOriginalContent) {
|
||||
const newChanges = diffLines(page.content!, newContent);
|
||||
const newHistory: PageHistory = {
|
||||
originalContent: page.content!,
|
||||
lastModified: Date.now(),
|
||||
changes: newChanges,
|
||||
versions: [
|
||||
{
|
||||
timestamp: Date.now(),
|
||||
content: newContent,
|
||||
},
|
||||
],
|
||||
changeSource: 'auto-save',
|
||||
};
|
||||
this.pageHistory.setKey(pageName, newHistory);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果存在历史记录,则检查自上次版本以来是否有实际变化
|
||||
const lastVersion = pageHistory.versions[pageHistory.versions.length - 1];
|
||||
const normalizedLastContent = lastVersion?.content.replace(/\r\n/g, '\n').trim();
|
||||
if (normalizedCurrentContent === normalizedLastContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unifiedDiff = diffPages(pageName, lastVersion.content, newContent);
|
||||
const unifiedDiff = diffPages(pageName, pageHistory.originalContent, newContent);
|
||||
if (!unifiedDiff) {
|
||||
return;
|
||||
}
|
||||
const newChanges = diffLines(lastVersion.content, newContent);
|
||||
const newChanges = diffLines(pageHistory.originalContent, newContent);
|
||||
|
||||
// 检查是否有显著变化
|
||||
const hasSignificantChanges = newChanges.some(
|
||||
@@ -229,17 +230,16 @@ export class PagesStore {
|
||||
|
||||
const newHistory: PageHistory = {
|
||||
originalContent: pageHistory.originalContent,
|
||||
latestVersion: pageHistory.latestVersion + 1,
|
||||
latestModified: Date.now(),
|
||||
lastModified: Date.now(),
|
||||
changes: [...pageHistory.changes, ...newChanges].slice(-100), // Limitar histórico de mudanças
|
||||
versions: [
|
||||
...pageHistory.versions,
|
||||
{
|
||||
version: pageHistory.latestVersion + 1,
|
||||
timestamp: Date.now(),
|
||||
content: newContent,
|
||||
changeSource,
|
||||
},
|
||||
].slice(-20), // 只保留最近的 20 个版本
|
||||
].slice(-10), // 只保留最近的 10 个版本
|
||||
changeSource: 'auto-save',
|
||||
};
|
||||
|
||||
this.pageHistory.setKey(pageName, newHistory);
|
||||
|
||||
@@ -4,7 +4,7 @@ import JSZip from 'jszip';
|
||||
import { atom, type WritableAtom } from 'nanostores';
|
||||
import { toast } from 'sonner';
|
||||
import { formatFile } from '~/.client/utils/prettier';
|
||||
import type { ChangeSource, Page } from '~/types/actions';
|
||||
import type { Page } from '~/types/actions';
|
||||
import type { PageMap } from '~/types/pages';
|
||||
import { base64ToBinary, getContentType, getExtensionFromMimeType, getFileName } from '~/utils/file-utils';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
@@ -66,16 +66,11 @@ export class WebBuilderStore {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动设置页面数据,通常用于初始化页面数据。
|
||||
* @param pages 页面数据
|
||||
*/
|
||||
setPages(pages: PageMap) {
|
||||
this.editorStore.setDocuments(pages, true);
|
||||
for (const [pageName, page] of Object.entries(pages)) {
|
||||
if (page) {
|
||||
this.pagesStore.setPage(pageName, page);
|
||||
this.pagesStore.savePageHistory(pageName, page.content as string, 'initial');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,14 +121,27 @@ export class WebBuilderStore {
|
||||
this.editorStore.updateDocumentContent(pageName, _html);
|
||||
}
|
||||
|
||||
async saveDocument(pageName: string, changeSource: ChangeSource) {
|
||||
/**
|
||||
* 执行保存,将 editorStore 中的当前页面内容同步保存至 PagesStore 中。
|
||||
* @returns
|
||||
*/
|
||||
async saveCurrentDocument() {
|
||||
const currentPage = this.editorStore.currentDocument.get();
|
||||
if (!currentPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.saveDocument(currentPage.name as string);
|
||||
}
|
||||
|
||||
async saveDocument(pageName: string) {
|
||||
const documents = this.editorStore.editorDocuments.get();
|
||||
const pageProperties = documents[pageName];
|
||||
if (pageProperties === undefined) {
|
||||
return;
|
||||
}
|
||||
// 触发 page 的保存
|
||||
this.pagesStore.savePage(pageName, pageProperties.content as string, changeSource).then(() => {
|
||||
this.pagesStore.savePage(pageName, pageProperties.content as string).then(() => {
|
||||
this.editorStore.removeUnsavedDocument(pageName, true);
|
||||
});
|
||||
}
|
||||
@@ -156,9 +164,9 @@ export class WebBuilderStore {
|
||||
this.editorStore.updateDocumentContent(pageName as string, page.content as string);
|
||||
}
|
||||
|
||||
async saveAllPages(changeSource: ChangeSource) {
|
||||
async saveAllPages() {
|
||||
for (const pageName of this.editorStore.unsavedDocuments.get()) {
|
||||
await this.saveDocument(pageName, changeSource);
|
||||
await this.saveDocument(pageName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
export class LRUCache<K, V> {
|
||||
private cache = new Map<K, V>();
|
||||
private maxSize: number;
|
||||
|
||||
constructor(maxSize: number) {
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
if (!this.cache.has(key)) {
|
||||
return undefined;
|
||||
}
|
||||
const value = this.cache.get(key)!;
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.delete(key);
|
||||
} else if (this.cache.size >= this.maxSize) {
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
return this.cache.has(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,3 @@ export function getParser(filePath: string): BuiltInParserName | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeContent(content?: string) {
|
||||
return content?.replace(/\r\n/g, '\n').trim();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Change } from 'diff';
|
||||
|
||||
export interface Page {
|
||||
name: string;
|
||||
title: string;
|
||||
@@ -35,26 +37,15 @@ export interface ActionAlert {
|
||||
source?: 'preview';
|
||||
}
|
||||
|
||||
export type ChangeSource = 'user' | 'auto-save' | 'initial';
|
||||
|
||||
export interface PageHistoryVersion {
|
||||
// 版本号
|
||||
version: number;
|
||||
// 时间戳
|
||||
timestamp: number;
|
||||
// 内容
|
||||
content: string;
|
||||
// 变更来源
|
||||
changeSource: ChangeSource;
|
||||
}
|
||||
|
||||
export interface PageHistory {
|
||||
// 最初的内容
|
||||
originalContent: string;
|
||||
// 最新修改时间
|
||||
latestModified: number;
|
||||
// 最新版本
|
||||
latestVersion: number;
|
||||
// 版本历史
|
||||
versions: PageHistoryVersion[];
|
||||
lastModified: number;
|
||||
changes: Change[];
|
||||
versions: {
|
||||
timestamp: number;
|
||||
content: string;
|
||||
}[];
|
||||
|
||||
// 记录变更来源
|
||||
changeSource?: 'user' | 'auto-save' | 'external';
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# 二次对话必现中断问题排查记录
|
||||
|
||||
> 时间范围:2025-10-14 ~ 2025-10-15
|
||||
> 调试参与者:Codex(AI 助手)
|
||||
> 目标:定位并解决「在同一聊天中第二次发送消息必定失败」的问题
|
||||
|
||||
## 一、现象与初始假设
|
||||
|
||||
1. **复现特征**
|
||||
- 第一次对话正常生成页面与 Section,并成功保存。
|
||||
- 紧接着第二次对话必定失败,前端提示 `net::ERR_INCOMPLETE_CHUNKED_ENCODING` 或返回 502。
|
||||
- 服务端日志显示 `request.signal` 被中止,随后触发 `/api/project` 保存失败并抛出 `P2003`。
|
||||
|
||||
2. **初步假设**
|
||||
- 可能是浏览器主动 abort 导致 SSE 连接中断。
|
||||
- 也可能是结构化摘要(structuredPageSnapshot)等辅助调用耗时太长,引发代理或服务器超时。
|
||||
- `/api/project` 写库失败属于连锁反应,需要隔离观测。
|
||||
|
||||
## 二、针对性尝试与结果
|
||||
|
||||
### 1. 前端日志打点
|
||||
- 为 `fetch`、`AbortController.abort`、`useChat` 状态变更以及 `stream-data` 增加详尽日志。
|
||||
- 结果:确认 `ERR_INCOMPLETE_CHUNKED_ENCODING` 发生时,浏览器确实收到 200 响应头后被动断流;`AbortController.abort` 堆栈来自 Remix fetcher 的 `submit`(revalidation),并非我们主动调用。
|
||||
|
||||
### 2. 阻断 `/api/project` 的连锁影响
|
||||
- 在 `useChatMessage.onFinish` 与 `useProject.saveProject` 中加入 `skipNextProjectSaveRef` 与 `getAborted()` 检查,避免失败时继续提交 `/api/project`,消除 `P2003` 噪声。
|
||||
- 验证:失败后不再触发保存请求,日志干净。
|
||||
|
||||
### 3. 结构化摘要调用的中止处理
|
||||
- 在 `structuredPageSnapshot` 周围增加 `AbortController`,尝试在前端 abort 时终止重试。
|
||||
- 初始实现将自定义 `AbortError` 抛到外层,导致 Node 进程崩溃。修复后改用 `retry.AbortError` 并吞掉异常,确保只记录 warning,不崩溃。
|
||||
|
||||
### 4. 服务端额外日志与防护
|
||||
- 捕获 `chatStreamText`、`structuredPageSnapshot` 的耗时及异常堆栈,判断是否是上游 LLM 调用超时。
|
||||
- 在 `streamExecutor` 与 `chatStreamText` 内部检测 `request.signal.aborted` 后立即返回,防止中止后继续执行后续逻辑。
|
||||
- 处理 `trust proxy` 设置,避免限流中间件报错阻塞服务启动。
|
||||
|
||||
### 5. 观察结果
|
||||
- 中止场景下服务器不再崩溃,也不会触发 `/api/project`,但「第二次请求仍然被客户端中止」这一现象未根治。
|
||||
- 前端控制台依旧会在第二轮输出 `ERR_INCOMPLETE_CHUNKED_ENCODING`,说明连接被关闭但没有崩溃日志。
|
||||
|
||||
## 三、现阶段确认的结论
|
||||
|
||||
1. **崩溃原因已排除**
|
||||
- 之前 Node 进程重启是因为我们抛出的 `Error("客户端已中止…")` 浮出到最外层,现在已改为 `retry.AbortError` 并在 catch 内消化,服务端不会再因此崩溃。
|
||||
|
||||
2. **保存数据的连锁反应已阻断**
|
||||
- 二次对话失败不会再触发 `/api/project` 的 500 与 `P2003`,数据库层面不受影响。
|
||||
|
||||
3. **核心断流仍存在**
|
||||
- 浏览器在第二次流式请求开始不久就断开连接,`AbortController.abort` 的堆栈指向 Remix fetcher(revalidation)。
|
||||
- `structuredPageSnapshot`(辅助模型调用)在每次中止时都会被打断,并记录 warning;但即便完全不保存也会早早中止,说明问题不在写库阶段,而是在请求处理链更早的位置。
|
||||
|
||||
## 四、待验证与下一步计划
|
||||
|
||||
1. **验证结构化阶段是否必要条件**
|
||||
- 临时跳过 `structuredPageSnapshot` 和 `selectContext`,仅保留主模型回复,看二次对话是否仍断流。
|
||||
- 若断流消失,则将进一步评估结构化内容的超时策略或输入长度限制。
|
||||
|
||||
2. **获取 Abort 堆栈**
|
||||
- 在浏览器控制台记录 `[AbortController.abort]` 的堆栈(我们已注入补丁),用于确认到底是 Remix fetcher 还是其他逻辑主动取消请求。
|
||||
- 若堆栈指向 Remix revalidation,可考虑调整 Remix 的 `shouldRevalidate` 或 fetcher 调用时机。
|
||||
|
||||
3. **查看上游网络/代理**
|
||||
- 目前日志没有出现 502,但仍报 `ERR_INCOMPLETE_CHUNKED_ENCODING`,需要确认 Docker/宿主机是否存在连接重置(本地端口、代理等)。
|
||||
- 可以通过 `curl -N` 直接请求 `/api/chat` 进行 CLI 流式验证,排除浏览器因素。
|
||||
|
||||
4. **针对 HTML 解析警告**
|
||||
- 第二轮响应中模型返回了不完整的 `<script>` 与 `<section>`,导致前端解析失败。虽与断线无直接关系,但可能触发某些保护性中止,需在后续排查中一并关注。
|
||||
|
||||
## 五、总结
|
||||
|
||||
目前的改动保证了服务端在异常情况下不会崩溃,也不会误写数据库。但核心问题是客户端在第二轮请求时仍旧提前关闭连接(表现为 `ERR_INCOMPLETE_CHUNKED_ENCODING`)。
|
||||
|
||||
下一步的核心工作是找出“谁”触发了 `AbortController.abort()`,并验证是否与结构化辅助步骤相关。待上述验证完成后,再恢复辅助功能并优化网络策略,才能真正实现「第二次对话不再失败」的目标。*** End Patch
|
||||
Reference in New Issue
Block a user