fix(storage): harden startup safety and favorite asset handling
This commit is contained in:
1 parent
d8d252eed6
commit
d0f1d45c17
21 files changed
+2834
-31
No files matched your search
@@ -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`
|
||||
- 主文件:`<userData>/prompt-optimizer-data.json`
|
||||
- 备份文件:`<userData>/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 边界的强测试
|
||||
|
||||
只有当“结构化数据”和“图片资产”在架构、代码、测试、删除回收四个层面都被强制分层后,这类问题才算真正从根上解决。
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown> =>
|
||||
!!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<object> = 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<string, unknown>).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<string, unknown>
|
||||
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
|
||||
}
|
||||
@@ -251,8 +251,12 @@ export class ImageStorageService implements IImageStorageService {
|
||||
* @returns 清理的图像数量
|
||||
*/
|
||||
async cleanupOldImages(): Promise<number> {
|
||||
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<void> {
|
||||
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 表,性能优化)
|
||||
|
||||
@@ -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);
|
||||
// 将键添加到缓存中
|
||||
|
||||
@@ -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<string[]>
|
||||
}
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
!!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<object> = 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<string, unknown>)) {
|
||||
const match = findInlineImagePath(child, `${path}.${key}`, seen)
|
||||
if (match) return match
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const findOversizedString = (
|
||||
value: unknown,
|
||||
maxBytes: number,
|
||||
path = '$',
|
||||
seen: WeakSet<object> = 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<string, unknown>)) {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<string[]> => {
|
||||
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<void> {
|
||||
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<StartupRepairReport> {
|
||||
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
|
||||
}
|
||||
@@ -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<string, unknown> = {}) => ({
|
||||
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', () => {
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({
|
||||
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',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 之前)
|
||||
//
|
||||
|
||||
@@ -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<GardenSnapshotAssetItem> => {
|
||||
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<GardenSnapshot> {
|
||||
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<string, unknown> = {
|
||||
gardenSnapshot: snapshot,
|
||||
...(media ? { media } : {}),
|
||||
...(includeMedia
|
||||
? (() => {
|
||||
const media = buildFavoriteMediaFromSnapshot(snapshot)
|
||||
return media ? { media } : {}
|
||||
})()
|
||||
: {}),
|
||||
}
|
||||
|
||||
openSaveFavoriteDialog({
|
||||
|
||||
@@ -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<StartupRepairReport | null> => {
|
||||
const report = await preferenceService.get<StartupRepairReport | null>(
|
||||
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<AppServices | null>;
|
||||
isInitializing: Ref<boolean>;
|
||||
error: Ref<Error | null>;
|
||||
startupRepairReport: Ref<StartupRepairReport | null>;
|
||||
} {
|
||||
const services = shallowRef<AppServices | null>(null);
|
||||
const isInitializing = ref(true);
|
||||
const error = ref<Error | null>(null);
|
||||
const startupRepairReport = ref<StartupRepairReport | null>(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 };
|
||||
}
|
||||
@@ -39,6 +39,44 @@ export type SubModeKey =
|
||||
| 'image-image2image' // 图生图
|
||||
| 'image-multiimage' // 多图生图
|
||||
|
||||
const SESSION_STORAGE_KEYS: Record<SubModeKey, string> = {
|
||||
'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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> =>
|
||||
!!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<string> => {
|
||||
const assetIds = new Set<string>()
|
||||
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<string>, right: Set<string>): 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<FavoritePrompt | null> => {
|
||||
try {
|
||||
return await favoriteManager.getFavorite(id)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const runFavoriteAssetGc = async (
|
||||
favoriteManager: Pick<IFavoriteManager, 'getFavorites'>,
|
||||
favoriteImageStorageService: Pick<IImageStorageService, 'listAllMetadata' | 'deleteImages'> | null | undefined,
|
||||
): Promise<FavoriteAssetGcResult> => {
|
||||
if (!favoriteImageStorageService) {
|
||||
return {
|
||||
deletedIds: [],
|
||||
referencedIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
const favorites = await favoriteManager.getFavorites()
|
||||
const referencedIds = new Set<string>()
|
||||
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<IImageStorageService, 'listAllMetadata' | 'deleteImages'> | 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<FavoritePrompt>) => {
|
||||
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
|
||||
}
|
||||
@@ -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<ImagePayload> => {
|
||||
const resp = await fetch(absoluteUrl, { method: 'GET' })
|
||||
if (!resp.ok) {
|
||||
@@ -33,6 +86,13 @@ const fetchImagePayloadFromUrl = async (absoluteUrl: string): Promise<ImagePaylo
|
||||
|
||||
const headerType = resp.headers.get('content-type')
|
||||
const mimeType = typeof headerType === 'string' ? headerType.split(';')[0].trim() : ''
|
||||
const ab = await resp.arrayBuffer()
|
||||
const bytes = new Uint8Array(ab)
|
||||
const inferredMimeType = inferMimeTypeFromBytes(bytes)
|
||||
const finalMimeType =
|
||||
mimeType && mimeType !== 'application/octet-stream'
|
||||
? mimeType
|
||||
: inferredMimeType || mimeType || 'application/octet-stream'
|
||||
|
||||
type BufferLike = {
|
||||
from: (data: ArrayBuffer) => { toString: (encoding: 'base64') => string }
|
||||
@@ -40,17 +100,15 @@ const fetchImagePayloadFromUrl = async (absoluteUrl: string): Promise<ImagePaylo
|
||||
|
||||
const maybeBuffer = (globalThis as unknown as { Buffer?: BufferLike }).Buffer
|
||||
if (maybeBuffer && typeof maybeBuffer.from === 'function') {
|
||||
const ab = await resp.arrayBuffer()
|
||||
const b64 = maybeBuffer.from(ab).toString('base64')
|
||||
return { b64, mimeType: mimeType || 'application/octet-stream' }
|
||||
return { b64, mimeType: finalMimeType }
|
||||
}
|
||||
|
||||
if (typeof FileReader === 'undefined') {
|
||||
throw new Error('FileReader is not available to decode image payload')
|
||||
}
|
||||
|
||||
const blob = await resp.blob()
|
||||
const actualMime = blob.type || mimeType || 'application/octet-stream'
|
||||
const blob = new Blob([ab], { type: finalMimeType })
|
||||
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
@@ -66,7 +124,7 @@ const fetchImagePayloadFromUrl = async (absoluteUrl: string): Promise<ImagePaylo
|
||||
|
||||
return {
|
||||
b64: parsed.b64,
|
||||
mimeType: parsed.mimeType || actualMime,
|
||||
mimeType: parsed.mimeType || finalMimeType,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1835,6 +1835,273 @@ describe('useAppPromptGardenImport', () => {
|
||||
}
|
||||
})
|
||||
|
||||
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<PromptRecordChain['versions']>([])
|
||||
const hasRestoredInitialState = ref(false)
|
||||
const isLoadingExternalData = ref(false)
|
||||
|
||||
const query: LocationQuery = {
|
||||
importCode: 'NB-SAVE-FAIL-001',
|
||||
saveToFavorites: '1',
|
||||
}
|
||||
|
||||
const currentRoute = ref<RouteLocationNormalizedLoaded>(makeRoute('/basic/system', query))
|
||||
|
||||
let replaceResolve: (() => void) | undefined
|
||||
const replaceDone = new Promise<void>((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<Router, 'currentRoute' | 'push' | 'replace'> = {
|
||||
currentRoute,
|
||||
push,
|
||||
replace,
|
||||
}
|
||||
|
||||
const favoriteManager = {
|
||||
getFavorites: vi.fn(async (): Promise<FavoritePrompt[]> => []),
|
||||
addFavorite: vi.fn(async (
|
||||
_favorite: Omit<FavoritePrompt, 'id' | 'createdAt' | 'updatedAt' | 'useCount'>
|
||||
) => 'fav-new'),
|
||||
updateFavorite: vi.fn(async (_id: string, _updates: Partial<FavoritePrompt>) => {}),
|
||||
}
|
||||
|
||||
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<Response>
|
||||
>(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<PromptRecordChain['versions']>([])
|
||||
const hasRestoredInitialState = ref(false)
|
||||
const isLoadingExternalData = ref(false)
|
||||
|
||||
const query: LocationQuery = {
|
||||
importCode: 'NB-CONFIRM-FAIL-001',
|
||||
saveToFavorites: 'confirm',
|
||||
}
|
||||
|
||||
const currentRoute = ref<RouteLocationNormalizedLoaded>(makeRoute('/basic/system', query))
|
||||
|
||||
let replaceResolve: (() => void) | undefined
|
||||
const replaceDone = new Promise<void>((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<Router, 'currentRoute' | 'push' | 'replace'> = {
|
||||
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<Response>
|
||||
>(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<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
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<PromptRecordChain['versions']>([])
|
||||
const hasRestoredInitialState = ref(false)
|
||||
const isLoadingExternalData = ref(false)
|
||||
|
||||
const query: LocationQuery = {
|
||||
importCode: 'NB-AUTO-SAVE-FAIL-001',
|
||||
saveToFavorites: 'true',
|
||||
}
|
||||
|
||||
const currentRoute = ref<RouteLocationNormalizedLoaded>(makeRoute('/basic/system', query))
|
||||
|
||||
let replaceResolve: (() => void) | undefined
|
||||
const replaceDone = new Promise<void>((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<Router, 'currentRoute' | 'push' | 'replace'> = {
|
||||
currentRoute,
|
||||
push,
|
||||
replace,
|
||||
}
|
||||
|
||||
const favoriteManager = {
|
||||
getFavorites: vi.fn(async (): Promise<FavoritePrompt[]> => []),
|
||||
addFavorite: vi.fn(async () => {
|
||||
throw new Error('favorites payload exceeds hard limit')
|
||||
}),
|
||||
updateFavorite: vi.fn(async (_id: string, _updates: Partial<FavoritePrompt>) => {}),
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn<
|
||||
(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
>(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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>) => {
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -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'),
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user