Files
upage-git/app/lib/stores/pages.ts

424 lines
12 KiB
TypeScript

import { diffLines } from 'diff';
import { atom, computed, type MapStore, map, type WritableAtom } from 'nanostores';
import { type EditorBridge, type EventPayload, editorBridge } from '~/lib/bridge';
import type { Page, PageHistory, Section } from '~/types/actions';
import { computePageModifications, diffPages } from '~/utils/diff';
import { isValidContent } from '~/utils/html-parse';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('PagesStore');
export type PageSection = Section & {
validRootDomId?: boolean;
};
export type PageMap = Record<string, Page | undefined>;
type ActiveSection = WritableAtom<string | undefined>;
type ActivePage = WritableAtom<string | undefined>;
export type SectionMap = Record<string, PageSection | undefined>;
/**
* 保存与 AI 交互的页面数据, AI 生成的页面数据会保存在此处。
* 当用户在两个消息之间修改 editor 时,也会讲修改的页面和内容保存在此处。
*/
export class PagesStore {
private readonly editorBridge: Promise<EditorBridge> = editorBridge;
/**
* 跟踪页面数量
*/
private size = 0;
/**
* @note 跟踪所有自上次用户消息以来被修改的文件及其原始内容,以便模型感知这些更改。
* 当用户发送另一条消息且所有更改都需要提交时,需要重置。
*/
private modifiedPages: Map<string, string> = import.meta.hot?.data?.modifiedPages ?? new Map();
/**
* 跟踪已删除的页面,防止它们在重新加载时重新出现
*/
private deletedPages: Set<string> = import.meta.hot?.data?.deletedPages ?? new Set();
/**
* 页面映射,与 AI 做交互,基于 artifacts 数据解析而来。
* 因此,此数据表示与数据库通信的底层数据,未保存的数据将不会在此处体现。
* 如果在编辑器中确定保存了数据,则需要实时同步进 #modifiedPages 中。
*/
pages: MapStore<PageMap> = import.meta.hot?.data?.pages ?? map({});
/**
* 页面历史记录,用于 diff 视图。
* 每次页面保存时,会保存上一次的页面内容。
*/
pageHistory: MapStore<Record<string, PageHistory>> = import.meta.hot?.data?.pageHistory ?? map({});
activePage: ActivePage = import.meta.hot?.data?.activePage ?? atom<string | undefined>();
currentPage = computed([this.pages, this.activePage], (pages, activePage) => {
if (!activePage) {
return undefined;
}
return pages[activePage];
});
/**
* 基于 action 的 section 映射,作为与 AI 交互的底层数据,基于 actions 数据解析而来。
*/
sections: MapStore<SectionMap> = import.meta.hot?.data?.sections ?? map({});
/**
* 当前活跃的 section。
*/
activeSection: ActiveSection = import.meta.hot?.data?.activeSection ?? atom<string | undefined>();
currentSection = computed([this.sections, this.activeSection], (sections, activeSection) => {
if (!activeSection) {
return undefined;
}
return sections[activeSection];
});
get pagesCount() {
return this.size;
}
constructor() {
// Load deleted paths from localStorage if available
try {
if (typeof localStorage !== 'undefined') {
const deletedPagesJson = localStorage.getItem('upage-deleted-pages');
if (deletedPagesJson) {
const deletedPages = JSON.parse(deletedPagesJson);
if (Array.isArray(deletedPages)) {
deletedPages.forEach((path) => this.deletedPages.add(path));
}
}
}
} catch (error) {
logger.error('Failed to load deleted paths from localStorage', error);
}
if (import.meta.hot && import.meta.hot.data) {
// Persist our state across hot reloads
import.meta.hot.data.pages = this.pages;
import.meta.hot.data.modifiedPages = this.modifiedPages;
import.meta.hot.data.deletedPages = this.deletedPages;
import.meta.hot.data.sections = this.sections;
import.meta.hot.data.pageHistory = this.pageHistory;
}
this.#init();
this.setupCoordination();
}
private setupCoordination() {
this.sections.listen(() => {
const currentPage = this.activePage.get();
if (currentPage && this.currentSection.get() === undefined) {
const pageActions = this.getPage(currentPage)?.actionIds;
if (pageActions) {
this.setActiveSection(pageActions[pageActions.length - 1]);
}
}
});
}
getPage(pageName: string) {
return this.pages.get()[pageName];
}
getPageModifications() {
return computePageModifications(this.pages.get(), this.modifiedPages);
}
getModifiedPages() {
let modifiedPages: { [pageName: string]: Page } | undefined = undefined;
for (const [pageName, originalContent] of this.modifiedPages) {
const page = this.pages.get()[pageName];
if (!page) {
continue;
}
if (page.content === originalContent) {
continue;
}
if (!modifiedPages) {
modifiedPages = {};
}
modifiedPages[pageName] = page;
}
return modifiedPages;
}
resetPageModifications() {
this.modifiedPages.clear();
}
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');
} catch (error) {
logger.error('Failed to update page content\n\n', error);
throw error;
}
}
async savePageHistory(pageName: string, newContent: string) {
const page = this.getPage(pageName);
if (!page) {
return;
}
const pageHistory = this.pageHistory.get()[pageName];
// 如果不存在历史记录,则创建一个新的历史记录
const normalizedCurrentContent = newContent?.replace(/\r\n/g, '\n').trim();
const originalContent = pageHistory?.originalContent || page.content!;
if (!originalContent) {
return;
}
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, pageHistory.originalContent, newContent);
if (!unifiedDiff) {
return;
}
const newChanges = diffLines(pageHistory.originalContent, newContent);
// 检查是否有显著变化
const hasSignificantChanges = newChanges.some(
(change) => (change.added || change.removed) && change.value.trim().length > 0,
);
if (!hasSignificantChanges) {
return;
}
const newHistory: PageHistory = {
originalContent: pageHistory.originalContent,
lastModified: Date.now(),
changes: [...pageHistory.changes, ...newChanges].slice(-100), // Limitar histórico de mudanças
versions: [
...pageHistory.versions,
{
timestamp: Date.now(),
content: newContent,
},
].slice(-10), // 只保留最近的 10 个版本
changeSource: 'auto-save',
};
this.pageHistory.setKey(pageName, newHistory);
}
async #init() {
const grapesBridge = await this.editorBridge;
this.#cleanupDeletedPages();
grapesBridge.watch(({ type, payload }) => this.#processGrapesBridgeEvent(type, payload));
}
/**
* Removes any deleted files/folders from the store
*/
#cleanupDeletedPages() {
if (this.deletedPages.size === 0) {
return;
}
const currentPages = this.pages.get();
for (const deletedPageName of this.deletedPages) {
if (currentPages[deletedPageName]) {
this.pages.setKey(deletedPageName, undefined);
this.size--;
}
for (const [path] of Object.entries(currentPages)) {
if (path.startsWith(deletedPageName + '/')) {
this.pages.setKey(path, undefined);
this.size--;
if (this.modifiedPages.has(path)) {
this.modifiedPages.delete(path);
}
}
}
}
}
#processGrapesBridgeEvent(type: string, payload: EventPayload) {
const { pageName } = payload;
// Skip processing if this page was explicitly deleted
if (this.deletedPages.has(pageName)) {
return;
}
switch (type) {
case 'add_page': {
const { title: pageTitle, actionIds = [] } = payload;
this.pages.setKey(pageName, {
name: pageName,
title: pageTitle,
content: '',
actionIds,
});
this.size++;
logger.info(`Page created: ${pageName}`);
break;
}
case 'upsert_page': {
const { title: pageTitle, actionIds = [] } = payload;
this.pages.setKey(pageName, {
name: pageName,
title: pageTitle,
actionIds,
});
break;
}
case 'remove_page': {
this.deletedPages.add(pageName);
this.pages.setKey(pageName, undefined);
this.size--;
if (this.modifiedPages.has(pageName)) {
this.modifiedPages.delete(pageName);
}
this.#persistDeletedPages();
logger.info(`Page deleted: ${pageName}`);
break;
}
case 'update_section': {
const { id, section } = payload;
this.sections.setKey(id, { id, type: 'section', ...section });
break;
}
}
}
async createPage(pageName: string, pageTitle: string) {
await this.editorBridge.then((grapesBridge) => grapesBridge.createPage(pageName, { title: pageTitle }));
return true;
}
async deletePage(pageName: string) {
await this.editorBridge.then((grapesBridge) => grapesBridge.removePage(pageName));
return true;
}
// method to persist deleted paths to localStorage
#persistDeletedPages() {
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('upage-deleted-pages', JSON.stringify([...this.deletedPages]));
}
} catch (error) {
logger.error('Failed to persist deleted paths to localStorage', error);
}
}
updateSection(actionId: string, sectionContent: string) {
const sections = this.sections.get();
const sectionState = sections[actionId];
if (!sectionState) {
return;
}
const oldContent = sectionState.content;
const contentChanged = oldContent !== sectionContent;
if (contentChanged) {
this.sections.setKey(actionId, { ...sectionState, content: sectionContent });
}
this.updateSectionRootDomId(actionId, sectionState, sectionContent);
}
private updateSectionRootDomId(actionId: string, section: PageSection, sectionContent: string) {
if (section.validRootDomId) {
return;
}
if (section.action === 'remove') {
this.sections.setKey(actionId, { ...section, rootDomId: section.domId, validRootDomId: true });
return;
}
const isValid = isValidContent(sectionContent);
if (!isValid) {
return;
}
const div = document.createElement('div');
div.innerHTML = sectionContent;
const rootDomId = div.firstElementChild?.id;
if (!rootDomId) {
return;
}
const oldRootDomId = section.rootDomId;
if (oldRootDomId && oldRootDomId === rootDomId) {
this.sections.setKey(actionId, { ...section, validRootDomId: true });
} else {
this.sections.setKey(actionId, { ...section, rootDomId });
}
}
setActiveSection(sectionId: string | undefined) {
this.activeSection.set(sectionId);
}
setActivePage(pageName: string | undefined) {
this.activePage.set(pageName);
}
setPage(pageName: string, page: Page) {
const oldPage = this.getPage(pageName);
if (!oldPage) {
return;
}
this.pages.setKey(pageName, { ...oldPage, ...page });
}
}