diff --git a/docs/architecture/storage-runtime-architecture.md b/docs/architecture/storage-runtime-architecture.md new file mode 100644 index 00000000..3c270c68 --- /dev/null +++ b/docs/architecture/storage-runtime-architecture.md @@ -0,0 +1,640 @@ +# 存储运行时架构与边界说明 + +## 📋 文档目的 + +本文档用于说明当前仓库在 **Web / Extension / Desktop** 三种运行环境下的真实存储结构,重点回答以下问题: + +- 现在有哪些存储方式 +- 各存储里有哪些逻辑区域 +- 不同类型的内容会落到哪里 +- 每个区域的大小限制、配额和清理策略是什么 +- 当前已经建立了哪些防线 +- 还存在哪些灰区和后续必须补齐的边界 + +本文档描述的是 **当前运行态事实**,不是理想设计图。 + +## 🎯 核心原则 + +当前系统必须严格区分两类数据: + +1. **结构化业务数据** + - 例如设置、会话快照、模型配置、收藏文本、上下文文档。 + - 应存入结构化主存储。 + +2. **图片二进制资产** + - 例如上传图、生成图、收藏封面、收藏示例图。 + - 必须存入独立图片资产库。 + - 结构化主存储中只允许保存图片引用和轻量元数据,不允许保存大体积 inline base64。 + +这条边界是本轮存储事故复盘后的第一原则。 + +## 🧱 一、物理存储介质 + +### 1. Web / Extension 结构化主存储 + +- 介质:IndexedDB +- 实现:`DexieStorageProvider` +- 数据库名:`PromptOptimizerDB` +- 表结构:单表 `storage` +- 本质:一个共享的 KV 桶 + +相关文件: + +- `packages/core/src/services/storage/dexieStorageProvider.ts` + +### 2. Desktop 结构化主存储 + +- 介质:主进程 JSON 文件 +- 实现:`FileStorageProvider` +- 主文件:`/prompt-optimizer-data.json` +- 备份文件:`/prompt-optimizer-data.json.backup` +- 写入方式:内存镜像 + 延迟写盘 + 原子替换 + 备份恢复 + +相关文件: + +- `packages/core/src/services/storage/fileStorageProvider.ts` +- `packages/desktop/main.js` + +### 3. Session 图片资产库 + +- 介质:IndexedDB +- 实现:`ImageStorageService` +- 数据库名:`PromptOptimizerImageDB` +- 目标:保存会话使用的上传图、生成图和相关图片资产 + +表结构分为两张表: + +- `imageMetadata` +- `imageData` + +相关文件: + +- `packages/core/src/services/image/storage.ts` +- `packages/ui/src/composables/system/useAppInitializer.ts` + +### 4. Favorite 图片资产库 + +- 介质:IndexedDB +- 实现:`ImageStorageService` +- 数据库名:`PromptOptimizerFavoriteImageDB` +- 目标:保存收藏项相关的封面图、示例图、引用图资产 + +表结构同样分为两张表: + +- `imageMetadata` +- `imageData` + +相关文件: + +- `packages/core/src/services/image/storage.ts` +- `packages/ui/src/composables/system/useAppInitializer.ts` + +### 5. 非主线路径 provider + +代码里还存在两个 provider,但不是当前线上主路径: + +- `LocalStorageProvider` + - 浏览器本地存储实现 + - 代码声明能力上限约 `5MB` +- `MemoryStorageProvider` + - 主要用于测试或临时环境 + +相关文件: + +- `packages/core/src/services/storage/localStorageProvider.ts` +- `packages/core/src/services/storage/factory.ts` + +## 🗂️ 二、结构化主存储里的逻辑分区 + +虽然 Web 物理上只有 `PromptOptimizerDB.storage` 一张表,Desktop 物理上只有一个 JSON 文件,但逻辑上可以拆成以下几块。 + +### 1. `pref:*` 命名空间 + +由 `PreferenceService` 统一管理。逻辑 key 会被自动加上 `pref:` 前缀后再落盘。 + +示例: + +- `global-settings/v1` -> `pref:global-settings/v1` +- `session/v1/basic-system` -> `pref:session/v1/basic-system` +- `variableManager.storage` -> `pref:variableManager.storage` + +相关文件: + +- `packages/core/src/services/preference/service.ts` + +### 2. Core 直接 key + +这些 key 不经过 `PreferenceService`,直接写入主存储: + +- `models` +- `image-models` +- `user-templates` +- `prompt_history` + +相关文件: + +- `packages/core/src/constants/storage-keys.ts` + +### 3. Context 单文档区 + +上下文不是按多个 key 分散存,而是集中在一个文档里: + +- `ctx:store` + +这个文档同时保存: + +- 所有 context 数据 +- 当前激活的 context id +- 文档版本号 + +相关文件: + +- `packages/core/src/services/context/constants.ts` +- `packages/core/src/services/context/repo.ts` + +### 4. Favorites 直接 key 区 + +收藏系统也是独立 key,不经过 `PreferenceService`: + +- `favorites` +- `favorite_categories` +- `favorite_stats` +- `favorite_tags` +- `favorite_categories_initialized` + +相关文件: + +- `packages/core/src/services/favorite/manager.ts` + +### 5. 旧 UI key / 混合态 key + +当前仓库仍存在一批历史 key 和新快照并存的情况,包括但不限于: + +- `global-settings/v1` +- `app:settings:ui:function-mode` +- `app:settings:ui:basic-sub-mode` +- `app:settings:ui:pro-sub-mode` +- `app:settings:ui:builtin-template-language` + +需要特别注意: + +- `imageSubMode` 现在以路由为真源,不再直接依赖 preference 持久化 +- 但应用外壳仍会把当前路由状态镜像回 `global-settings/v1` + +相关文件: + +- `packages/ui/src/stores/settings/useGlobalSettings.ts` +- `packages/ui/src/composables/mode/useFunctionMode.ts` +- `packages/ui/src/composables/mode/useBasicSubMode.ts` +- `packages/ui/src/composables/mode/useProSubMode.ts` +- `packages/ui/src/composables/mode/useImageSubMode.ts` +- `packages/ui/src/components/app-layout/PromptOptimizerApp.vue` + +## 📦 三、不同内容现在实际存到哪里 + +### 1. 会话快照 + +各功能区会话存储在 `pref:session/v1/*` 下,包括: + +- basic-system +- basic-user +- pro-multi +- pro-variable +- image-text2image +- image-image2image +- image-multiimage + +这些快照保存的是结构化 JSON,例如: + +- 原始 prompt +- 优化后 prompt +- 模型选择 +- 模板选择 +- 评测结果 +- 变量值 +- 图片引用 id + +相关文件: + +- `packages/ui/src/stores/session/useSessionManager.ts` +- `packages/ui/src/stores/session/useBasicSystemSession.ts` +- `packages/ui/src/stores/session/useBasicUserSession.ts` +- `packages/ui/src/stores/session/useProMultiMessageSession.ts` +- `packages/ui/src/stores/session/useProVariableSession.ts` +- `packages/ui/src/stores/session/useImageText2ImageSession.ts` +- `packages/ui/src/stores/session/useImageImage2ImageSession.ts` +- `packages/ui/src/stores/session/useImageMultiImageSession.ts` + +### 2. Session 图片 + +会话中真正的图片字节不应该放进 session 快照,而是写入 `PromptOptimizerImageDB`。 + +session 快照里应该只保留: + +- `assetId` +- `image-ref` +- 轻量 metadata + +图片写入和引用转换相关文件: + +- `packages/ui/src/utils/image-asset-storage.ts` +- `packages/ui/src/stores/session/imageStorageMaintenance.ts` + +### 3. 收藏文本和收藏元数据 + +收藏项本体仍保存在结构化主存储中的 `favorites` key 下。 + +其中包含: + +- 标题 +- 正文内容 +- 标签 +- 分类 +- functionMode +- optimizationMode +- imageSubMode +- metadata + +注意: + +- `favorites` 不是 `pref:*` 命名空间的一部分 +- 因此它不受 `PreferenceService` 针对 session 的大小防线保护 + +相关文件: + +- `packages/core/src/services/favorite/manager.ts` + +### 4. 收藏图片资产 + +收藏相关图片应该写入 `PromptOptimizerFavoriteImageDB`,然后在收藏 metadata 中保存引用信息: + +- `coverAssetId` +- `assetIds` +- `imageAssetIds` +- `inputImageAssetIds` + +相关文件: + +- `packages/ui/src/utils/favorite-media.ts` +- `packages/ui/src/components/SaveFavoriteDialog.vue` +- `packages/ui/src/composables/app/useAppPromptGardenImport.ts` + +### 5. 收藏图片 fallback + +当前收藏元数据 schema 仍允许 URL 型 fallback: + +- `coverUrl` +- `urls` + +这意味着当前系统允许: + +- 远程 URL fallback + +但现在已经明确禁止: + +- `data:image/...;base64,...` 这种 inline 数据 URL 被写入 favorite metadata + +相关文件: + +- `packages/ui/src/utils/favorite-media.ts` +- `packages/core/src/services/favorite/manager.ts` + +### 6. Prompt Garden 导入收藏 + +Prompt Garden 导入链路已经改成严格模式: + +- 优先把图片落到图片资产库 +- 不再允许把 inline 图片内容回退进收藏 metadata +- 自动收藏路径在图片落盘失败时会跳过收藏保存 +- 确认弹窗路径在图片落盘失败时不再附带 `media` fallback + +相关文件: + +- `packages/ui/src/composables/app/useAppPromptGardenImport.ts` + +### 7. 模型、模板、历史、上下文 + +这几类数据都属于结构化主存储: + +- 模型配置 -> `models` +- 图像模型配置 -> `image-models` +- 用户模板 -> `user-templates` +- 历史记录 -> `prompt_history` +- 上下文文档 -> `ctx:store` + +相关文件: + +- `packages/core/src/services/model/manager.ts` +- `packages/core/src/services/template/manager.ts` +- `packages/core/src/services/history/manager.ts` +- `packages/core/src/services/context/repo.ts` + +## 📏 四、大小限制、配额和清理策略 + +### 1. Session 快照大小限制 + +`PreferenceService` 现在对 `session/*` key 建立了硬限制: + +- 单条 session 快照最大 `1 MiB` + +限制覆盖: + +- 写入时校验 +- 读取时校验 + +超限行为: + +- 写入失败,抛出结构化存储错误 +- 读取失败时,`SessionManager` 会清理超限的单个 session key,而不是清空整库 + +相关文件: + +- `packages/core/src/services/preference/service.ts` +- `packages/ui/src/stores/session/useSessionManager.ts` + +### 2. Session 图片库配额 + +`PromptOptimizerImageDB` 默认配额: + +- `maxCacheSize = 50 MB` +- `maxAge = 7 天` +- `maxCount = 100` +- `autoCleanupThreshold = 0.8` + +清理顺序: + +1. 清理过期图片 +2. 超过数量上限时按最旧访问时间删除 +3. 超过总容量时继续按最旧访问时间删除,直到降到目标阈值 + +相关文件: + +- `packages/core/src/services/image/storage.ts` +- `packages/ui/src/composables/system/useAppInitializer.ts` + +### 3. Favorite 图片库配额 + +`PromptOptimizerFavoriteImageDB` 默认配额: + +- `maxCacheSize = 200 MB` +- `maxAge = 365 天` +- `maxCount = 1000` +- `autoCleanupThreshold = 0.9` + +这套配置比 session 图片库更宽松,因为收藏被视为长期保留资产。 + +相关文件: + +- `packages/core/src/services/image/storage.ts` +- `packages/ui/src/composables/system/useAppInitializer.ts` + +### 4. 历史记录限制 + +`prompt_history` 最多保留: + +- `50` 条记录 + +相关文件: + +- `packages/core/src/services/history/manager.ts` + +### 5. Desktop 文件存储运行特性 + +Desktop 主存储没有应用层固定大小上限,但有运行时写盘策略: + +- 延迟写入:`500ms` +- 最大 flush 超时:`3s` +- 自动备份恢复:主文件失败时尝试 backup + +这意味着 Desktop 不是“无限安全”,只是没有像 session 那样的显式 size cap。 + +相关文件: + +- `packages/core/src/services/storage/fileStorageProvider.ts` + +### 6. LocalStorage provider 能力上限 + +`LocalStorageProvider` 报告的能力上限约为: + +- `5MB` + +但它不是当前主线路径。 + +相关文件: + +- `packages/core/src/services/storage/localStorageProvider.ts` + +## 🔄 五、导入导出边界 + +当前 `DataManager.exportAllData()` 导出的不是整套运行态存储,而是一个经过裁剪的业务数据集合。 + +当前导出包含: + +- `history` +- `models` +- `userTemplates` +- `userSettings` +- `contexts` + +当前不包含: + +- `favorites` +- `favorite image assets` +- `session snapshots` +- `session image assets` + +因此必须明确: + +- **导入导出边界 != 运行时存储边界** +- 当前导入导出不能视为完整灾备备份 + +相关文件: + +- `packages/core/src/services/data/manager.ts` +- `packages/core/src/services/preference/service.ts` + +## 🚨 六、当前已经建立的防线 + +### 1. Session 超限防线 + +- `session/*` 超过 `1 MiB` 直接拒绝 +- 恢复时只清理超限单 key,不再整库清空 + +### 2. Favorite inline 图片防线 + +- FavoriteManager 现在拒绝 `data:image/...` inline data URL 进入 metadata + +### 3. Prompt Garden 收藏严格落盘策略 + +- 图片资产落盘失败时不再用 inline 图片兜底 + +### 4. Session 图片与结构化快照分层 + +- 图片二进制进入独立图片库 +- session 快照只保留引用 + +## ⚠️ 七、当前仍存在的灰区和风险 + +### 1. 结构化主存储物理上仍是共享桶 + +无论在 Web 还是 Desktop,本质上都还是单一主存储容器: + +- Web 是一张 Dexie 表 +- Desktop 是一个 JSON 文件 + +这意味着只要某条链路把大对象写错位置,影响范围就不是单个业务模块,而是整个主存储。 + +### 2. Favorites 不经过 PreferenceService + +`favorites` 是直接 key,不走 `PreferenceService`,因此: + +- session 的 `1 MiB` 防线不覆盖 favorites +- favorites 必须在自身 manager 和调用方建立独立边界 + +### 3. Favorite 图片资产目前更像“配额库”,不是“强引用回收库” + +已确认: + +- session 图片库会根据 session 快照引用做 GC +- 收藏删除链路当前只删除 `favorites` 文本记录 +- 没有看到收藏删除后同步清理 `PromptOptimizerFavoriteImageDB` 孤儿资产的强引用回收逻辑 + +这意味着当前 favorite 图片库仍存在孤儿资产积累风险,更多依赖配额清理,而不是引用级删除。 + +相关文件: + +- `packages/ui/src/stores/session/imageStorageMaintenance.ts` +- `packages/ui/src/components/FavoriteButton.vue` +- `packages/ui/src/components/FavoriteManager.vue` +- `packages/core/src/services/favorite/manager.ts` + +### 4. UI 设置仍处于混合态 + +当前存在: + +- 新的 `global-settings/v1` +- 旧的 `app:settings:ui:*` +- 路由真源与持久化镜像并存 + +这不一定立即导致数据损坏,但会提高认知成本,也会增加后续修改时误写双真源的风险。 + +### 5. 导出数据不覆盖图片资产 + +当前导出文件不能还原: + +- 收藏图片库 +- session 图片库 + +这也是为什么“运行时数据完整性”和“导出文件完整性”必须分开讨论。 + +## 🛡️ 八、必须遵守的存储红线 + +后续所有功能开发必须遵守以下规则: + +### 红线 1:结构化主存储禁止承载大体积二进制内容 + +禁止把以下内容直接写入主存储: + +- 图片 base64 +- data URL +- 大型二进制序列化字符串 + +### 红线 2:图片只能进入图片资产库 + +所有图片相关能力都必须遵循: + +- 先落图片资产库 +- 再把 `assetId` 写入业务对象 + +### 红线 3:任何 fallback 都不能回退到 inline 图片 + +允许的 fallback: + +- 外部 URL +- 跳过保存 +- 显式失败 + +禁止的 fallback: + +- 将 `data:image/...` 写回 favorites 或 session metadata + +### 红线 4:删除业务对象必须考虑资产回收 + +如果一个对象持有图片 `assetId`,那么删除该对象时必须明确以下策略之一: + +- 立即删除资产 +- 标记引用减少后异步 GC +- 周期性按引用扫描清理 + +不能只删文本记录,不考虑资产生命周期。 + +### 红线 5:provider 级测试必须覆盖真实请求体和真实落盘结果 + +仅验证“调用链路经过某个 adapter”是不够的。 + +必须补齐: + +- provider 级 payload 断言 +- 存储层实际写入结果断言 +- 大对象拒绝与回退行为断言 + +## 🧭 九、建议的后续治理方向 + +### 1. Favorite 图片库补引用级 GC + +目标: + +- 删除收藏时同步处理收藏图片资产 +- 或建立 favorites 专属的引用扫描 + 清理任务 + +### 2. 为 favorites 建立独立 size guard + +建议: + +- 不依赖 `PreferenceService` +- 在 `FavoriteManager` 或更底层 provider 边界增加单项和总量限制 + +### 3. 收口 UI 设置双真源 + +建议逐步统一: + +- 哪些由路由主导 +- 哪些由 `global-settings/v1` 主导 +- 哪些旧 key 只保留迁移读,不再写入 + +### 4. 补齐“结构化存储禁止 inline 图片”的系统性测试 + +建议至少覆盖: + +- session +- favorites +- Prompt Garden import +- SaveFavoriteDialog +- 未来任何带图片快照的新功能 + +## ✅ 十、结论 + +当前系统的真实运行时存储结构可以总结为: + +1. **结构化主存储** + - 保存设置、会话快照、模型、模板、历史、上下文、收藏文本等结构化数据。 + +2. **Session 图片资产库** + - 保存会话期间使用的图片字节。 + +3. **Favorite 图片资产库** + - 保存收藏相关的图片资产。 + +当前已经建立了两条关键防线: + +- `session/*` 的 `1 MiB` 硬限制 +- favorites metadata 禁止 inline 图片 data URL + +但要彻底避免同类事故再次发生,还需要继续补齐: + +- favorite 图片资产回收 +- favorites 的独立容量防线 +- 旧 UI key 的收口 +- provider / storage 边界的强测试 + +只有当“结构化数据”和“图片资产”在架构、代码、测试、删除回收四个层面都被强制分层后,这类问题才算真正从根上解决。 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6145919f..2691c220 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -106,6 +106,16 @@ export { DexieStorageProvider } from './services/storage/dexieStorageProvider' export { LocalStorageProvider } from './services/storage/localStorageProvider' export { MemoryStorageProvider } from './services/storage/memoryStorageProvider' export { FileStorageProvider } from './services/storage/fileStorageProvider' +export { + runStorageStartupSafetyCheck, + writeStartupRepairReport, + STARTUP_REPAIR_REPORT_PREFERENCE_KEY, + STARTUP_REPAIR_REPORT_STORAGE_KEY, +} from './services/storage/startup-safety-check' +export type { + StartupRepairAction, + StartupRepairReport, +} from './services/storage/startup-safety-check' // 导出提示词服务相关 export { PromptService } from './services/prompt/service' @@ -208,6 +218,16 @@ export * from './services/context/constants' export { FavoriteManager } from './services/favorite/manager' export { FavoriteManagerElectronProxy } from './services/favorite/electron-proxy' export { TagTypeConverter } from './services/favorite/type-converter' +export { + FAVORITE_ITEM_HARD_LIMIT_BYTES, + FAVORITES_SOFT_LIMIT_BYTES, + FAVORITES_HARD_LIMIT_BYTES, + INLINE_IMAGE_DATA_URL_RE, + assertFavoriteMetadataHasNoInlineImages, + assertFavoriteFitsItemBudget, + assertFavoritesPayloadWithinBudget, + normalizeFavoriteRecord, +} from './services/favorite/storage-guards' export * from './services/favorite/types' export * from './services/favorite/errors' diff --git a/packages/core/src/services/favorite/manager.ts b/packages/core/src/services/favorite/manager.ts index 74f18fc6..c0f89e39 100644 --- a/packages/core/src/services/favorite/manager.ts +++ b/packages/core/src/services/favorite/manager.ts @@ -17,6 +17,11 @@ import { } from './errors'; import { TypeMapper } from './type-mapper'; import { TagTypeConverter } from './type-converter'; +import { + assertFavoriteFitsItemBudget, + assertFavoriteMetadataHasNoInlineImages, + assertFavoritesPayloadWithinBudget, +} from './storage-guards'; /** * 收藏管理器实现 @@ -209,6 +214,8 @@ export class FavoriteManager implements IFavoriteManager { } } + assertFavoriteMetadataHasNoInlineImages(favorite.metadata); + const favoriteData = { title: favorite.title?.trim() || favorite.content.slice(0, 50) + (favorite.content.length > 50 ? '...' : ''), content: favorite.content, @@ -231,13 +238,18 @@ export class FavoriteManager implements IFavoriteManager { updatedAt: now, useCount: 0 }; + assertFavoriteFitsItemBudget(newFavorite); try { await this.storageProvider.updateData(this.STORAGE_KEYS.FAVORITES, (favorites: FavoritePrompt[] | null) => { const favoritesList = favorites || []; // 🔧 移除重复内容检查 - 允许收藏相同内容但属性不同的提示词 // 用户可能需要��同一内容设置不同的标题、分类、标签等 - return [...favoritesList, newFavorite]; + const nextFavoritesList = [...favoritesList, newFavorite]; + assertFavoritesPayloadWithinBudget(nextFavoritesList, { + warnOnSoftLimit: true, + }); + return nextFavoritesList; }); await this.updateStats(); @@ -345,6 +357,10 @@ export class FavoriteManager implements IFavoriteManager { await this.ensureInitialized(); try { + if (Object.prototype.hasOwnProperty.call(updates, 'metadata')) { + assertFavoriteMetadataHasNoInlineImages(updates.metadata); + } + await this.storageProvider.updateData(this.STORAGE_KEYS.FAVORITES, (favorites: FavoritePrompt[] | null) => { const favoritesList = favorites || []; const index = favoritesList.findIndex(f => f.id === id); @@ -352,11 +368,17 @@ export class FavoriteManager implements IFavoriteManager { throw new FavoriteNotFoundError(id); } - favoritesList[index] = { + const nextFavorite = { ...favoritesList[index], ...updates, updatedAt: Date.now() }; + assertFavoriteFitsItemBudget(nextFavorite); + + favoritesList[index] = nextFavorite; + assertFavoritesPayloadWithinBudget(favoritesList, { + warnOnSoftLimit: true, + }); return favoritesList; }); @@ -1116,6 +1138,7 @@ export class FavoriteManager implements IFavoriteManager { const normalizeMetadata = (metadata: unknown) => { if (metadata && typeof metadata === 'object') { + assertFavoriteMetadataHasNoInlineImages(metadata); return metadata as Record; } return undefined; @@ -1197,6 +1220,7 @@ export class FavoriteManager implements IFavoriteManager { updatedAt, useCount }; + assertFavoriteFitsItemBudget(newFavorite); favoritesList.push(newFavorite); timestampOffset++; @@ -1207,6 +1231,10 @@ export class FavoriteManager implements IFavoriteManager { } }); + assertFavoritesPayloadWithinBudget(favoritesList, { + warnOnSoftLimit: true, + }); + return favoritesList; }); diff --git a/packages/core/src/services/favorite/storage-guards.ts b/packages/core/src/services/favorite/storage-guards.ts new file mode 100644 index 00000000..ab5b2562 --- /dev/null +++ b/packages/core/src/services/favorite/storage-guards.ts @@ -0,0 +1,235 @@ +import type { FavoritePrompt } from './types' +import { FavoriteValidationError } from './errors' +import { TypeMapper } from './type-mapper' + +const TEXT_ENCODER = new TextEncoder() + +export const INLINE_IMAGE_DATA_URL_RE = /^data:image\/[a-z0-9.+-]+(?:;charset=[^;,]+)?;base64,/iu + +export const FAVORITE_ITEM_HARD_LIMIT_BYTES = 512 * 1024 +export const FAVORITES_SOFT_LIMIT_BYTES = 2 * 1024 * 1024 +export const FAVORITES_HARD_LIMIT_BYTES = 8 * 1024 * 1024 +const FAVORITES_SOFT_WARNING_HEADROOM_BYTES = 8 * 1024 + +const isPlainObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) + +const toTrimmedString = (value: unknown): string | undefined => { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed ? trimmed : undefined +} + +const parseTimestamp = (value: unknown, fallback: number): number => { + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { + return value + } + + if (typeof value === 'string') { + const asNumber = Number(value) + if (Number.isFinite(asNumber) && asNumber >= 0) { + return asNumber + } + + const asDate = Date.parse(value) + if (!Number.isNaN(asDate) && asDate >= 0) { + return asDate + } + } + + return fallback +} + +const normalizeTags = (value: unknown): string[] => { + if (!Array.isArray(value)) return [] + + return Array.from( + new Set( + value + .map((tag) => (typeof tag === 'string' ? tag.trim() : '')) + .filter(Boolean), + ), + ) +} + +export const getSerializedByteLength = (value: string): number => + TEXT_ENCODER.encode(value).byteLength + +export const assertFavoriteMetadataHasNoInlineImages = ( + value: unknown, + path = 'metadata', + seen: WeakSet = new WeakSet(), +): void => { + if (typeof value === 'string') { + if (INLINE_IMAGE_DATA_URL_RE.test(value.trim())) { + throw new FavoriteValidationError( + `Favorite metadata cannot contain inline image data URLs (${path})`, + ) + } + return + } + + if (!value || typeof value !== 'object') { + return + } + + if (seen.has(value as object)) { + return + } + seen.add(value as object) + + if (Array.isArray(value)) { + value.forEach((item, index) => { + assertFavoriteMetadataHasNoInlineImages(item, `${path}[${index}]`, seen) + }) + return + } + + Object.entries(value as Record).forEach(([key, child]) => { + assertFavoriteMetadataHasNoInlineImages(child, `${path}.${key}`, seen) + }) +} + +export const assertFavoriteFitsItemBudget = (favorite: FavoritePrompt): number => { + const serializedFavorite = JSON.stringify(favorite) + const itemBytes = getSerializedByteLength(serializedFavorite) + + if (itemBytes > FAVORITE_ITEM_HARD_LIMIT_BYTES) { + throw new FavoriteValidationError( + `Favorite entry exceeds hard limit of ${FAVORITE_ITEM_HARD_LIMIT_BYTES} bytes`, + ) + } + + return itemBytes +} + +export const assertFavoritesPayloadWithinBudget = ( + favorites: FavoritePrompt[], + options?: { + warnOnSoftLimit?: boolean + logWarning?: (message: string) => void + }, +): { + totalBytes: number + softLimitExceeded: boolean +} => { + favorites.forEach((favorite) => { + assertFavoriteMetadataHasNoInlineImages(favorite.metadata) + assertFavoriteFitsItemBudget(favorite) + }) + + const totalBytes = getSerializedByteLength(JSON.stringify(favorites)) + if (totalBytes > FAVORITES_HARD_LIMIT_BYTES) { + throw new FavoriteValidationError( + `favorites payload exceeds hard limit of ${FAVORITES_HARD_LIMIT_BYTES} bytes`, + ) + } + + const softLimitExceeded = + totalBytes > FAVORITES_SOFT_LIMIT_BYTES - FAVORITES_SOFT_WARNING_HEADROOM_BYTES + if (softLimitExceeded && options?.warnOnSoftLimit) { + const logWarning = options.logWarning ?? console.warn + logWarning( + `favorites payload exceeds soft limit (${totalBytes} bytes > ${FAVORITES_SOFT_LIMIT_BYTES} bytes)`, + ) + } + + return { + totalBytes, + softLimitExceeded, + } +} + +export const normalizeFavoriteRecord = ( + value: unknown, + fallbackTimestamp = Date.now(), +): FavoritePrompt => { + if (!isPlainObject(value)) { + throw new FavoriteValidationError('Favorite entry must be an object') + } + + const raw = value as Record + const rawContent = typeof raw.content === 'string' ? raw.content : '' + if (!rawContent.trim()) { + throw new FavoriteValidationError('Favorite prompt content cannot be empty') + } + + const content = rawContent + const metadata = isPlainObject(raw.metadata) ? { ...raw.metadata } : undefined + const originalContent = toTrimmedString(raw.originalContent) + const nextMetadata = originalContent + ? { ...(metadata || {}), originalContent } + : metadata + + assertFavoriteMetadataHasNoInlineImages(nextMetadata) + + let functionMode = raw.functionMode + let optimizationMode = raw.optimizationMode + const imageSubMode = raw.imageSubMode + + if ( + functionMode !== 'basic' && + functionMode !== 'context' && + functionMode !== 'image' + ) { + functionMode = 'basic' + optimizationMode = 'system' + } + + if ( + (functionMode === 'basic' || functionMode === 'context') && + optimizationMode !== 'system' && + optimizationMode !== 'user' + ) { + optimizationMode = 'system' + } + + const mapping = { + functionMode, + optimizationMode, + imageSubMode, + } + + if (!TypeMapper.validateMapping(mapping)) { + throw new FavoriteValidationError( + `Invalid favorite mode mapping: functionMode=${String(functionMode)}, optimizationMode=${String(optimizationMode)}, imageSubMode=${String(imageSubMode)}`, + ) + } + + const createdAt = parseTimestamp(raw.createdAt, fallbackTimestamp) + const updatedAt = parseTimestamp(raw.updatedAt, createdAt) + const title = + toTrimmedString(raw.title) || + (content.length > 50 ? `${content.slice(0, 50)}...` : content) + + const favorite: FavoritePrompt = { + id: + toTrimmedString(raw.id) || + `fav_salvaged_${createdAt}_${Math.random().toString(36).slice(2, 11)}`, + title, + content, + description: typeof raw.description === 'string' ? raw.description : undefined, + createdAt, + updatedAt, + tags: normalizeTags(raw.tags), + category: toTrimmedString(raw.category), + useCount: + typeof raw.useCount === 'number' && Number.isFinite(raw.useCount) && raw.useCount >= 0 + ? raw.useCount + : 0, + functionMode, + optimizationMode: + functionMode === 'basic' || functionMode === 'context' + ? (optimizationMode as 'system' | 'user') + : undefined, + imageSubMode: + functionMode === 'image' + ? (imageSubMode as 'text2image' | 'image2image' | 'multiimage') + : undefined, + metadata: nextMetadata, + } + + assertFavoriteFitsItemBudget(favorite) + + return favorite +} diff --git a/packages/core/src/services/image/storage.ts b/packages/core/src/services/image/storage.ts index 68bc6a08..161735dc 100644 --- a/packages/core/src/services/image/storage.ts +++ b/packages/core/src/services/image/storage.ts @@ -251,8 +251,12 @@ export class ImageStorageService implements IImageStorageService { * @returns 清理的图像数量 */ async cleanupOldImages(): Promise { + const maxAge = this.config.maxAge + if (typeof maxAge !== 'number' || !Number.isFinite(maxAge) || maxAge <= 0) { + return 0 + } + const now = Date.now() - const maxAge = this.config.maxAge! const cutoffTime = now - maxAge // 查找过期图像(基于 accessedAt,而非 createdAt) @@ -279,20 +283,22 @@ export class ImageStorageService implements IImageStorageService { * 3. 超过 maxCacheSize 的部分(删除最旧的) */ async enforceQuota(): Promise { - const maxAge = this.config.maxAge! + const maxAge = this.config.maxAge const maxCount = this.config.maxCount! const maxCacheSize = this.config.maxCacheSize! const now = Date.now() // 1. 清理过期图像(基于 accessedAt) - const cutoffTime = now - maxAge - const expiredImages = await this.db.imageMetadata - .where('accessedAt') - .below(cutoffTime) - .primaryKeys() + if (typeof maxAge === 'number' && Number.isFinite(maxAge) && maxAge > 0) { + const cutoffTime = now - maxAge + const expiredImages = await this.db.imageMetadata + .where('accessedAt') + .below(cutoffTime) + .primaryKeys() - if (expiredImages.length > 0) { - await this.deleteImages(expiredImages) + if (expiredImages.length > 0) { + await this.deleteImages(expiredImages) + } } // 重新获取统计(只查询 metadata 表,性能优化) diff --git a/packages/core/src/services/preference/service.ts b/packages/core/src/services/preference/service.ts index 0a5d31cd..749b76b6 100644 --- a/packages/core/src/services/preference/service.ts +++ b/packages/core/src/services/preference/service.ts @@ -5,6 +5,40 @@ import { IMPORT_EXPORT_ERROR_CODES } from "../../constants/error-codes"; import { StorageError } from "../storage/errors"; import { toErrorWithCode } from "../../utils/error"; +const SESSION_KEY_PREFIX = "session/"; +const MAX_SESSION_SNAPSHOT_BYTES = 1024 * 1024; + +type SessionStorageOperation = "read" | "write"; + +const getSerializedValueBytes = (value: string): number => + new TextEncoder().encode(value).byteLength; + +const assertSessionSnapshotSize = ( + key: string, + serializedValue: string, + operation: SessionStorageOperation, +): void => { + if (!key.startsWith(SESSION_KEY_PREFIX)) { + return; + } + + const bytes = getSerializedValueBytes(serializedValue); + if (bytes <= MAX_SESSION_SNAPSHOT_BYTES) { + return; + } + + throw new StorageError( + `Session snapshot exceeds ${MAX_SESSION_SNAPSHOT_BYTES} bytes`, + operation, + { + reason: "session_snapshot_too_large", + key, + bytes, + limitBytes: MAX_SESSION_SNAPSHOT_BYTES, + }, + ); +}; + // 需要导出的UI配置键 - 白名单验证 const UI_SETTINGS_KEYS = [ "app:settings:ui:theme-id", @@ -91,6 +125,7 @@ export class PreferenceService implements IPreferenceService { if (storedValue === null) { return defaultValue; } + assertSessionSnapshotSize(key, storedValue, "read"); // 将键添加到缓存中 this.keyCache.add(key); return JSON.parse(storedValue) as T; @@ -116,6 +151,7 @@ export class PreferenceService implements IPreferenceService { try { const prefKey = this.getPrefKey(key); const stringValue = JSON.stringify(value); + assertSessionSnapshotSize(key, stringValue, "write"); await this.storageProvider.setItem(prefKey, stringValue); // 将键添加到缓存中 diff --git a/packages/core/src/services/storage/startup-safety-check.ts b/packages/core/src/services/storage/startup-safety-check.ts new file mode 100644 index 00000000..72bee57e --- /dev/null +++ b/packages/core/src/services/storage/startup-safety-check.ts @@ -0,0 +1,441 @@ +import type { IStorageProvider } from './types' +import type { FavoritePrompt } from '../favorite/types' +import { + FAVORITES_HARD_LIMIT_BYTES, + FAVORITES_SOFT_LIMIT_BYTES, + INLINE_IMAGE_DATA_URL_RE, + assertFavoritesPayloadWithinBudget, + getSerializedByteLength, + normalizeFavoriteRecord, +} from '../favorite/storage-guards' + +const MAX_SESSION_SNAPSHOT_BYTES = 1024 * 1024 +const GENERIC_MAX_STRING_BYTES = 512 * 1024 +const CONTEXT_STORE_MAX_STRING_BYTES = 1024 * 1024 + +const DEFAULT_SESSION_STORAGE_KEYS = [ + 'pref:session/v1/basic-system', + 'pref:session/v1/basic-user', + 'pref:session/v1/pro-multi', + 'pref:session/v1/pro-variable', + 'pref:session/v1/image-text2image', + 'pref:session/v1/image-image2image', + 'pref:session/v1/image-multiimage', +] as const + +export const STARTUP_REPAIR_REPORT_PREFERENCE_KEY = 'startup-repair-report/v1' +export const STARTUP_REPAIR_REPORT_STORAGE_KEY = `pref:${STARTUP_REPAIR_REPORT_PREFERENCE_KEY}` + +type StartupRepairReason = + | 'invalid_json' + | 'invalid_structure' + | 'inline_image_detected' + | 'abnormal_string_detected' + | 'budget_exceeded' + | 'invalid_entries_removed' + | 'budget_trimmed' + | 'soft_limit_exceeded' + | 'orphan_assets_removed' + +export interface StartupRepairAction { + key: string + action: 'removed' | 'rewritten' | 'warning' + reason: StartupRepairReason + droppedCount?: number + keptCount?: number + deletedCount?: number + details?: string +} + +export interface StartupRepairReport { + checkedAt: number + actions: StartupRepairAction[] +} + +type StorageWithOptionalKeyEnumeration = IStorageProvider & { + getAllKeys?: () => string[] | Promise +} + +const isPlainObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) + +const parseStoredJsonDocument = (raw: string): unknown => { + const parsed = JSON.parse(raw) + if (typeof parsed !== 'string') { + return parsed + } + + const nested = parsed.trim() + if (!nested) { + return parsed + } + + if ( + (nested.startsWith('{') && nested.endsWith('}')) || + (nested.startsWith('[') && nested.endsWith(']')) + ) { + return JSON.parse(nested) + } + + return parsed +} + +const findInlineImagePath = ( + value: unknown, + path = '$', + seen: WeakSet = new WeakSet(), +): string | null => { + if (typeof value === 'string') { + return INLINE_IMAGE_DATA_URL_RE.test(value.trim()) ? path : null + } + + if (!value || typeof value !== 'object') { + return null + } + + if (seen.has(value as object)) { + return null + } + seen.add(value as object) + + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + const match = findInlineImagePath(value[index], `${path}[${index}]`, seen) + if (match) return match + } + return null + } + + for (const [key, child] of Object.entries(value as Record)) { + const match = findInlineImagePath(child, `${path}.${key}`, seen) + if (match) return match + } + + return null +} + +const findOversizedString = ( + value: unknown, + maxBytes: number, + path = '$', + seen: WeakSet = new WeakSet(), +): { path: string; bytes: number } | null => { + if (typeof value === 'string') { + const bytes = getSerializedByteLength(value) + return bytes > maxBytes ? { path, bytes } : null + } + + if (!value || typeof value !== 'object') { + return null + } + + if (seen.has(value as object)) { + return null + } + seen.add(value as object) + + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + const match = findOversizedString(value[index], maxBytes, `${path}[${index}]`, seen) + if (match) return match + } + return null + } + + for (const [key, child] of Object.entries(value as Record)) { + const match = findOversizedString(child, maxBytes, `${path}.${key}`, seen) + if (match) return match + } + + return null +} + +const isValidGlobalSettings = (value: unknown): boolean => { + if (!isPlainObject(value)) return false + + const functionMode = value.functionMode + const basicSubMode = value.basicSubMode + const proSubMode = value.proSubMode + const imageSubMode = value.imageSubMode + + return ( + (value.selectedThemeId === undefined || typeof value.selectedThemeId === 'string') && + (value.preferredLanguage === undefined || typeof value.preferredLanguage === 'string') && + (value.builtinTemplateLanguage === undefined || typeof value.builtinTemplateLanguage === 'string') && + (functionMode === undefined || functionMode === 'basic' || functionMode === 'pro' || functionMode === 'image') && + (basicSubMode === undefined || basicSubMode === 'system' || basicSubMode === 'user') && + (proSubMode === undefined || proSubMode === 'multi' || proSubMode === 'variable') && + ( + imageSubMode === undefined || + imageSubMode === 'text2image' || + imageSubMode === 'image2image' || + imageSubMode === 'multiimage' + ) && + (value.lastActiveAt === undefined || typeof value.lastActiveAt === 'number') + ) +} + +const isValidContextStore = (value: unknown): boolean => + isPlainObject(value) && + typeof value.currentId === 'string' && + isPlainObject(value.contexts) + +const isValidVariableStorage = (value: unknown): boolean => { + if (!isPlainObject(value)) return false + + const customVariables = value.customVariables + if (customVariables !== undefined) { + if (!isPlainObject(customVariables)) return false + const invalidEntry = Object.values(customVariables).some((item) => typeof item !== 'string') + if (invalidEntry) return false + } + + return ( + (value.advancedModeEnabled === undefined || typeof value.advancedModeEnabled === 'boolean') && + ( + value.lastConversationMessages === undefined || + Array.isArray(value.lastConversationMessages) + ) + ) +} + +const isValidSessionSnapshot = (value: unknown): boolean => + Array.isArray(value) || isPlainObject(value) + +const removeKeyAndReport = async ( + storageProvider: IStorageProvider, + report: StartupRepairReport, + action: StartupRepairAction, +): Promise => { + await storageProvider.removeItem(action.key) + report.actions.push(action) +} + +const validateStructuredKey = async ( + storageProvider: IStorageProvider, + report: StartupRepairReport, + options: { + key: string + validator: (value: unknown) => boolean + maxStringBytes: number + hardLimitBytes?: number + }, +): Promise => { + const raw = await storageProvider.getItem(options.key) + if (raw === null) { + return + } + + if ( + typeof options.hardLimitBytes === 'number' && + getSerializedByteLength(raw) > options.hardLimitBytes + ) { + await removeKeyAndReport(storageProvider, report, { + key: options.key, + action: 'removed', + reason: 'budget_exceeded', + }) + return + } + + let parsed: unknown + try { + parsed = parseStoredJsonDocument(raw) + } catch { + await removeKeyAndReport(storageProvider, report, { + key: options.key, + action: 'removed', + reason: 'invalid_json', + }) + return + } + + if (!options.validator(parsed)) { + await removeKeyAndReport(storageProvider, report, { + key: options.key, + action: 'removed', + reason: 'invalid_structure', + }) + return + } + + const inlineImagePath = findInlineImagePath(parsed) + if (inlineImagePath) { + await removeKeyAndReport(storageProvider, report, { + key: options.key, + action: 'removed', + reason: 'inline_image_detected', + details: inlineImagePath, + }) + return + } + + const oversizedString = findOversizedString(parsed, options.maxStringBytes) + if (oversizedString) { + await removeKeyAndReport(storageProvider, report, { + key: options.key, + action: 'removed', + reason: 'abnormal_string_detected', + details: `${oversizedString.path} (${oversizedString.bytes} bytes)`, + }) + } +} + +const sortFavoritesByUpdatedAtDesc = (favorites: FavoritePrompt[]): FavoritePrompt[] => + [...favorites].sort((left, right) => right.updatedAt - left.updatedAt) + +const trimFavoritesToBudget = (favorites: FavoritePrompt[]): FavoritePrompt[] => { + const kept: FavoritePrompt[] = [] + + sortFavoritesByUpdatedAtDesc(favorites).forEach((favorite) => { + const candidate = [...kept, favorite] + const totalBytes = getSerializedByteLength(JSON.stringify(candidate)) + if (totalBytes <= FAVORITES_HARD_LIMIT_BYTES) { + kept.push(favorite) + } + }) + + return kept +} + +const repairFavoritesKey = async ( + storageProvider: IStorageProvider, + report: StartupRepairReport, +): Promise => { + const raw = await storageProvider.getItem('favorites') + if (raw === null) { + return + } + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + await removeKeyAndReport(storageProvider, report, { + key: 'favorites', + action: 'removed', + reason: 'invalid_json', + }) + return + } + + if (!Array.isArray(parsed)) { + await removeKeyAndReport(storageProvider, report, { + key: 'favorites', + action: 'removed', + reason: 'invalid_structure', + }) + return + } + + const sanitizedFavorites: FavoritePrompt[] = [] + let droppedCount = 0 + const fallbackTimestamp = Date.now() + + parsed.forEach((item, index) => { + try { + sanitizedFavorites.push(normalizeFavoriteRecord(item, fallbackTimestamp + index)) + } catch { + droppedCount += 1 + } + }) + + let finalFavorites = sanitizedFavorites + let reason: StartupRepairReason | null = droppedCount > 0 ? 'invalid_entries_removed' : null + + try { + const { totalBytes } = assertFavoritesPayloadWithinBudget(finalFavorites) + if (totalBytes > FAVORITES_SOFT_LIMIT_BYTES) { + report.actions.push({ + key: 'favorites', + action: 'warning', + reason: 'soft_limit_exceeded', + keptCount: finalFavorites.length, + }) + } + } catch { + finalFavorites = trimFavoritesToBudget(finalFavorites) + reason = 'budget_trimmed' + } + + const serializedFinalFavorites = JSON.stringify(finalFavorites) + if (serializedFinalFavorites !== raw || reason) { + await storageProvider.setItem('favorites', serializedFinalFavorites) + report.actions.push({ + key: 'favorites', + action: 'rewritten', + reason: reason || 'invalid_entries_removed', + droppedCount: parsed.length - finalFavorites.length, + keptCount: finalFavorites.length, + }) + } +} + +const collectSessionStorageKeys = async (storageProvider: StorageWithOptionalKeyEnumeration): Promise => { + const enumeratedKeys = typeof storageProvider.getAllKeys === 'function' + ? await storageProvider.getAllKeys() + : [] + + return Array.from( + new Set([ + ...DEFAULT_SESSION_STORAGE_KEYS, + ...enumeratedKeys.filter((key) => key.startsWith('pref:session/v1/')), + ]), + ) +} + +export async function writeStartupRepairReport( + storageProvider: IStorageProvider, + report: StartupRepairReport, +): Promise { + if (report.actions.length === 0) { + await storageProvider.removeItem(STARTUP_REPAIR_REPORT_STORAGE_KEY) + return + } + + await storageProvider.setItem( + STARTUP_REPAIR_REPORT_STORAGE_KEY, + JSON.stringify(report), + ) +} + +export async function runStorageStartupSafetyCheck( + storageProvider: IStorageProvider, +): Promise { + const report: StartupRepairReport = { + checkedAt: Date.now(), + actions: [], + } + + await validateStructuredKey(storageProvider, report, { + key: 'pref:global-settings/v1', + validator: isValidGlobalSettings, + maxStringBytes: GENERIC_MAX_STRING_BYTES, + }) + + const sessionKeys = await collectSessionStorageKeys(storageProvider as StorageWithOptionalKeyEnumeration) + for (const key of sessionKeys) { + await validateStructuredKey(storageProvider, report, { + key, + validator: isValidSessionSnapshot, + maxStringBytes: MAX_SESSION_SNAPSHOT_BYTES, + hardLimitBytes: MAX_SESSION_SNAPSHOT_BYTES, + }) + } + + await validateStructuredKey(storageProvider, report, { + key: 'ctx:store', + validator: isValidContextStore, + maxStringBytes: CONTEXT_STORE_MAX_STRING_BYTES, + }) + + await validateStructuredKey(storageProvider, report, { + key: 'pref:variableManager.storage', + validator: isValidVariableStorage, + maxStringBytes: GENERIC_MAX_STRING_BYTES, + }) + + await repairFavoritesKey(storageProvider, report) + + return report +} diff --git a/packages/core/tests/services/favorite/manager-extended.test.ts b/packages/core/tests/services/favorite/manager-extended.test.ts index 2305acc9..e6b6e30b 100644 --- a/packages/core/tests/services/favorite/manager-extended.test.ts +++ b/packages/core/tests/services/favorite/manager-extended.test.ts @@ -217,6 +217,128 @@ describe('FavoriteManager - 扩展功能', () => { expect(favorite.metadata?.customField).toBe('自定义值'); expect(favorite.metadata?.anotherField).toBe(123); }); + + it('应该拒绝包含 data URL 封面的收藏 metadata', async () => { + await expect( + manager.addFavorite({ + title: '测试', + content: '内容', + tags: [], + functionMode: 'basic', + optimizationMode: 'system', + metadata: { + media: { + coverUrl: 'data:image/png;base64,AAAA', + }, + }, + }) + ).rejects.toThrow(FavoriteValidationError); + }); + + it('应该拒绝在更新时写入包含 data URL 的 gardenSnapshot 图片', async () => { + const id = await manager.addFavorite({ + title: '测试', + content: '内容', + tags: [], + functionMode: 'basic', + optimizationMode: 'system', + }); + + await expect( + manager.updateFavorite(id, { + metadata: { + gardenSnapshot: { + assets: { + showcases: [ + { + images: ['data:image/png;base64,BBBB'], + }, + ], + }, + }, + }, + }) + ).rejects.toThrow(FavoriteValidationError); + }); + }); + + describe('favorites 存储防线', () => { + const buildFavoriteInput = (overrides: Record = {}) => ({ + title: '测试收藏', + content: '内容', + tags: [], + functionMode: 'basic' as const, + optimizationMode: 'system' as const, + ...overrides, + }); + + const buildStoredFavorite = (id: string, contentSizeBytes: number, updatedAt = Date.now()) => ({ + id, + title: `收藏 ${id}`, + content: 'x'.repeat(contentSizeBytes), + tags: [], + functionMode: 'basic' as const, + optimizationMode: 'system' as const, + createdAt: updatedAt, + updatedAt, + useCount: 0, + }); + + it('应该拒绝写入超过 512 KiB 的单条收藏', async () => { + await expect( + manager.addFavorite( + buildFavoriteInput({ + content: 'x'.repeat(520 * 1024), + }), + ), + ).rejects.toThrow(FavoriteValidationError); + }); + + it('应该拒绝将更新后的单条收藏扩张到超过 512 KiB', async () => { + const id = await manager.addFavorite(buildFavoriteInput()); + + await expect( + manager.updateFavorite(id, { + content: 'x'.repeat(520 * 1024), + }), + ).rejects.toThrow(FavoriteValidationError); + }); + + it('应该拒绝写入后整体 favorites payload 超过 8 MiB', async () => { + const existingFavorites = Array.from({ length: 16 }, (_, index) => + buildStoredFavorite(`fav-${index + 1}`, 500 * 1024, 1_700_000_000_000 + index), + ); + mockStorage.set('favorites', JSON.stringify(existingFavorites)); + + await expect( + manager.addFavorite( + buildFavoriteInput({ + title: '会超限的收藏', + content: 'y'.repeat(500 * 1024), + }), + ), + ).rejects.toThrow(FavoriteValidationError); + }); + + it('在超过 2 MiB 但未达到 8 MiB 时应该允许写入并记录 warning', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const existingFavorites = Array.from({ length: 4 }, (_, index) => + buildStoredFavorite(`fav-soft-${index + 1}`, 500 * 1024, 1_700_000_100_000 + index), + ); + mockStorage.set('favorites', JSON.stringify(existingFavorites)); + + const id = await manager.addFavorite( + buildFavoriteInput({ + title: '接近上限的收藏', + content: 'z'.repeat(40 * 1024), + }), + ); + + expect(id).toBeTruthy(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('favorites payload exceeds soft limit'), + ); + }); }); describe('分类管理 - reorderCategories', () => { diff --git a/packages/core/tests/unit/preference/service.test.ts b/packages/core/tests/unit/preference/service.test.ts index 1ab9249a..619b1346 100644 --- a/packages/core/tests/unit/preference/service.test.ts +++ b/packages/core/tests/unit/preference/service.test.ts @@ -139,6 +139,40 @@ describe('PreferenceService', () => { }); describe('错误处理', () => { + it('should reject oversized session snapshots on write', async () => { + const oversizedValue = { + payload: 'x'.repeat(1024 * 1024 + 128), + } + + await expect( + preferenceService.set('session/v1/image-multiimage', oversizedValue) + ).rejects.toMatchObject({ + code: 'error.storage.write', + params: expect.objectContaining({ + reason: 'session_snapshot_too_large', + key: 'session/v1/image-multiimage', + }), + }) + }); + + it('should reject oversized session snapshots on read before parsing', async () => { + const oversizedRaw = JSON.stringify({ + payload: 'x'.repeat(1024 * 1024 + 128), + }) + + await mockStorage.setItem('pref:session/v1/image-multiimage', oversizedRaw) + + await expect( + preferenceService.get('session/v1/image-multiimage', null) + ).rejects.toMatchObject({ + code: 'error.storage.read', + params: expect.objectContaining({ + reason: 'session_snapshot_too_large', + key: 'session/v1/image-multiimage', + }), + }) + }); + it('should handle storage errors in get', async () => { const mockStorageWithError = { getItem: vi.fn().mockRejectedValue(new Error('Storage error')), diff --git a/packages/core/tests/unit/storage/startup-safety-check.test.ts b/packages/core/tests/unit/storage/startup-safety-check.test.ts new file mode 100644 index 00000000..ef2b1d6e --- /dev/null +++ b/packages/core/tests/unit/storage/startup-safety-check.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest' + +import { MemoryStorageProvider } from '../../../src/services/storage/memoryStorageProvider' +import { runStorageStartupSafetyCheck } from '../../../src/services/storage/startup-safety-check' + +const buildFavorite = (id: string, overrides: Record = {}) => ({ + id, + title: `收藏 ${id}`, + content: '有效内容', + tags: [], + functionMode: 'basic' as const, + optimizationMode: 'system' as const, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + useCount: 0, + ...overrides, +}) + +describe('runStorageStartupSafetyCheck', () => { + it('removes a broken global-settings key and keeps the app bootable', async () => { + const storage = new MemoryStorageProvider() + await storage.setItem('pref:global-settings/v1', '{invalid json') + + const report = await runStorageStartupSafetyCheck(storage) + + expect(await storage.getItem('pref:global-settings/v1')).toBeNull() + expect(report.actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'pref:global-settings/v1', + action: 'removed', + }), + ]), + ) + }) + + it('removes a broken ctx:store key and preserves unrelated data', async () => { + const storage = new MemoryStorageProvider() + await storage.setItem( + 'ctx:store', + JSON.stringify({ + currentId: 123, + }), + ) + await storage.setItem( + 'pref:global-settings/v1', + JSON.stringify({ + version: 1, + theme: 'light', + }), + ) + + await runStorageStartupSafetyCheck(storage) + + expect(await storage.getItem('ctx:store')).toBeNull() + expect(await storage.getItem('pref:global-settings/v1')).not.toBeNull() + }) + + it('removes only the oversized session snapshot and leaves other sessions untouched', async () => { + const storage = new MemoryStorageProvider() + await storage.setItem( + 'pref:session/v1/image-multiimage', + JSON.stringify({ + payload: 'x'.repeat(1024 * 1024 + 128), + }), + ) + await storage.setItem( + 'pref:session/v1/basic-system', + JSON.stringify({ + prompt: 'safe', + }), + ) + + const report = await runStorageStartupSafetyCheck(storage) + + expect(await storage.getItem('pref:session/v1/image-multiimage')).toBeNull() + expect(await storage.getItem('pref:session/v1/basic-system')).toBe( + JSON.stringify({ + prompt: 'safe', + }), + ) + expect(report.actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'pref:session/v1/image-multiimage', + action: 'removed', + reason: 'budget_exceeded', + }), + ]), + ) + }) + + it('salvages valid favorites and drops invalid, inline-image, and oversized entries', async () => { + const storage = new MemoryStorageProvider() + await storage.setItem( + 'favorites', + JSON.stringify([ + buildFavorite('fav-valid'), + 'bad-item', + buildFavorite('fav-inline', { + metadata: { + media: { + coverUrl: 'data:image/png;base64,AAAA', + }, + }, + }), + buildFavorite('fav-too-large', { + content: 'x'.repeat(520 * 1024), + }), + ]), + ) + + const report = await runStorageStartupSafetyCheck(storage) + const repairedRaw = await storage.getItem('favorites') + const repairedFavorites = repairedRaw ? JSON.parse(repairedRaw) : [] + + expect(repairedFavorites).toHaveLength(1) + expect(repairedFavorites[0].id).toBe('fav-valid') + expect(report.actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'favorites', + action: 'rewritten', + droppedCount: 3, + }), + ]), + ) + }) + + it('trims an oversized favorites payload by keeping the newest entries first', async () => { + const storage = new MemoryStorageProvider() + const favorites = Array.from({ length: 20 }, (_, index) => + buildFavorite(`fav-${index + 1}`, { + content: 'x'.repeat(500 * 1024), + createdAt: 1_700_000_000_000 + index, + updatedAt: 1_700_000_000_000 + index, + }), + ) + await storage.setItem('favorites', JSON.stringify(favorites)) + + const report = await runStorageStartupSafetyCheck(storage) + const repairedRaw = await storage.getItem('favorites') + const repairedFavorites = repairedRaw ? JSON.parse(repairedRaw) : [] + + expect(repairedFavorites.length).toBeLessThan(favorites.length) + expect(repairedFavorites[0].updatedAt).toBeGreaterThan(repairedFavorites[repairedFavorites.length - 1].updatedAt) + expect(repairedFavorites.some((favorite: { id: string }) => favorite.id === 'fav-20')).toBe(true) + expect(repairedFavorites.some((favorite: { id: string }) => favorite.id === 'fav-1')).toBe(false) + expect(report.actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'favorites', + action: 'rewritten', + reason: 'budget_trimmed', + }), + ]), + ) + }) +}) diff --git a/packages/desktop/main.js b/packages/desktop/main.js index e98456ca..b3de7f74 100644 --- a/packages/desktop/main.js +++ b/packages/desktop/main.js @@ -67,6 +67,8 @@ const { createContextRepo, FavoriteManager, FileStorageProvider, + runStorageStartupSafetyCheck, + writeStartupRepairReport, // 导入共享的环境变量扫描常量 CUSTOM_API_PATTERN, SUFFIX_PATTERN, @@ -593,6 +595,8 @@ async function initializeServices() { const userDataPath = app.getPath('userData'); console.log('[DESKTOP] Using standard user data directory for auto-update compatibility:', userDataPath); storageProvider = new FileStorageProvider(userDataPath); + const startupRepairReport = await runStorageStartupSafetyCheck(storageProvider); + await writeStartupRepairReport(storageProvider, startupRepairReport); await initializePreferenceService(storageProvider); diff --git a/packages/ui/src/components/app-layout/PromptOptimizerApp.vue b/packages/ui/src/components/app-layout/PromptOptimizerApp.vue index fa85b776..c18edff3 100644 --- a/packages/ui/src/components/app-layout/PromptOptimizerApp.vue +++ b/packages/ui/src/components/app-layout/PromptOptimizerApp.vue @@ -386,7 +386,26 @@ const handleUnhandledRejection = (event: PromiseRejectionEvent) => { }; // 2. 初始化应用服务 -const { services, isInitializing } = useAppInitializer(); +const { services, isInitializing, startupRepairReport } = useAppInitializer(); + +const hasShownStartupRepairToast = ref(false) + +watch( + [isInitializing, startupRepairReport], + ([initializing, report]) => { + if (initializing || !report || hasShownStartupRepairToast.value) { + return + } + + if (!Array.isArray(report.actions) || report.actions.length === 0) { + return + } + + hasShownStartupRepairToast.value = true + toast.warning(`启动时已自动修复 ${report.actions.length} 项本地存储异常。`) + }, + { immediate: true }, +) // 3. 初始化功能模式和子模式(必须在 sessionManager 之前) // diff --git a/packages/ui/src/composables/app/useAppPromptGardenImport.ts b/packages/ui/src/composables/app/useAppPromptGardenImport.ts index d7d1b476..e9e216ea 100644 --- a/packages/ui/src/composables/app/useAppPromptGardenImport.ts +++ b/packages/ui/src/composables/app/useAppPromptGardenImport.ts @@ -311,7 +311,11 @@ const saveImportedPromptToFavorites = async (opts: { } const modeMapping = toFavoriteModeMapping(targetKey) - const snapshot = await buildStorableGardenSnapshot(fetched.gardenSnapshot, imageStorageService) + const snapshot = await buildStorableGardenSnapshot( + fetched.gardenSnapshot, + imageStorageService, + { allowImageFallback: false }, + ) const media = buildFavoriteMediaFromSnapshot(snapshot) const favorites = await manager.getFavorites() const existing = favorites.find((favorite) => isSameGardenSnapshotFavorite(favorite, snapshot)) @@ -840,11 +844,22 @@ const persistSourcesToAssetIdsWithFallback = async (opts: { sources: string[] storageService: IImageStorageService | null | undefined metadata?: { prompt?: string } + allowSourceFallback?: boolean }): Promise<{ assetIds: string[]; fallbackSources: string[] }> => { - const { storageService, metadata } = opts + const { storageService, metadata, allowSourceFallback = true } = opts const normalizedSources = dedupeStrings(opts.sources.map((item) => String(item || '').trim()).filter(Boolean)) - if (!storageService || normalizedSources.length === 0) { + if (normalizedSources.length === 0) { + return { + assetIds: [], + fallbackSources: normalizedSources, + } + } + + if (!storageService) { + if (!allowSourceFallback) { + throw new Error('Favorite image storage service unavailable') + } return { assetIds: [], fallbackSources: normalizedSources, @@ -865,11 +880,16 @@ const persistSourcesToAssetIdsWithFallback = async (opts: { if (assetId) { assetIds.push(assetId) + } else if (!allowSourceFallback) { + throw new Error(`Failed to persist snapshot image source: ${source}`) } else { fallbackSources.push(source) } } catch (error) { console.warn('[PromptGardenImport] Failed to persist snapshot image source:', source, error) + if (!allowSourceFallback) { + throw error + } fallbackSources.push(source) } } @@ -884,8 +904,9 @@ const persistSnapshotAssetItem = async (opts: { item: GardenSnapshotAssetItem storageService: IImageStorageService | null | undefined metadata?: { prompt?: string } + allowSourceFallback?: boolean }): Promise => { - const { storageService, metadata } = opts + const { storageService, metadata, allowSourceFallback = true } = opts const next: GardenSnapshotAssetItem = { ...opts.item } const imageSources = dedupeStrings([ @@ -897,6 +918,7 @@ const persistSnapshotAssetItem = async (opts: { sources: imageSources, storageService, metadata, + allowSourceFallback, }) const existingImageAssetIds = extractStringArray(next.imageAssetIds) @@ -915,6 +937,7 @@ const persistSnapshotAssetItem = async (opts: { sources: inputSources, storageService, metadata, + allowSourceFallback, }) const existingInputAssetIds = extractStringArray(next.inputImageAssetIds) @@ -927,9 +950,13 @@ const persistSnapshotAssetItem = async (opts: { async function buildStorableGardenSnapshot( snapshot: GardenSnapshot, imageStorageService?: IImageStorageService | null, + options?: { + allowImageFallback?: boolean + }, ): Promise { const assets = snapshot.assets || {} const sourceMetadata = buildAssetSourceMetadata(snapshot) + const allowImageFallback = options?.allowImageFallback ?? true const cover = assets.cover ? { ...assets.cover } : undefined if (cover && typeof cover.url === 'string') { @@ -943,9 +970,14 @@ async function buildStorableGardenSnapshot( if (coverAssetId) { cover.assetId = coverAssetId delete cover.url + } else if (!allowImageFallback) { + throw new Error(`Failed to persist cover image source: ${cover.url}`) } } catch (error) { - console.warn('[PromptGardenImport] Failed to persist cover image source:', cover.url, error) + console.info('[PromptGardenImport] Failed to persist cover image source:', cover.url, error) + if (!allowImageFallback) { + throw error + } } } @@ -956,6 +988,7 @@ async function buildStorableGardenSnapshot( item, storageService: imageStorageService, metadata: sourceMetadata, + allowSourceFallback: allowImageFallback, }), ), ) @@ -968,6 +1001,7 @@ async function buildStorableGardenSnapshot( item, storageService: imageStorageService, metadata: sourceMetadata, + allowSourceFallback: allowImageFallback, }), ), ) @@ -1389,7 +1423,7 @@ export function useAppPromptGardenImport(options: AppPromptGardenImportOptions) targetKey, }) } catch (error) { - console.warn('[PromptGardenImport] Auto-save to favorites failed:', error) + toast.warning('Prompt Garden 导入成功,但收藏未保存。') } } else { console.warn('[PromptGardenImport] Favorite manager unavailable, skip auto-save') @@ -1406,16 +1440,24 @@ export function useAppPromptGardenImport(options: AppPromptGardenImportOptions) getFavoriteImageStorageService?.() || getImageStorageService?.() || null let snapshot = fetched.gardenSnapshot + let includeMedia = true try { - snapshot = await buildStorableGardenSnapshot(snapshot, imageStorageService) + snapshot = await buildStorableGardenSnapshot(snapshot, imageStorageService, { + allowImageFallback: false, + }) } catch (error) { - console.warn('[PromptGardenImport] Failed to persist snapshot assets for favorite dialog:', error) + console.info('[PromptGardenImport] Failed to persist snapshot assets for favorite dialog:', error) + includeMedia = false } - const media = buildFavoriteMediaFromSnapshot(snapshot) const metadata: Record = { gardenSnapshot: snapshot, - ...(media ? { media } : {}), + ...(includeMedia + ? (() => { + const media = buildFavoriteMediaFromSnapshot(snapshot) + return media ? { media } : {} + })() + : {}), } openSaveFavoriteDialog({ diff --git a/packages/ui/src/composables/system/useAppInitializer.ts b/packages/ui/src/composables/system/useAppInitializer.ts index c0bc76bb..9d03b8df 100644 --- a/packages/ui/src/composables/system/useAppInitializer.ts +++ b/packages/ui/src/composables/system/useAppInitializer.ts @@ -2,6 +2,7 @@ import { ref, shallowRef, onMounted, type Ref } from 'vue' import { StorageFactory, + STARTUP_REPAIR_REPORT_PREFERENCE_KEY, createModelManager, createTemplateManager, createHistoryManager, @@ -33,6 +34,8 @@ import { createImageAdapterRegistry, createTextAdapterRegistry, createImageStorageService, + runStorageStartupSafetyCheck, + writeStartupRepairReport, // migrateLegacySessions - 已移除,session 是本次重构新引入 type IImageModelManager, type IImageService, @@ -49,11 +52,39 @@ import { type IVariableExtractionService, type IVariableValueGenerationService, type IImageStorageService, + type StartupRepairReport, type ContextMode, DEFAULT_CONTEXT_MODE } from '@prompt-optimizer/core'; import type { AppServices } from '../../types/services'; import { scheduleImageStorageGc } from '../../stores/session/imageStorageMaintenance' +import { + attachFavoriteAssetGc, + runFavoriteAssetGc, +} from '../../utils/favorite-asset-maintenance' + +const appendStartupRepairReport = ( + currentReport: StartupRepairReport | null, + nextAction: StartupRepairReport['actions'][number], +): StartupRepairReport => ({ + checkedAt: currentReport?.checkedAt ?? Date.now(), + actions: [...(currentReport?.actions || []), nextAction], +}) + +const consumeStartupRepairReport = async ( + preferenceService: IPreferenceService, +): Promise => { + const report = await preferenceService.get( + STARTUP_REPAIR_REPORT_PREFERENCE_KEY, + null, + ) + + if (report) { + await preferenceService.delete(STARTUP_REPAIR_REPORT_PREFERENCE_KEY) + } + + return report +} /** * 应用服务统一初始化器。 @@ -64,10 +95,12 @@ export function useAppInitializer(): { services: Ref; isInitializing: Ref; error: Ref; + startupRepairReport: Ref; } { const services = shallowRef(null); const isInitializing = ref(true); const error = ref(null); + const startupRepairReport = ref(null); onMounted(async () => { try { @@ -113,6 +146,7 @@ export function useAppInitializer(): { llmService = new ElectronLLMProxy(); promptService = new ElectronPromptServiceProxy(); preferenceService = new ElectronPreferenceServiceProxy(); + startupRepairReport.value = await consumeStartupRepairReport(preferenceService) // 文本模型适配器注册表(本地实例,不需要代理) textAdapterRegistryInstance = createTextAdapterRegistry(); @@ -136,7 +170,7 @@ export function useAppInitializer(): { // 收藏快照图像存储(独立数据库,避免与 session 图片清理策略耦合) favoriteImageStorageService = createImageStorageService({ maxCacheSize: 200 * 1024 * 1024, // 200 MB - maxAge: 365 * 24 * 60 * 60 * 1000, // 365 天 + maxAge: undefined, maxCount: 1000, autoCleanupThreshold: 0.9, dbName: 'PromptOptimizerFavoriteImageDB', @@ -157,6 +191,22 @@ export function useAppInitializer(): { // 创建收藏管理器代理 const { FavoriteManagerElectronProxy } = await import('@prompt-optimizer/core') favoriteManager = new FavoriteManagerElectronProxy(); + favoriteManager = attachFavoriteAssetGc(favoriteManager as any, favoriteImageStorageService) + + if (favoriteImageStorageService) { + const favoriteAssetGcResult = await runFavoriteAssetGc( + favoriteManager, + favoriteImageStorageService, + ) + if (favoriteAssetGcResult.deletedIds.length > 0) { + startupRepairReport.value = appendStartupRepairReport(startupRepairReport.value, { + key: 'PromptOptimizerFavoriteImageDB', + action: 'removed', + reason: 'orphan_assets_removed', + deletedCount: favoriteAssetGcResult.deletedIds.length, + }) + } + } // 🆕 创建评估服务(使用代理的 llmService, modelManager, templateManager) evaluationService = createEvaluationService(llmService, modelManager, templateManager, { @@ -220,9 +270,12 @@ export function useAppInitializer(): { console.log('[AppInitializer] 检测到Web环境,初始化完整服务...'); // 在Web环境中,我们创建一套完整的、真实的服务 const storageProvider = StorageFactory.create('dexie'); + const stage1StartupRepairReport = await runStorageStartupSafetyCheck(storageProvider) + await writeStartupRepairReport(storageProvider, stage1StartupRepairReport) // 创建基于存储提供器的偏好设置服务,使用core包中的createPreferenceService preferenceService = createPreferenceService(storageProvider); + startupRepairReport.value = await consumeStartupRepairReport(preferenceService) const languageService = createTemplateLanguageService(preferenceService); @@ -250,7 +303,7 @@ export function useAppInitializer(): { // 收藏快照图像存储(独立数据库,避免与 session 图片清理策略耦合) favoriteImageStorageService = createImageStorageService({ maxCacheSize: 200 * 1024 * 1024, // 200 MB - maxAge: 365 * 24 * 60 * 60 * 1000, // 365 天 + maxAge: undefined, maxCount: 1000, autoCleanupThreshold: 0.9, dbName: 'PromptOptimizerFavoriteImageDB', @@ -370,6 +423,22 @@ export function useAppInitializer(): { // 创建收藏管理器 favoriteManager = new FavoriteManager(storageProvider); + favoriteManager = attachFavoriteAssetGc(favoriteManager as any, favoriteImageStorageService) + + if (favoriteImageStorageService) { + const favoriteAssetGcResult = await runFavoriteAssetGc( + favoriteManager, + favoriteImageStorageService, + ) + if (favoriteAssetGcResult.deletedIds.length > 0) { + startupRepairReport.value = appendStartupRepairReport(startupRepairReport.value, { + key: 'PromptOptimizerFavoriteImageDB', + action: 'removed', + reason: 'orphan_assets_removed', + deletedCount: favoriteAssetGcResult.deletedIds.length, + }) + } + } // 🆕 创建评估服务 evaluationService = createEvaluationService(llmService, modelManagerAdapter, templateManagerAdapter, { @@ -443,5 +512,5 @@ export function useAppInitializer(): { } }); - return { services, isInitializing, error }; + return { services, isInitializing, error, startupRepairReport }; } diff --git a/packages/ui/src/stores/session/useSessionManager.ts b/packages/ui/src/stores/session/useSessionManager.ts index 4ef8d8dd..87086a0b 100644 --- a/packages/ui/src/stores/session/useSessionManager.ts +++ b/packages/ui/src/stores/session/useSessionManager.ts @@ -39,6 +39,44 @@ export type SubModeKey = | 'image-image2image' // 图生图 | 'image-multiimage' // 多图生图 +const SESSION_STORAGE_KEYS: Record = { + 'basic-system': 'session/v1/basic-system', + 'basic-user': 'session/v1/basic-user', + 'pro-multi': 'session/v1/pro-multi', + 'pro-variable': 'session/v1/pro-variable', + 'image-text2image': 'session/v1/image-text2image', + 'image-image2image': 'session/v1/image-image2image', + 'image-multiimage': 'session/v1/image-multiimage', +} + +const getSessionCleanupKey = (key: SubModeKey, error: unknown): string | null => { + if (!error || typeof error !== 'object') { + return null + } + + const maybeError = error as { + code?: unknown + params?: { + reason?: unknown + key?: unknown + } + } + + if (maybeError.code !== 'error.storage.read') { + return null + } + + if (maybeError.params?.reason !== 'session_snapshot_too_large') { + return null + } + + if (typeof maybeError.params.key === 'string' && maybeError.params.key.trim()) { + return maybeError.params.key + } + + return SESSION_STORAGE_KEYS[key] +} + /** * 子模式读取器接口(从外部注入) */ @@ -275,7 +313,28 @@ export const useSessionManager = defineStore('sessionManager', () => { break } } catch (error) { - console.error(`[SessionManager] 恢复 ${key} 会话失败:`, error) + const cleanupKey = getSessionCleanupKey(key, error) + if (cleanupKey) { + console.info(`[SessionManager] 检测到超限会话快照,准备清理: ${cleanupKey}`) + } else { + console.error(`[SessionManager] 恢复 ${key} 会话失败:`, error) + } + + if (!cleanupKey) { + return + } + + const $services = getPiniaServices() + if (!$services?.preferenceService) { + return + } + + try { + await $services.preferenceService.delete(cleanupKey) + console.info(`[SessionManager] 已清理超限会话快照: ${cleanupKey}`) + } catch (cleanupError) { + console.error(`[SessionManager] 清理超限会话快照失败 (${cleanupKey}):`, cleanupError) + } } } diff --git a/packages/ui/src/utils/favorite-asset-maintenance.ts b/packages/ui/src/utils/favorite-asset-maintenance.ts new file mode 100644 index 00000000..3131be75 --- /dev/null +++ b/packages/ui/src/utils/favorite-asset-maintenance.ts @@ -0,0 +1,173 @@ +import type { + FavoritePrompt, + IFavoriteManager, + IImageStorageService, +} from '@prompt-optimizer/core' + +type FavoriteManagerForGc = Pick< + IFavoriteManager, + 'getFavorites' | 'getFavorite' | 'updateFavorite' | 'deleteFavorite' | 'deleteFavorites' +> & + IFavoriteManager + +type FavoriteAssetGcResult = { + deletedIds: string[] + referencedIds: string[] +} + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) + +const collectStringArray = (value: unknown): string[] => { + if (!Array.isArray(value)) return [] + return value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter(Boolean) +} + +const collectFavoriteAssetIds = ( + favorite: FavoritePrompt | null | undefined, +): Set => { + const assetIds = new Set() + if (!favorite || !isRecord(favorite.metadata)) { + return assetIds + } + + const media = isRecord(favorite.metadata.media) ? favorite.metadata.media : null + if (media) { + const coverAssetId = typeof media.coverAssetId === 'string' ? media.coverAssetId.trim() : '' + if (coverAssetId) { + assetIds.add(coverAssetId) + } + + collectStringArray(media.assetIds).forEach((id) => assetIds.add(id)) + } + + const gardenSnapshot = isRecord(favorite.metadata.gardenSnapshot) + ? favorite.metadata.gardenSnapshot + : null + if (!gardenSnapshot || !isRecord(gardenSnapshot.assets)) { + return assetIds + } + + const cover = isRecord(gardenSnapshot.assets.cover) ? gardenSnapshot.assets.cover : null + if (cover) { + const coverAssetId = typeof cover.assetId === 'string' ? cover.assetId.trim() : '' + if (coverAssetId) { + assetIds.add(coverAssetId) + } + } + + const collectFromSnapshotItems = (items: unknown) => { + if (!Array.isArray(items)) return + + items.forEach((item) => { + if (!isRecord(item)) return + + collectStringArray(item.imageAssetIds).forEach((id) => assetIds.add(id)) + collectStringArray(item.inputImageAssetIds).forEach((id) => assetIds.add(id)) + }) + } + + collectFromSnapshotItems(gardenSnapshot.assets.showcases) + collectFromSnapshotItems(gardenSnapshot.assets.examples) + + return assetIds +} + +const setsAreEqual = (left: Set, right: Set): boolean => { + if (left.size !== right.size) return false + for (const value of left) { + if (!right.has(value)) return false + } + return true +} + +const safeGetFavorite = async ( + favoriteManager: FavoriteManagerForGc, + id: string, +): Promise => { + try { + return await favoriteManager.getFavorite(id) + } catch { + return null + } +} + +export const runFavoriteAssetGc = async ( + favoriteManager: Pick, + favoriteImageStorageService: Pick | null | undefined, +): Promise => { + if (!favoriteImageStorageService) { + return { + deletedIds: [], + referencedIds: [], + } + } + + const favorites = await favoriteManager.getFavorites() + const referencedIds = new Set() + favorites.forEach((favorite) => { + collectFavoriteAssetIds(favorite).forEach((id) => referencedIds.add(id)) + }) + + const allMetadata = await favoriteImageStorageService.listAllMetadata() + const orphanIds = allMetadata + .map((metadata) => metadata.id) + .filter((id) => !referencedIds.has(id)) + + if (orphanIds.length > 0) { + await favoriteImageStorageService.deleteImages(orphanIds) + } + + return { + deletedIds: orphanIds, + referencedIds: Array.from(referencedIds), + } +} + +export const attachFavoriteAssetGc = ( + favoriteManager: FavoriteManagerForGc, + favoriteImageStorageService: Pick | null | undefined, +): IFavoriteManager => { + if (!favoriteImageStorageService) { + return favoriteManager + } + + return new Proxy(favoriteManager, { + get(target, prop, receiver) { + if (prop === 'deleteFavorite') { + return async (id: string) => { + await target.deleteFavorite(id) + await runFavoriteAssetGc(target, favoriteImageStorageService) + } + } + + if (prop === 'deleteFavorites') { + return async (ids: string[]) => { + await target.deleteFavorites(ids) + await runFavoriteAssetGc(target, favoriteImageStorageService) + } + } + + if (prop === 'updateFavorite') { + return async (id: string, updates: Partial) => { + const beforeFavorite = await safeGetFavorite(target, id) + const beforeRefs = collectFavoriteAssetIds(beforeFavorite) + + await target.updateFavorite(id, updates) + + const afterFavorite = await safeGetFavorite(target, id) + const afterRefs = collectFavoriteAssetIds(afterFavorite) + + if (!setsAreEqual(beforeRefs, afterRefs)) { + await runFavoriteAssetGc(target, favoriteImageStorageService) + } + } + } + + const value = Reflect.get(target, prop, receiver) + return typeof value === 'function' ? value.bind(target) : value + }, + }) as IFavoriteManager +} diff --git a/packages/ui/src/utils/image-asset-storage.ts b/packages/ui/src/utils/image-asset-storage.ts index 255c7ae5..a5409b22 100644 --- a/packages/ui/src/utils/image-asset-storage.ts +++ b/packages/ui/src/utils/image-asset-storage.ts @@ -25,6 +25,59 @@ const parseDataUrlPayload = (source: string): ImagePayload | null => { return { b64, mimeType } } +const inferMimeTypeFromBytes = (bytes: Uint8Array): string | null => { + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return 'image/png' + } + + if ( + bytes.length >= 3 && + bytes[0] === 0xff && + bytes[1] === 0xd8 && + bytes[2] === 0xff + ) { + return 'image/jpeg' + } + + if ( + bytes.length >= 12 && + bytes[0] === 0x52 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x46 && + bytes[8] === 0x57 && + bytes[9] === 0x45 && + bytes[10] === 0x42 && + bytes[11] === 0x50 + ) { + return 'image/webp' + } + + if ( + bytes.length >= 6 && + bytes[0] === 0x47 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x38 && + (bytes[4] === 0x37 || bytes[4] === 0x39) && + bytes[5] === 0x61 + ) { + return 'image/gif' + } + + return null +} + const fetchImagePayloadFromUrl = async (absoluteUrl: string): Promise => { const resp = await fetch(absoluteUrl, { method: 'GET' }) if (!resp.ok) { @@ -33,6 +86,13 @@ const fetchImagePayloadFromUrl = async (absoluteUrl: string): Promise { toString: (encoding: 'base64') => string } @@ -40,17 +100,15 @@ const fetchImagePayloadFromUrl = async (absoluteUrl: string): Promise((resolve, reject) => { const reader = new FileReader() @@ -66,7 +124,7 @@ const fetchImagePayloadFromUrl = async (absoluteUrl: string): Promise { } }) + it('skips auto-save to favorites when snapshot image persistence fails', async () => { + const { pinia } = createTestPinia() + + const createReactive = (): MessageReactive => ({ + destroy: () => {}, + } as unknown as MessageReactive) + setGlobalMessageApi({ + success: vi.fn(() => createReactive()), + error: vi.fn(() => createReactive()), + warning: vi.fn(() => createReactive()), + info: vi.fn(() => createReactive()), + }) + + const basicSystemSession = useBasicSystemSession(pinia) + const basicUserSession = useBasicUserSession(pinia) + const proMultiMessageSession = useProMultiMessageSession(pinia) + const proVariableSession = useProVariableSession(pinia) + const imageText2ImageSession = useImageText2ImageSession(pinia) + const imageImage2ImageSession = useImageImage2ImageSession(pinia) + + const optimizerCurrentVersions = ref([]) + const hasRestoredInitialState = ref(false) + const isLoadingExternalData = ref(false) + + const query: LocationQuery = { + importCode: 'NB-SAVE-FAIL-001', + saveToFavorites: '1', + } + + const currentRoute = ref(makeRoute('/basic/system', query)) + + let replaceResolve: (() => void) | undefined + const replaceDone = new Promise((resolve) => { + replaceResolve = resolve + }) + + const push: Router['push'] = vi.fn(async (to) => { + applyNavigation(currentRoute, to) + return undefined + }) + + const replace: Router['replace'] = vi.fn(async (to) => { + applyNavigation(currentRoute, to) + replaceResolve?.() + return undefined + }) + + const router: Pick = { + currentRoute, + push, + replace, + } + + const favoriteManager = { + getFavorites: vi.fn(async (): Promise => []), + addFavorite: vi.fn(async ( + _favorite: Omit + ) => 'fav-new'), + updateFavorite: vi.fn(async (_id: string, _updates: Partial) => {}), + } + + const imageStorageService = { + getMetadata: vi.fn(async () => null), + saveImage: vi.fn(async () => { + throw new Error('boom') + }), + } as any + + const fetchMock = vi.fn< + (input: RequestInfo | URL, init?: RequestInit) => Promise + >(async () => { + return new Response( + JSON.stringify({ + id: 'prompt-asset-fail-1', + importCode: 'NB-SAVE-FAIL-001', + schema: 'prompt-garden.prompt.v1', + schemaVersion: 1, + optimizerTarget: { subModeKey: 'basic-system' }, + prompt: { format: 'text', text: 'PROMPT WITH ASSET FAILURE' }, + variables: [], + assets: { + cover: { + url: '/prompt-assets/cover.png', + }, + showcases: [ + { + id: 'show-1', + images: ['/prompt-assets/show-1.png'], + }, + ], + }, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + } + ) + }) + vi.stubGlobal('fetch', fetchMock) + + const scope = effectScope() + try { + scope.run(() => { + useAppPromptGardenImport({ + router, + hasRestoredInitialState, + isLoadingExternalData, + gardenBaseUrl: 'http://garden.local', + basicSystemSession, + basicUserSession, + proMultiMessageSession, + proVariableSession, + imageText2ImageSession, + imageImage2ImageSession, + getFavoriteManager: () => favoriteManager, + getFavoriteImageStorageService: () => imageStorageService, + optimizerCurrentVersions, + }) + }) + + hasRestoredInitialState.value = true + + await replaceDone + await waitForCondition(() => isLoadingExternalData.value === false) + + expect(favoriteManager.getFavorites).not.toHaveBeenCalled() + expect(favoriteManager.addFavorite).not.toHaveBeenCalled() + expect(favoriteManager.updateFavorite).not.toHaveBeenCalled() + } finally { + scope.stop() + } + }) + + it('opens save-favorite dialog without media fallback when snapshot image persistence fails', async () => { + const { pinia } = createTestPinia() + + const createReactive = (): MessageReactive => ({ + destroy: () => {}, + } as unknown as MessageReactive) + setGlobalMessageApi({ + success: vi.fn(() => createReactive()), + error: vi.fn(() => createReactive()), + warning: vi.fn(() => createReactive()), + info: vi.fn(() => createReactive()), + }) + + const basicSystemSession = useBasicSystemSession(pinia) + const basicUserSession = useBasicUserSession(pinia) + const proMultiMessageSession = useProMultiMessageSession(pinia) + const proVariableSession = useProVariableSession(pinia) + const imageText2ImageSession = useImageText2ImageSession(pinia) + const imageImage2ImageSession = useImageImage2ImageSession(pinia) + + const optimizerCurrentVersions = ref([]) + const hasRestoredInitialState = ref(false) + const isLoadingExternalData = ref(false) + + const query: LocationQuery = { + importCode: 'NB-CONFIRM-FAIL-001', + saveToFavorites: 'confirm', + } + + const currentRoute = ref(makeRoute('/basic/system', query)) + + let replaceResolve: (() => void) | undefined + const replaceDone = new Promise((resolve) => { + replaceResolve = resolve + }) + + const push: Router['push'] = vi.fn(async (to) => { + applyNavigation(currentRoute, to) + return undefined + }) + + const replace: Router['replace'] = vi.fn(async (to) => { + applyNavigation(currentRoute, to) + replaceResolve?.() + return undefined + }) + + const router: Pick = { + currentRoute, + push, + replace, + } + + const openSaveFavoriteDialog = vi.fn() + const imageStorageService = { + getMetadata: vi.fn(async () => null), + saveImage: vi.fn(async () => { + throw new Error('boom') + }), + } as any + + const fetchMock = vi.fn< + (input: RequestInfo | URL, init?: RequestInit) => Promise + >(async () => { + return new Response( + JSON.stringify({ + id: 'prompt-confirm-fail-1', + importCode: 'NB-CONFIRM-FAIL-001', + schema: 'prompt-garden.prompt.v1', + schemaVersion: 1, + optimizerTarget: { subModeKey: 'basic-system' }, + prompt: { format: 'text', text: 'CONFIRM CONTENT WITH IMAGE' }, + variables: [], + assets: { + cover: { + url: '/prompt-assets/cover.png', + }, + showcases: [ + { + id: 'show-1', + images: ['/prompt-assets/show-1.png'], + }, + ], + }, + meta: { + title: 'Confirm Prompt Title', + }, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + } + ) + }) + vi.stubGlobal('fetch', fetchMock) + + const scope = effectScope() + try { + scope.run(() => { + useAppPromptGardenImport({ + router, + hasRestoredInitialState, + isLoadingExternalData, + gardenBaseUrl: 'http://garden.local', + basicSystemSession, + basicUserSession, + proMultiMessageSession, + proVariableSession, + imageText2ImageSession, + imageImage2ImageSession, + getFavoriteImageStorageService: () => imageStorageService, + openSaveFavoriteDialog, + optimizerCurrentVersions, + }) + }) + + hasRestoredInitialState.value = true + + await replaceDone + await waitForCondition(() => isLoadingExternalData.value === false) + + expect(openSaveFavoriteDialog).toHaveBeenCalledTimes(1) + const savedArg = openSaveFavoriteDialog.mock.calls[0]?.[0] as { + prefill?: { + metadata?: Record + } + } + + expect(savedArg.prefill?.metadata?.media).toBeUndefined() + } finally { + scope.stop() + } + }) + it('does not auto-save when saveToFavorites flag is absent', async () => { const { pinia } = createTestPinia() @@ -2108,4 +2375,127 @@ describe('useAppPromptGardenImport', () => { scope.stop() } }) + + it('shows a warning when Prompt Garden auto-save to favorites fails', async () => { + const { pinia } = createTestPinia() + + const createReactive = (): MessageReactive => ({ + destroy: () => {}, + } as unknown as MessageReactive) + const successMock = vi.fn(() => createReactive()) + const errorMock = vi.fn(() => createReactive()) + const warningMock = vi.fn(() => createReactive()) + setGlobalMessageApi({ + success: successMock, + error: errorMock, + warning: warningMock, + info: vi.fn(() => createReactive()), + }) + + const basicSystemSession = useBasicSystemSession(pinia) + const basicUserSession = useBasicUserSession(pinia) + const proMultiMessageSession = useProMultiMessageSession(pinia) + const proVariableSession = useProVariableSession(pinia) + const imageText2ImageSession = useImageText2ImageSession(pinia) + const imageImage2ImageSession = useImageImage2ImageSession(pinia) + + const optimizerCurrentVersions = ref([]) + const hasRestoredInitialState = ref(false) + const isLoadingExternalData = ref(false) + + const query: LocationQuery = { + importCode: 'NB-AUTO-SAVE-FAIL-001', + saveToFavorites: 'true', + } + + const currentRoute = ref(makeRoute('/basic/system', query)) + + let replaceResolve: (() => void) | undefined + const replaceDone = new Promise((resolve) => { + replaceResolve = resolve + }) + + const push: Router['push'] = vi.fn(async (to) => { + applyNavigation(currentRoute, to) + return undefined + }) + + const replace: Router['replace'] = vi.fn(async (to) => { + applyNavigation(currentRoute, to) + replaceResolve?.() + return undefined + }) + + const router: Pick = { + currentRoute, + push, + replace, + } + + const favoriteManager = { + getFavorites: vi.fn(async (): Promise => []), + addFavorite: vi.fn(async () => { + throw new Error('favorites payload exceeds hard limit') + }), + updateFavorite: vi.fn(async (_id: string, _updates: Partial) => {}), + } + + const fetchMock = vi.fn< + (input: RequestInfo | URL, init?: RequestInit) => Promise + >(async () => { + return new Response( + JSON.stringify({ + schema: 'prompt-garden.prompt.v1', + schemaVersion: 1, + optimizerTarget: { subModeKey: 'basic-system' }, + prompt: { format: 'text', text: 'AUTO SAVE FAIL PROMPT' }, + variables: [], + meta: { + title: 'Auto Save Fail Prompt', + }, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ) + }) + vi.stubGlobal('fetch', fetchMock) + + const scope = effectScope() + try { + scope.run(() => { + useAppPromptGardenImport({ + router, + hasRestoredInitialState, + isLoadingExternalData, + gardenBaseUrl: 'http://garden.local', + basicSystemSession, + basicUserSession, + proMultiMessageSession, + proVariableSession, + imageText2ImageSession, + imageImage2ImageSession, + getFavoriteManager: () => favoriteManager, + optimizerCurrentVersions, + }) + }) + + hasRestoredInitialState.value = true + + await replaceDone + await waitForCondition(() => isLoadingExternalData.value === false) + + expect(favoriteManager.getFavorites).toHaveBeenCalledTimes(1) + expect(favoriteManager.addFavorite).toHaveBeenCalledTimes(1) + expect(warningMock).toHaveBeenCalledTimes(1) + expect(warningMock.mock.calls[0]?.[0]).toEqual( + expect.stringContaining('收藏'), + ) + expect(errorMock).not.toHaveBeenCalled() + expect(successMock).toHaveBeenCalled() + } finally { + scope.stop() + } + }) }) diff --git a/packages/ui/tests/unit/stores/session/session-manager.spec.ts b/packages/ui/tests/unit/stores/session/session-manager.spec.ts new file mode 100644 index 00000000..04611e8b --- /dev/null +++ b/packages/ui/tests/unit/stores/session/session-manager.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest' + +import { createTestPinia, createPreferenceServiceStub } from '../../../utils/pinia-test-helpers' +import { useSessionManager } from '../../../../src/stores/session/useSessionManager' + +describe('SessionManager', () => { + it('cleans only the oversized session key when restore fails with session snapshot overflow', async () => { + const deleteMock = vi.fn(async () => {}) + + const { pinia, services } = createTestPinia({ + preferenceService: createPreferenceServiceStub({ + get: vi.fn(async () => { + throw { + code: 'error.storage.read', + params: { + reason: 'session_snapshot_too_large', + key: 'session/v1/image-multiimage', + }, + } + }), + delete: deleteMock, + }), + imageStorageService: { + getImage: vi.fn(async () => null), + } as any, + }) + + const manager = useSessionManager(pinia) + + await manager.restoreSubModeSession('image-multiimage') + + expect(deleteMock).toHaveBeenCalledTimes(1) + expect(deleteMock).toHaveBeenCalledWith('session/v1/image-multiimage') + expect(services.preferenceService.get).toHaveBeenCalledWith( + 'session/v1/image-multiimage', + null, + ) + }) +}) diff --git a/packages/ui/tests/unit/utils/favorite-asset-maintenance.spec.ts b/packages/ui/tests/unit/utils/favorite-asset-maintenance.spec.ts new file mode 100644 index 00000000..f842064b --- /dev/null +++ b/packages/ui/tests/unit/utils/favorite-asset-maintenance.spec.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi } from 'vitest' + +import { + attachFavoriteAssetGc, + runFavoriteAssetGc, +} from '../../../src/utils/favorite-asset-maintenance' + +describe('favoriteAssetMaintenance', () => { + it('deletes orphaned assets from the favorite image store', async () => { + const favoriteManager = { + getFavorites: vi.fn(async () => [ + { + id: 'fav-1', + metadata: { + media: { + coverAssetId: 'cover-1', + assetIds: ['asset-1'], + }, + gardenSnapshot: { + assets: { + showcases: [{ imageAssetIds: ['gallery-1'] }], + examples: [{ inputImageAssetIds: ['input-1'] }], + }, + }, + }, + }, + ]), + } + + const favoriteImageStorageService = { + listAllMetadata: vi.fn(async () => [ + { id: 'cover-1' }, + { id: 'asset-1' }, + { id: 'gallery-1' }, + { id: 'input-1' }, + { id: 'orphan-1' }, + ]), + deleteImages: vi.fn(async (_ids: string[]) => {}), + } + + const result = await runFavoriteAssetGc( + favoriteManager as any, + favoriteImageStorageService as any, + ) + + expect(favoriteImageStorageService.deleteImages).toHaveBeenCalledWith(['orphan-1']) + expect(result.deletedIds).toEqual(['orphan-1']) + }) + + it('runs GC after deleting a favorite and keeps assets still referenced by siblings', async () => { + const favorites = [ + { + id: 'fav-1', + metadata: { + media: { + assetIds: ['shared-asset', 'soon-orphaned'], + }, + }, + }, + { + id: 'fav-2', + metadata: { + media: { + assetIds: ['shared-asset'], + }, + }, + }, + ] + + const favoriteManager = { + addFavorite: vi.fn(), + getFavorites: vi.fn(async () => favorites), + getFavorite: vi.fn(async (id: string) => favorites.find((favorite) => favorite.id === id) || null), + updateFavorite: vi.fn(), + deleteFavorite: vi.fn(async (id: string) => { + const index = favorites.findIndex((favorite) => favorite.id === id) + if (index >= 0) { + favorites.splice(index, 1) + } + }), + deleteFavorites: vi.fn(), + incrementUseCount: vi.fn(), + getCategories: vi.fn(), + addCategory: vi.fn(), + updateCategory: vi.fn(), + deleteCategory: vi.fn(), + getStats: vi.fn(), + searchFavorites: vi.fn(), + exportFavorites: vi.fn(), + importFavorites: vi.fn(), + getAllTags: vi.fn(), + addTag: vi.fn(), + renameTag: vi.fn(), + mergeTags: vi.fn(), + deleteTag: vi.fn(), + reorderCategories: vi.fn(), + getCategoryUsage: vi.fn(), + ensureDefaultCategories: vi.fn(), + } + + const favoriteImageStorageService = { + listAllMetadata: vi.fn(async () => [ + { id: 'shared-asset' }, + { id: 'soon-orphaned' }, + ]), + deleteImages: vi.fn(async (_ids: string[]) => {}), + } + + const guardedManager = attachFavoriteAssetGc( + favoriteManager as any, + favoriteImageStorageService as any, + ) + + await guardedManager.deleteFavorite('fav-1') + + expect(favoriteManager.deleteFavorite).toHaveBeenCalledWith('fav-1') + expect(favoriteImageStorageService.deleteImages).toHaveBeenCalledWith(['soon-orphaned']) + }) + + it('runs GC after updating a favorite when image references change', async () => { + const favorites = [ + { + id: 'fav-1', + metadata: { + media: { + assetIds: ['old-asset'], + }, + }, + }, + ] + + const favoriteManager = { + addFavorite: vi.fn(), + getFavorites: vi.fn(async () => favorites), + getFavorite: vi.fn(async (id: string) => favorites.find((favorite) => favorite.id === id) || null), + updateFavorite: vi.fn(async (id: string, updates: Record) => { + const index = favorites.findIndex((favorite) => favorite.id === id) + if (index >= 0) { + favorites[index] = { + ...favorites[index], + ...updates, + } + } + }), + deleteFavorite: vi.fn(), + deleteFavorites: vi.fn(), + incrementUseCount: vi.fn(), + getCategories: vi.fn(), + addCategory: vi.fn(), + updateCategory: vi.fn(), + deleteCategory: vi.fn(), + getStats: vi.fn(), + searchFavorites: vi.fn(), + exportFavorites: vi.fn(), + importFavorites: vi.fn(), + getAllTags: vi.fn(), + addTag: vi.fn(), + renameTag: vi.fn(), + mergeTags: vi.fn(), + deleteTag: vi.fn(), + reorderCategories: vi.fn(), + getCategoryUsage: vi.fn(), + ensureDefaultCategories: vi.fn(), + } + + const favoriteImageStorageService = { + listAllMetadata: vi.fn(async () => [ + { id: 'old-asset' }, + { id: 'new-asset' }, + ]), + deleteImages: vi.fn(async (_ids: string[]) => {}), + } + + const guardedManager = attachFavoriteAssetGc( + favoriteManager as any, + favoriteImageStorageService as any, + ) + + await guardedManager.updateFavorite('fav-1', { + metadata: { + media: { + assetIds: ['new-asset'], + }, + }, + } as any) + + expect(favoriteManager.updateFavorite).toHaveBeenCalled() + expect(favoriteImageStorageService.deleteImages).toHaveBeenCalledWith(['old-asset']) + }) +}) diff --git a/packages/ui/tests/unit/utils/image-asset-storage.spec.ts b/packages/ui/tests/unit/utils/image-asset-storage.spec.ts new file mode 100644 index 00000000..86043755 --- /dev/null +++ b/packages/ui/tests/unit/utils/image-asset-storage.spec.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { normalizeImageSourceToPayload } from '../../../src/utils/image-asset-storage' + +describe('image asset storage utilities', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('infers image mime type from PNG bytes when the remote url responds as application/octet-stream', async () => { + const pngBytes = Uint8Array.from([ + 0x89, 0x50, 0x4e, 0x47, + 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, + ]) + + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + headers: { + get: vi.fn((name: string) => + name.toLowerCase() === 'content-type' ? 'application/octet-stream' : null + ), + }, + arrayBuffer: async () => pngBytes.buffer, + })) + ) + + const payload = await normalizeImageSourceToPayload('https://example.com/generated-image') + + expect(payload).toMatchObject({ + mimeType: 'image/png', + b64: Buffer.from(pngBytes).toString('base64'), + }) + }) +})