4 Commits

Author SHA1 Message Date
史悦
177f15a136 避免 Remix revalidation:使用原生 fetch 而非 fetcher.submit,避免触发 Remix 的数据重新验证机制,这是导致流式请求被中止的主要原因。
Some checks failed
CI/CD / Test (push) Has been cancelled
Docker Publish / docker-build-and-push (push) Has been cancelled
增加延迟时间:将项目保存延迟增加到2秒,确保流式请求完全结束后再执行保存操作,减少时间窗口内的冲突。

改进错误处理:添加了更详细的错误处理和日志记录,便于后续调试。
2025-10-15 09:55:47 +08:00
Takagi
c1829e5af9 pref: optimize the diff to make it more accurately reflect changes (#11)
* pref: optimize the diff to make it more accurately reflect changes

* pref: optimize the diff page selection
2025-10-14 12:11:58 +08:00
LIlGG
4e39d41362 fix: ensure nanostores listeners trigger on artifact updates
Refactored artifact update logic to create new Map instances when modifying artifacts, ensuring nanostores listeners are properly triggered. This prevents issues where listeners do not react to in-place mutations of existing Map objects.
2025-10-13 12:42:56 +08:00
Takagi
8c6f55dda8 release: Release v1.0.1 version (#9)
* release: Release v1.0.1 version

* release: Release v1.0.1 version
2025-10-11 19:01:52 +08:00
15 changed files with 699 additions and 510 deletions

25
CHANGELOG.md Normal file
View File

@@ -0,0 +1,25 @@
## <small>[1.0.1](https://github.com/halo-dev/upage/compare/v1.0.0...v1.0.1) (2025-10-11)</small>
### Features
- allow using chat to modify page titles ([7acc494](https://github.com/halo-dev/upage/commit/7acc4949fb4d76a2c5429769ae3d1289ac07fcc5))
### Performance Improvements
- make the generated page names unique rather than consistent ([a93a679](https://github.com/halo-dev/upage/commit/a93a679c712182c5348593df4f1b6a1c8c83ebdd))
- reduce the frequency of saving empty pages ([3af1c30](https://github.com/halo-dev/upage/commit/3af1c30d49c37a69c442e03f2c0bea30a10716ef))
### Bug Fixes
- switching pages may cause page confusion ([a672fca](https://github.com/halo-dev/upage/commit/a672fcad1c6bda7b5ea927826d0218de5e1bc274))
- resolve logical issues when generating multi-page data ([884f518](https://github.com/halo-dev/upage/commit/884f5186a6ded44832555c03661683d19ad23201))
- resolve the issue of frequent triggering of replaceState ([5b8408d](https://github.com/halo-dev/upage/commit/5b8408d7da272f66ee7bce139d56d450158fc86b))
- allow rate limit trust proxy ([196a0c3](https://github.com/halo-dev/upage/commit/196a0c39e7f970c9dc161d046d2530da053f8004))
- addressing the issues with outdated prompt information in multi-turn dialogues ([5ff32f2](https://github.com/halo-dev/upage/commit/5ff32f2c987cf8b9fa3494a5b72f4b46c48abc90))
- resolve the issue of possible abnormal text generated during page creation. ([c5d47c6](https://github.com/halo-dev/upage/commit/c5d47c680ce736f54aa7b35974e2317be0d73146))
### Miscellaneous Chores
- remove tracking script from layout ([63636fe](https://github.com/halo-dev/upage/commit/63636fef1f32130079f7ce38bb21a25e37b80cb3))
### Code Refactoring
- repartition server-side and client-side code ([e9b573a](https://github.com/halo-dev/upage/commit/e9b573a2762e86c4c4df066baa1c33c93436bdc4))

View File

@@ -164,7 +164,7 @@ export const EditorStudio = memo(
[onSave], [onSave],
); );
const handleAutoSave = useCallback(async () => { const handleSave = useCallback(async () => {
const editor = editorRef.current; const editor = editorRef.current;
if (!editor) { if (!editor) {
return; return;
@@ -192,7 +192,7 @@ export const EditorStudio = memo(
documents={documents} documents={documents}
onLoad={handleLoad} onLoad={handleLoad}
onReady={handleEditorReady} onReady={handleEditorReady}
onSave={handleAutoSave} onSave={handleSave}
onContentChange={handleContentChange} onContentChange={handleContentChange}
/> />
); );

View File

@@ -1,101 +1,17 @@
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { type Change, diffLines } from 'diff'; import { type Change, diffLines } from 'diff';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { List, type RowComponentProps } from 'react-window';
import { createHighlighter } from 'shiki'; import { createHighlighter } from 'shiki';
import '~/styles/diff-view.css'; import '~/styles/diff-view.css';
import { webBuilderStore } from '~/.client/stores/web-builder'; import { webBuilderStore } from '~/.client/stores/web-builder';
import { formatCode } from '~/.client/utils/prettier'; import { LRUCache } from '~/.client/utils/lru-cache';
import { formatCode, normalizeContent } from '~/.client/utils/prettier';
import { themeStore } from '~/stores/theme'; import { themeStore } from '~/stores/theme';
// 高亮结果缓存,使用 Map 存储已高亮的代码行 const highlightCache = new LRUCache<string, string>(1000);
const highlightCache = new Map<string, string>(); const formatCache = new LRUCache<string, string>(100);
// 格式化结果缓存,使用 Map 存储已格式化的代码 const diffCache = new LRUCache<string, ReturnType<typeof processChanges>>(50);
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 { interface CodeComparisonProps {
beforeCode?: string; beforeCode?: string;
@@ -148,34 +64,36 @@ const FullscreenOverlay = memo(({ isFullscreen, children }: { isFullscreen: bool
const processChanges = (beforeCode?: string, afterCode?: string) => { const processChanges = (beforeCode?: string, afterCode?: string) => {
try { try {
const normalizeContent = (content: string): string[] => { const normalizeContent = (content?: string): string[] => {
if (!content) {
return [];
}
return content return content
.replace(/\r\n/g, '\n') .replace(/\r\n/g, '\n')
.split('\n') .split('\n')
.map((line) => line.trimEnd()); .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 beforeLines = normalizeContent(beforeCode);
const afterLines = normalizeContent(afterCode); const afterLines = normalizeContent(afterCode);
if (beforeLines.join('\n') === afterLines.join('\n')) { if (beforeLines.length === afterLines.length) {
return { let isEqual = true;
beforeLines, for (let idx = 0; idx < beforeLines.length; idx++) {
afterLines, if (beforeLines[idx] !== afterLines[idx]) {
hasChanges: false, isEqual = false;
lineChanges: { before: new Set(), after: new Set() }, break;
unifiedBlocks: [], }
}; }
if (isEqual) {
return {
beforeLines,
afterLines,
hasChanges: false,
lineChanges: { before: new Set(), after: new Set() },
unifiedBlocks: [],
};
}
} }
const lineChanges = { const lineChanges = {
@@ -376,20 +294,19 @@ const processChanges = (beforeCode?: string, afterCode?: string) => {
const lineNumberStyles = 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'; '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 = const lineContentStyles = 'px-1 py-1 font-mono whitespace-pre flex-1 text-upage-elements-textPrimary';
'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'; const diffPanelStyles = 'h-full overflow-auto diff-panel-content';
// Updated color styles for better consistency // Updated color styles for better consistency
const diffLineStyles = { const diffLineStyles = {
added: 'bg-green-500/10 dark:bg-green-500/20 border-l-4 border-green-500', added: 'bg-green-50/30 dark:bg-green-500/5 border-l-4 border-green-500',
removed: 'bg-red-500/10 dark:bg-red-500/20 border-l-4 border-red-500', removed: 'bg-red-50/30 dark:bg-red-500/5 border-l-4 border-red-500',
unchanged: '', unchanged: '',
}; };
const changeColorStyles = { const changeColorStyles = {
added: 'text-green-700 dark:text-green-500 bg-green-500/10 dark:bg-green-500/20', 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-700 dark:text-red-500 bg-red-500/10 dark:bg-red-500/20', removed: 'text-red-800 dark:text-red-400 bg-red-500/35 dark:bg-red-500/45 px-0.5 rounded font-medium',
unchanged: 'text-upage-elements-textPrimary', unchanged: 'text-upage-elements-textPrimary',
}; };
@@ -427,20 +344,36 @@ const NoChangesView = memo(
})); }));
}, [beforeCode]); }, [beforeCode]);
const renderCodeLine = useCallback( const Row = useCallback(
(block: DiffBlock, index: number) => ( ({
<CodeLine index,
key={`unchanged-${index}`} style,
lineNumber={block.lineNumber} codeBlocks,
content={block.content} highlighter,
type={block.type} language,
highlighter={highlighter} theme,
language={language} }: RowComponentProps<{
block={block} codeBlocks: DiffBlock[];
theme={theme} highlighter: any;
/> language: string;
), theme: string;
[highlighter, language, theme], }>) => {
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>
);
},
[],
); );
return ( return (
@@ -456,12 +389,14 @@ const NoChangesView = memo(
</div> </div>
<div className="overflow-auto max-h-96"> <div className="overflow-auto max-h-96">
{codeBlocks.length > 0 ? ( {codeBlocks.length > 0 ? (
<VirtualizedList <List
items={codeBlocks} defaultHeight={384}
renderItem={renderCodeLine} rowCount={codeBlocks.length}
itemHeight={24} rowHeight={24}
className="overflow-x-auto" rowComponent={Row}
overscan={10} rowProps={{ codeBlocks, highlighter, language, theme }}
className="overflow-x-auto overflow-y-hidden!"
overscanCount={10}
/> />
) : ( ) : (
<div className="p-4 text-center text-upage-elements-textTertiary"></div> <div className="p-4 text-center text-upage-elements-textTertiary"></div>
@@ -473,13 +408,25 @@ 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) => { const useProcessChanges = (beforeCode?: string, afterCode?: string) => {
return useMemo(() => { return useMemo(() => {
if (!beforeCode || !afterCode) { if (!beforeCode && !afterCode) {
return processChanges(beforeCode, afterCode); return processChanges(beforeCode, afterCode);
} }
const cacheKey = `${beforeCode}:${afterCode}`; const beforeHash = beforeCode ? `${simpleHash(beforeCode)}:${beforeCode.length}` : 'empty';
const afterHash = afterCode ? `${simpleHash(afterCode)}:${afterCode.length}` : 'empty';
const cacheKey = `${beforeHash}:${afterHash}`;
if (diffCache.has(cacheKey)) { if (diffCache.has(cacheKey)) {
return diffCache.get(cacheKey)!; return diffCache.get(cacheKey)!;
@@ -491,6 +438,58 @@ const useProcessChanges = (beforeCode?: string, afterCode?: string) => {
}, [beforeCode, afterCode]); }, [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( const CodeLine = memo(
({ ({
lineNumber, lineNumber,
@@ -512,68 +511,13 @@ const CodeLine = memo(
const bgColor = diffLineStyles[type]; const bgColor = diffLineStyles[type];
const currentTheme = theme === 'dark' ? 'github-dark' : 'github-light'; const currentTheme = theme === 'dark' ? 'github-dark' : 'github-light';
const [isHighlighted, setIsHighlighted] = useState(false); const highlightedContent = useMemo(
const [highlightedContent, setHighlightedContent] = useState<string | null>(null); () => getHighlightedContent(content, language, currentTheme, highlighter, type, block.charChanges),
[content, language, currentTheme, highlighter, type, block.charChanges],
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 = () => { const renderContent = () => {
if (isHighlighted && highlightedContent) { if (highlightedContent) {
return <span dangerouslySetInnerHTML={{ __html: highlightedContent }} />; return <span dangerouslySetInnerHTML={{ __html: highlightedContent }} />;
} }
@@ -718,20 +662,50 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, pageName, language }
} }
}, [hasChanges, loadHighlighter]); }, [hasChanges, loadHighlighter]);
const renderCodeLine = useCallback( const containerRef = useRef<HTMLDivElement>(null);
(block: DiffBlock, index: number) => ( const [containerHeight, setContainerHeight] = useState(600);
<CodeLine
key={`${block.lineNumber}-${index}`} useEffect(() => {
lineNumber={block.lineNumber} const updateHeight = () => {
content={block.content} if (containerRef.current) {
type={block.type} setContainerHeight(containerRef.current.clientHeight);
highlighter={highlighter} }
language={language} };
block={block} updateHeight();
theme={theme} window.addEventListener('resize', updateHeight);
/> return () => window.removeEventListener('resize', updateHeight);
), }, []);
[highlighter, language, theme],
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>
);
},
[],
); );
if (error) { if (error) {
@@ -749,17 +723,17 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, pageName, language }
beforeCode={beforeCode} beforeCode={beforeCode}
afterCode={afterCode} afterCode={afterCode}
/> />
<div className={diffPanelStyles}> <div ref={containerRef} className={diffPanelStyles}>
{hasChanges ? ( {hasChanges ? (
<div className="overflow-x-auto min-w-full"> <List
<VirtualizedList defaultHeight={containerHeight}
items={unifiedBlocks} rowCount={unifiedBlocks.length}
renderItem={renderCodeLine} rowHeight={24}
itemHeight={24} rowComponent={Row}
className="overflow-x-auto" rowProps={{ unifiedBlocks, highlighter, language, theme }}
overscan={30} className="overflow-x-auto"
/> overscanCount={30}
</div> />
) : ( ) : (
<NoChangesView beforeCode={beforeCode} language={language} highlighter={highlighter} theme={theme} /> <NoChangesView beforeCode={beforeCode} language={language} highlighter={highlighter} theme={theme} />
)} )}
@@ -771,18 +745,13 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, pageName, language }
export const DiffView = memo(() => { export const DiffView = memo(() => {
const pageHistory = useStore(webBuilderStore.pagesStore.pageHistory); const pageHistory = useStore(webBuilderStore.pagesStore.pageHistory);
const pages = useStore(webBuilderStore.pagesStore.pages);
const selectedPage = useStore(webBuilderStore.pagesStore.activePage); const selectedPage = useStore(webBuilderStore.pagesStore.activePage);
const currentPage = useStore(webBuilderStore.pagesStore.currentPage);
const currentView = useStore(webBuilderStore.currentView); const currentView = useStore(webBuilderStore.currentView);
const [formattedContent, setFormattedContent] = useState<string>('');
const [effectiveOriginalContent, setEffectiveOriginalContent] = useState<string>('');
const [shouldProcess, setShouldProcess] = useState(false); const [shouldProcess, setShouldProcess] = useState(false);
const [compareVersionContent, setCompareVersionContent] = useState<string>('');
const [baselineVersionContent, setBaselineVersionContent] = useState<string>('');
// 存当前页面内容的键,用于检测内容是否变化缓
const contentCacheKey = useRef<string | null>(null);
// 当视图切换到 diff 时,标记需要处理
useEffect(() => { useEffect(() => {
if (currentView === 'diff') { if (currentView === 'diff') {
setShouldProcess(true); setShouldProcess(true);
@@ -790,59 +759,69 @@ export const DiffView = memo(() => {
}, [currentView]); }, [currentView]);
useEffect(() => { useEffect(() => {
if (!selectedPage || !currentPage || currentView !== 'diff' || !shouldProcess) { if (currentView !== 'diff' || !selectedPage || !shouldProcess) {
return; return;
} }
const page = pages[selectedPage];
const originalContent = page && 'content' in page ? page.content : '';
const history = pageHistory[selectedPage]; const history = pageHistory[selectedPage];
const originalContentToFormat = history?.originalContent || originalContent; // 使用最新版本
const lastVersion = history?.versions.find((version) => version.version === history.latestVersion);
const lastVersionContent = normalizeContent(lastVersion?.content);
if (currentPage?.content) { if (lastVersionContent) {
const currentContent = currentPage.content as string; const lastVersionCacheKey = `${selectedPage}:${history?.latestVersion}`;
if (formatCache.has(lastVersionCacheKey)) {
if (formatCache.has(currentContent)) { setCompareVersionContent(formatCache.get(lastVersionCacheKey)!);
setFormattedContent(formatCache.get(currentContent)!);
contentCacheKey.current = currentContent;
} else { } else {
formatCode(currentContent, { parser: 'html' }) formatCode(lastVersionContent, { parser: 'html' })
.then((formatted) => { .then((formatted) => {
formatCache.set(currentContent, formatted); formatCache.set(lastVersionCacheKey, formatted);
setFormattedContent(formatted); setCompareVersionContent(formatted);
contentCacheKey.current = currentContent;
}) })
.catch((error) => { .catch((error) => {
console.error('格式化当前内容失败:', error); console.error('格式化当前内容失败:', error);
setFormattedContent(currentContent); setCompareVersionContent(lastVersionContent);
contentCacheKey.current = currentContent;
}); });
} }
} }
if (originalContentToFormat) { // 获取上一次由聊天所触发的历史版本(不含本次版本),或者第一个初始化版本
if (formatCache.has(originalContentToFormat)) { const autoSaveHistories = history?.versions.filter((version) => version.changeSource === 'auto-save') || [];
setEffectiveOriginalContent(formatCache.get(originalContentToFormat)!); 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;
} else { } else {
formatCode(originalContentToFormat, { parser: 'html' }) 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' })
.then((formatted) => { .then((formatted) => {
formatCache.set(originalContentToFormat, formatted); formatCache.set(lastTimeChatVersionCacheKey, formatted);
setEffectiveOriginalContent(formatted); setBaselineVersionContent(formatted);
}) })
.catch((error) => { .catch((error) => {
console.error('格式化原始内容失败:', error); console.error('格式化原始内容失败:', error);
setEffectiveOriginalContent(originalContentToFormat); setBaselineVersionContent(lastTimeChatVersionContent);
}); });
} }
} }
setShouldProcess(false); setShouldProcess(false);
}, [currentPage?.content, selectedPage, currentView, shouldProcess, pageHistory, pages]); }, [selectedPage, currentView, shouldProcess, pageHistory]);
if (!selectedPage || !currentPage) { if (!selectedPage) {
return ( return (
<div className="flex size-full justify-center items-center bg-upage-elements-background-depth-1 text-upage-elements-textPrimary"> <div className="flex size-full justify-center items-center bg-upage-elements-background-depth-1 text-upage-elements-textPrimary">
@@ -854,8 +833,8 @@ export const DiffView = memo(() => {
return ( return (
<div className="h-full overflow-hidden"> <div className="h-full overflow-hidden">
<InlineDiffComparison <InlineDiffComparison
beforeCode={effectiveOriginalContent} beforeCode={baselineVersionContent}
afterCode={formattedContent} afterCode={compareVersionContent}
language={'html'} language={'html'}
pageName={selectedPage} pageName={selectedPage}
lightTheme="github-light" lightTheme="github-light"

View File

@@ -0,0 +1,199 @@
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';

View File

@@ -1,7 +1,5 @@
import { Popover, Transition } from '@headlessui/react';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import classNames from 'classnames'; import classNames from 'classnames';
import { type Change, diffLines } from 'diff';
import { type HTMLMotionProps, motion, type Variants } from 'framer-motion'; import { type HTMLMotionProps, motion, type Variants } from 'framer-motion';
import { memo, useCallback, useMemo, useState } from 'react'; import { memo, useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -26,6 +24,7 @@ import type { PageMap } from '~/types/pages';
import { renderLogger } from '~/utils/logger'; import { renderLogger } from '~/utils/logger';
import { DiffView } from './DiffView'; import { DiffView } from './DiffView';
import { EditorPanel } from './EditorPanel'; import { EditorPanel } from './EditorPanel';
import { PageModifiedDropdown } from './PageModifiedDropdown';
import { Preview } from './Preview'; import { Preview } from './Preview';
const viewTransition = { ease: cubicEasingFn }; const viewTransition = { ease: cubicEasingFn };
@@ -62,154 +61,6 @@ const workbenchVariants = {
}, },
} satisfies Variants; } 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(() => { export const WebBuilder = memo(() => {
renderLogger.trace('webBuilder'); renderLogger.trace('webBuilder');
@@ -251,7 +102,7 @@ export const WebBuilder = memo(() => {
webBuilderStore.setSelectedPage(pageName); webBuilderStore.setSelectedPage(pageName);
}, []); }, []);
const onAutoPageSave = useCallback<OnSaveCallback>(() => { const onPageSave = useCallback<OnSaveCallback>(() => {
if (isStreaming) { if (isStreaming) {
return; return;
} }
@@ -259,7 +110,7 @@ export const WebBuilder = memo(() => {
}, [isStreaming]); }, [isStreaming]);
const doPageSave = useCallback(() => { const doPageSave = useCallback(() => {
webBuilderStore.saveAllPages().catch(() => { webBuilderStore.saveAllPages('user').catch(() => {
toast.error('文件内容更新失败'); toast.error('文件内容更新失败');
}); });
const currentMessageId = webBuilderStore.chatStore.currentMessageId.get(); const currentMessageId = webBuilderStore.chatStore.currentMessageId.get();
@@ -377,7 +228,7 @@ export const WebBuilder = memo(() => {
unsavedPages={unsavedPages} unsavedPages={unsavedPages}
onPageSelect={onPageSelect} onPageSelect={onPageSelect}
onEditorChange={onEditorChange} onEditorChange={onEditorChange}
onPageSave={onAutoPageSave} onPageSave={onPageSave}
onPageReset={onPageReset} onPageReset={onPageReset}
onLoad={onLoad} onLoad={onLoad}
onReady={onReady} onReady={onReady}

View File

@@ -65,10 +65,11 @@ export function useChatMessage({
addStoppedProgressMessage('网络连接中断,响应已停止'); addStoppedProgressMessage('网络连接中断,响应已停止');
}, },
onFinish: ({ message }) => { onFinish: ({ message }) => {
// 增加延迟,避免与当前流式请求冲突
setTimeout(() => { setTimeout(() => {
// 保存 editor project // 保存 editor project
saveProject(message.id); saveProject(message.id);
}, SAVE_PROJECT_DELAY_MS); }, SAVE_PROJECT_DELAY_MS * 2); // 延迟时间加倍到2秒
refreshUsageStats(); refreshUsageStats();
logger.debug('Finished streaming'); logger.debug('Finished streaming');
}, },

View File

@@ -1,4 +1,3 @@
import { useFetcher } from '@remix-run/react';
import { useEditorStorage } from '~/.client/persistence/editor'; import { useEditorStorage } from '~/.client/persistence/editor';
import { webBuilderStore } from '~/.client/stores/web-builder'; import { webBuilderStore } from '~/.client/stores/web-builder';
import type { ApiResponse } from '~/types/global'; import type { ApiResponse } from '~/types/global';
@@ -7,7 +6,6 @@ import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('useGrapesProject'); const logger = createScopedLogger('useGrapesProject');
export function useProject() { export function useProject() {
const fetcher = useFetcher();
const { saveEditorProject } = useEditorStorage(); const { saveEditorProject } = useEditorStorage();
/** /**
@@ -25,7 +23,7 @@ export function useProject() {
} }
// 保存之前,先保存所有页面 // 保存之前,先保存所有页面
await webBuilderStore.saveAllPages(); await webBuilderStore.saveAllPages('auto-save');
const projectPages = Object.values(webBuilderStore.pagesStore.pages.get()).filter((page) => page !== undefined); const projectPages = Object.values(webBuilderStore.pagesStore.pages.get()).filter((page) => page !== undefined);
const projectSections = Object.values(webBuilderStore.pagesStore.sections.get()) const projectSections = Object.values(webBuilderStore.pagesStore.sections.get())
.filter((section) => section !== undefined) .filter((section) => section !== undefined)
@@ -62,18 +60,26 @@ export function useProject() {
// 先保存在本地数据中 // 先保存在本地数据中
saveEditorProject(messageId, projectPages, projectSections); saveEditorProject(messageId, projectPages, projectSections);
// 再调用远程接口保存到后端数据库 // 再调用远程接口保存到后端数据库
// 使用fetcher调用API保存项目数据 // 使用原生 fetch 而非 Remix fetcher避免触发 revalidation 导致流式请求中断
fetcher.submit( const response = await fetch('/api/project', {
{ method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
messageId, messageId,
pages: JSON.stringify(projectPages), pages: projectPages,
sections: JSON.stringify(projectSections), sections: projectSections,
}, }),
{ });
method: 'POST',
action: '/api/project', if (!response.ok) {
}, const errorData = await response.json().catch(() => ({}));
); logger.error('保存项目失败:', errorData.message || '服务器错误');
return false;
}
logger.info('项目保存成功');
return true; return true;
} catch (error) { } catch (error) {
logger.error('保存GrapesJS项目失败:', error); logger.error('保存GrapesJS项目失败:', error);

View File

@@ -120,15 +120,16 @@ export class ChatStore {
}; };
const artifactsByMessageId = this.artifacts.get(); const artifactsByMessageId = this.artifacts.get();
let artifactsByPageName = artifactsByMessageId.get(messageId); const existingArtifactsByPageName = artifactsByMessageId.get(messageId);
if (!artifactsByPageName) { const artifactsByPageName = existingArtifactsByPageName ? new Map(existingArtifactsByPageName) : new Map();
artifactsByPageName = new Map();
artifactsByMessageId.set(messageId, artifactsByPageName);
}
artifactsByPageName.set(name, newArtifact); artifactsByPageName.set(name, newArtifact);
this.artifacts.set(artifactsByMessageId); // create new outer Map instance to trigger nanostores listener
const newArtifactsByMessageId = new Map(artifactsByMessageId);
newArtifactsByMessageId.set(messageId, artifactsByPageName);
this.artifacts.set(newArtifactsByMessageId);
const bridge = await editorBridge; const bridge = await editorBridge;
bridge.updatePageAttributes(name, { title }); bridge.updatePageAttributes(name, { title });
} }
@@ -140,14 +141,19 @@ export class ChatStore {
} }
const artifactsByMessageId = this.artifacts.get(); const artifactsByMessageId = this.artifacts.get();
const artifactsByPageName = artifactsByMessageId.get(messageId); const existingArtifactsByPageName = artifactsByMessageId.get(messageId);
if (!artifactsByPageName) { if (!existingArtifactsByPageName) {
return; return;
} }
artifactsByPageName.set(name, { ...artifact, ...state });
artifactsByMessageId.set(messageId, artifactsByPageName);
this.artifacts.set(artifactsByMessageId); const artifactsByPageName = new Map(existingArtifactsByPageName);
artifactsByPageName.set(name, { ...artifact, ...state });
// create new outer Map instance to trigger nanostores listener
const newArtifactsByMessageId = new Map(artifactsByMessageId);
newArtifactsByMessageId.set(messageId, artifactsByPageName);
this.artifacts.set(newArtifactsByMessageId);
} }
private getArtifact(messageId: string, pageName: string) { private getArtifact(messageId: string, pageName: string) {

View File

@@ -3,7 +3,8 @@ import { atom, computed, type MapStore, map, type WritableAtom } from 'nanostore
import { type EditorBridge, type EventPayload, editorBridge } from '~/.client/bridge'; import { type EditorBridge, type EventPayload, editorBridge } from '~/.client/bridge';
import { computePageModifications, diffPages } from '~/.client/utils/diff'; import { computePageModifications, diffPages } from '~/.client/utils/diff';
import { isValidContent } from '~/.client/utils/html-parse'; import { isValidContent } from '~/.client/utils/html-parse';
import type { Page, PageHistory } from '~/types/actions'; import { normalizeContent } from '~/.client/utils/prettier';
import type { ChangeSource, Page, PageHistory } from '~/types/actions';
import type { PageMap, PageSection, SectionMap } from '~/types/pages'; import type { PageMap, PageSection, SectionMap } from '~/types/pages';
import { createScopedLogger } from '~/utils/logger'; import { createScopedLogger } from '~/utils/logger';
@@ -156,69 +157,67 @@ export class PagesStore {
this.modifiedPages.clear(); this.modifiedPages.clear();
} }
async savePage(pageName: string, content: string) { async savePage(pageName: string, content: string, changeSource: ChangeSource) {
const page = this.getPage(pageName); const page = this.getPage(pageName);
if (!page) { if (!page) {
return false; return false;
} }
// 保存上一次的页面内容
this.savePageHistory(pageName, content);
try { try {
this.pages.setKey(pageName, { ...page, content }); this.pages.setKey(pageName, { ...page, content });
logger.info('Page updated'); logger.info('Page updated');
// 保存上一次的页面内容
this.savePageHistory(pageName, content, changeSource);
} catch (error) { } catch (error) {
logger.error('Failed to update page content\n\n', error); logger.error('Failed to update page content\n\n', error);
throw error; throw error;
} }
} }
async savePageHistory(pageName: string, newContent: string) { async savePageHistory(pageName: string, newContent: string, changeSource: ChangeSource) {
const page = this.getPage(pageName); const page = this.getPage(pageName);
if (!page) { if (!page) {
return; return;
} }
const pageHistory = this.pageHistory.get()[pageName]; const pageHistory = this.pageHistory.get()[pageName];
// 如果不存在历史记录,则创建一个新的历史记录 // 如果不存在历史记录,则创建一个新的历史记录
const normalizedCurrentContent = newContent?.replace(/\r\n/g, '\n').trim(); if (!pageHistory) {
const originalContent = pageHistory?.originalContent || page.content!; 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!;
if (!originalContent) { if (!originalContent) {
return; return;
} }
const normalizedOriginalContent = (pageHistory?.originalContent || page.content!).replace(/\r\n/g, '\n').trim(); const normalizedCurrentContent = normalizeContent(newContent);
if (!pageHistory) { const normalizedLastContent = normalizeContent(lastVersion?.content);
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) { if (normalizedCurrentContent === normalizedLastContent) {
return; return;
} }
const unifiedDiff = diffPages(pageName, pageHistory.originalContent, newContent); const unifiedDiff = diffPages(pageName, lastVersion.content, newContent);
if (!unifiedDiff) { if (!unifiedDiff) {
return; return;
} }
const newChanges = diffLines(pageHistory.originalContent, newContent); const newChanges = diffLines(lastVersion.content, newContent);
// 检查是否有显著变化 // 检查是否有显著变化
const hasSignificantChanges = newChanges.some( const hasSignificantChanges = newChanges.some(
@@ -230,16 +229,17 @@ export class PagesStore {
const newHistory: PageHistory = { const newHistory: PageHistory = {
originalContent: pageHistory.originalContent, originalContent: pageHistory.originalContent,
lastModified: Date.now(), latestVersion: pageHistory.latestVersion + 1,
changes: [...pageHistory.changes, ...newChanges].slice(-100), // Limitar histórico de mudanças latestModified: Date.now(),
versions: [ versions: [
...pageHistory.versions, ...pageHistory.versions,
{ {
version: pageHistory.latestVersion + 1,
timestamp: Date.now(), timestamp: Date.now(),
content: newContent, content: newContent,
changeSource,
}, },
].slice(-10), // 只保留最近的 10 个版本 ].slice(-20), // 只保留最近的 20 个版本
changeSource: 'auto-save',
}; };
this.pageHistory.setKey(pageName, newHistory); this.pageHistory.setKey(pageName, newHistory);

View File

@@ -4,7 +4,7 @@ import JSZip from 'jszip';
import { atom, type WritableAtom } from 'nanostores'; import { atom, type WritableAtom } from 'nanostores';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { formatFile } from '~/.client/utils/prettier'; import { formatFile } from '~/.client/utils/prettier';
import type { Page } from '~/types/actions'; import type { ChangeSource, Page } from '~/types/actions';
import type { PageMap } from '~/types/pages'; import type { PageMap } from '~/types/pages';
import { base64ToBinary, getContentType, getExtensionFromMimeType, getFileName } from '~/utils/file-utils'; import { base64ToBinary, getContentType, getExtensionFromMimeType, getFileName } from '~/utils/file-utils';
import { createScopedLogger } from '~/utils/logger'; import { createScopedLogger } from '~/utils/logger';
@@ -66,11 +66,16 @@ export class WebBuilderStore {
}); });
} }
/**
* 手动设置页面数据,通常用于初始化页面数据。
* @param pages 页面数据
*/
setPages(pages: PageMap) { setPages(pages: PageMap) {
this.editorStore.setDocuments(pages, true); this.editorStore.setDocuments(pages, true);
for (const [pageName, page] of Object.entries(pages)) { for (const [pageName, page] of Object.entries(pages)) {
if (page) { if (page) {
this.pagesStore.setPage(pageName, page); this.pagesStore.setPage(pageName, page);
this.pagesStore.savePageHistory(pageName, page.content as string, 'initial');
} }
} }
@@ -121,27 +126,14 @@ export class WebBuilderStore {
this.editorStore.updateDocumentContent(pageName, _html); 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 documents = this.editorStore.editorDocuments.get();
const pageProperties = documents[pageName]; const pageProperties = documents[pageName];
if (pageProperties === undefined) { if (pageProperties === undefined) {
return; return;
} }
// 触发 page 的保存 // 触发 page 的保存
this.pagesStore.savePage(pageName, pageProperties.content as string).then(() => { this.pagesStore.savePage(pageName, pageProperties.content as string, changeSource).then(() => {
this.editorStore.removeUnsavedDocument(pageName, true); this.editorStore.removeUnsavedDocument(pageName, true);
}); });
} }
@@ -164,9 +156,9 @@ export class WebBuilderStore {
this.editorStore.updateDocumentContent(pageName as string, page.content as string); this.editorStore.updateDocumentContent(pageName as string, page.content as string);
} }
async saveAllPages() { async saveAllPages(changeSource: ChangeSource) {
for (const pageName of this.editorStore.unsavedDocuments.get()) { for (const pageName of this.editorStore.unsavedDocuments.get()) {
await this.saveDocument(pageName); await this.saveDocument(pageName, changeSource);
} }
} }

View File

@@ -0,0 +1,42 @@
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;
}
}

View File

@@ -50,3 +50,7 @@ export function getParser(filePath: string): BuiltInParserName | undefined {
return undefined; return undefined;
} }
} }
export function normalizeContent(content?: string) {
return content?.replace(/\r\n/g, '\n').trim();
}

View File

@@ -1,5 +1,3 @@
import type { Change } from 'diff';
export interface Page { export interface Page {
name: string; name: string;
title: string; title: string;
@@ -37,15 +35,26 @@ export interface ActionAlert {
source?: 'preview'; source?: 'preview';
} }
export interface PageHistory { export type ChangeSource = 'user' | 'auto-save' | 'initial';
originalContent: string;
lastModified: number;
changes: Change[];
versions: {
timestamp: number;
content: string;
}[];
// 记录变更来源 export interface PageHistoryVersion {
changeSource?: 'user' | 'auto-save' | 'external'; // 版本号
version: number;
// 时间戳
timestamp: number;
// 内容
content: string;
// 变更来源
changeSource: ChangeSource;
}
export interface PageHistory {
// 最初的内容
originalContent: string;
// 最新修改时间
latestModified: number;
// 最新版本
latestVersion: number;
// 版本历史
versions: PageHistoryVersion[];
} }

View File

@@ -0,0 +1,75 @@
# 二次对话必现中断问题排查记录
> 时间范围2025-10-14 ~ 2025-10-15
> 调试参与者CodexAI 助手)
> 目标:定位并解决「在同一聊天中第二次发送消息必定失败」的问题
## 一、现象与初始假设
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 fetcherrevalidation
- `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

View File

@@ -1,6 +1,6 @@
{ {
"name": "upage", "name": "upage",
"version": "1.0.0", "version": "1.0.1",
"private": true, "private": true,
"description": "使用人工智能构建可视化网页", "description": "使用人工智能构建可视化网页",
"sideEffects": false, "sideEffects": false,