424 lines
12 KiB
TypeScript
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 });
|
|
}
|
|
}
|