fix(storage): harden startup safety and favorite asset handling

This commit is contained in:
linshen committed 2026-04-06 14:13:28 +08:00
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 边界的强测试
只有当“结构化数据”和“图片资产”在架构、代码、测试、删除回收四个层面都被强制分层后,这类问题才算真正从根上解决。
+20
View File
@@ -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'
+30 -2
View File
@@ -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
}
+15 -9
View File
@@ -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',
}),
]),
)
})
})
+4
View File
@@ -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
}
+63 -5
View File
@@ -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'),
})
})
})