fix(ui): harden favorite asset quota and session recovery

This commit is contained in:
linshen committed 2026-04-06 14:13:30 +08:00
1 parent ccc75ee6f9
commit 4e3ea11567
12 files changed
+344 -26

No files matched your search

@@ -114,6 +114,7 @@ const DEFAULT_CONFIG: ImageStorageConfig = {
maxCount: 100, // 最多 100 张
autoCleanupThreshold: 0.8, // 达到 80% 时触发清理
dbName: 'PromptOptimizerImageDB',
quotaStrategy: 'evict',
}
/**
@@ -142,6 +143,8 @@ export class ImageStorageService implements IImageStorageService {
async saveImage(data: FullImageData): Promise<string> {
const now = Date.now()
await this.assertQuotaForSave(data)
// 准备元数据记录
const metadataRecord: MetadataRecord = {
id: data.metadata.id,
@@ -301,6 +304,10 @@ export class ImageStorageService implements IImageStorageService {
}
}
if (this.config.quotaStrategy !== 'evict') {
return
}
// 重新获取统计(只查询 metadata 表,性能优化)
const updatedStats = await this.getStorageStats()
@@ -417,6 +424,10 @@ export class ImageStorageService implements IImageStorageService {
* 如果达到阈值,触发清理
*/
private async autoCleanupIfNeeded(): Promise<void> {
if (this.config.quotaStrategy !== 'evict') {
return
}
const threshold = this.config.autoCleanupThreshold!
const maxCacheSize = this.config.maxCacheSize!
const maxCount = this.config.maxCount!
@@ -435,6 +446,36 @@ export class ImageStorageService implements IImageStorageService {
}
}
private async assertQuotaForSave(data: FullImageData): Promise<void> {
if (this.config.quotaStrategy !== 'reject') {
return
}
const currentStats = await this.getStorageStats()
const existing = await this.db.imageMetadata.get(data.metadata.id)
const nextCount = currentStats.count + (existing ? 0 : 1)
const nextTotalBytes =
currentStats.totalBytes - (existing?.sizeBytes || 0) + data.metadata.sizeBytes
const maxCount = this.config.maxCount
if (typeof maxCount === 'number' && Number.isFinite(maxCount) && nextCount > maxCount) {
throw new Error(
`Image storage quota exceeded: projected count ${nextCount} exceeds maxCount ${maxCount}`,
)
}
const maxCacheSize = this.config.maxCacheSize
if (
typeof maxCacheSize === 'number' &&
Number.isFinite(maxCacheSize) &&
nextTotalBytes > maxCacheSize
) {
throw new Error(
`Image storage quota exceeded: projected size ${nextTotalBytes} exceeds maxCacheSize ${maxCacheSize}`,
)
}
}
/**
* 获取最旧的 N 张图像 ID(按 accessedAt 排序)
*/
@@ -270,6 +270,7 @@ export interface ImageStorageConfig {
maxCount?: number // 最大图像数量,默认 100张
autoCleanupThreshold?: number // 自动清理阈值(达到此比例时触发),默认 0.8
dbName?: string // IndexedDB 数据库名(默认 PromptOptimizerImageDB)
quotaStrategy?: 'evict' | 'reject' // 超额策略:LRU 淘汰 vs 直接报错
}
/**
@@ -172,7 +172,7 @@ export function useAppInitializer(): {
maxCacheSize: 200 * 1024 * 1024, // 200 MB
maxAge: undefined,
maxCount: 1000,
autoCleanupThreshold: 0.9,
quotaStrategy: 'reject',
dbName: 'PromptOptimizerFavoriteImageDB',
});
@@ -305,7 +305,7 @@ export function useAppInitializer(): {
maxCacheSize: 200 * 1024 * 1024, // 200 MB
maxAge: undefined,
maxCount: 1000,
autoCleanupThreshold: 0.9,
quotaStrategy: 'reject',
dbName: 'PromptOptimizerFavoriteImageDB',
});
@@ -100,6 +100,19 @@ const createDefaultState = (): ImageMultiImageSessionState => ({
const createRuntimeImageId = () =>
`multi_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
const createMissingInputAssetError = (assetId: string) =>
Object.assign(
new Error(`[ImageMultiImageSession] Missing input image asset: ${assetId}`),
{
code: 'error.storage.read',
params: {
reason: 'session_referenced_image_missing',
key: IMAGE_MULTIIMAGE_SESSION_KEY,
assetId,
},
},
)
const isTestColumnCount = (value: unknown): value is TestColumnCount =>
value === 2 || value === 3 || value === 4
@@ -603,12 +616,21 @@ export const useImageMultiImageSession = defineStore('imageMultiImageSession', (
rawImages.map(async (item) => {
const record = (item || {}) as Record<string, unknown>
const assetId = typeof record.assetId === 'string' ? record.assetId : null
const mimeType = typeof record.mimeType === 'string' ? record.mimeType : 'image/png'
let mimeType = typeof record.mimeType === 'string' ? record.mimeType : 'image/png'
let b64 = typeof record.b64 === 'string' ? record.b64 : ''
if (!b64 && assetId) {
const fullImage = await imageStorageService.getImage(assetId)
b64 = fullImage?.data || ''
if (!fullImage?.data?.trim()) {
throw createMissingInputAssetError(assetId)
}
b64 = fullImage.data
mimeType = fullImage.metadata?.mimeType || mimeType
}
if (!b64.trim()) {
throw new Error('[ImageMultiImageSession] Missing input image base64 data in persisted session')
}
return {
@@ -66,7 +66,10 @@ const getSessionCleanupKey = (key: SubModeKey, error: unknown): string | null =>
return null
}
if (maybeError.params?.reason !== 'session_snapshot_too_large') {
if (
maybeError.params?.reason !== 'session_snapshot_too_large' &&
maybeError.params?.reason !== 'session_referenced_image_missing'
) {
return null
}
@@ -315,7 +318,7 @@ export const useSessionManager = defineStore('sessionManager', () => {
} catch (error) {
const cleanupKey = getSessionCleanupKey(key, error)
if (cleanupKey) {
console.info(`[SessionManager] 检测到超限会话快照,准备清理: ${cleanupKey}`)
console.info(`[SessionManager] 检测到损坏会话快照,准备清理: ${cleanupKey}`)
} else {
console.error(`[SessionManager] 恢复 ${key} 会话失败:`, error)
}
@@ -331,9 +334,9 @@ export const useSessionManager = defineStore('sessionManager', () => {
try {
await $services.preferenceService.delete(cleanupKey)
console.info(`[SessionManager] 已清理超限会话快照: ${cleanupKey}`)
console.info(`[SessionManager] 已清理损坏会话快照: ${cleanupKey}`)
} catch (cleanupError) {
console.error(`[SessionManager] 清理超限会话快照失败 (${cleanupKey}):`, cleanupError)
console.error(`[SessionManager] 清理损坏会话快照失败 (${cleanupKey}):`, cleanupError)
}
}
}
@@ -6,7 +6,7 @@ import type {
type FavoriteManagerForGc = Pick<
IFavoriteManager,
'getFavorites' | 'getFavorite' | 'updateFavorite' | 'deleteFavorite' | 'deleteFavorites'
'getFavorites' | 'getFavorite' | 'updateFavorite' | 'deleteFavorite' | 'deleteFavorites' | 'importFavorites'
> &
IFavoriteManager
@@ -166,6 +166,14 @@ export const attachFavoriteAssetGc = (
}
}
if (prop === 'importFavorites') {
return async (...args: Parameters<IFavoriteManager['importFavorites']>) => {
const result = await target.importFavorites(...args)
await runFavoriteAssetGc(target, favoriteImageStorageService)
return result
}
}
const value = Reflect.get(target, prop, receiver)
return typeof value === 'function' ? value.bind(target) : value
},
@@ -155,6 +155,56 @@ type PersistImagePayloadOptions = {
}
}
const assertStorageQuotaForPayload = async (
storageService: IImageStorageService,
imageId: string,
payload: ImagePayload,
): Promise<void> => {
const config = typeof storageService.getConfig === 'function'
? storageService.getConfig()
: null
if (!config || config.quotaStrategy !== 'reject') {
return
}
const existing = await storageService.getMetadata(imageId)
if (existing) {
return
}
const stats = typeof storageService.getStorageStats === 'function'
? await storageService.getStorageStats()
: null
if (!stats) {
return
}
const nextCount = stats.count + 1
const nextTotalBytes = stats.totalBytes + Math.floor(payload.b64.length * 0.75)
if (
typeof config.maxCount === 'number' &&
Number.isFinite(config.maxCount) &&
nextCount > config.maxCount
) {
throw new Error(
`Image storage quota exceeded: projected count ${nextCount} exceeds maxCount ${config.maxCount}`,
)
}
if (
typeof config.maxCacheSize === 'number' &&
Number.isFinite(config.maxCacheSize) &&
nextTotalBytes > config.maxCacheSize
) {
throw new Error(
`Image storage quota exceeded: projected size ${nextTotalBytes} exceeds maxCacheSize ${config.maxCacheSize}`,
)
}
}
export const persistImagePayloadAsAssetId = async (
opts: PersistImagePayloadOptions,
): Promise<string | null> => {
@@ -162,6 +212,7 @@ export const persistImagePayloadAsAssetId = async (
if (!storageService || !payload?.b64) return null
const imageId = await computeStableImageId(payload.b64, payload.mimeType)
await assertStorageQuotaForPayload(storageService, imageId, payload)
const existing = await storageService.getMetadata(imageId)
if (!existing) {
await storageService.saveImage({
@@ -9,31 +9,31 @@ const readWorkspaceSource = () =>
)
describe('image text2image evaluation closure guard', () => {
it('does not force prompt-only analysis and wires prompt apply-improvement', () => {
it('keeps prompt evaluation scoped to prompt-only and disables panel-side rewrite actions', () => {
const source = readWorkspaceSource()
expect(source).not.toContain('evaluation-type-override="prompt-only"')
expect(source).toContain('@apply-improvement="handleApplyImprovement"')
expect(source).toContain('evaluation-type-override="prompt-only"')
expect(source).toContain(':can-rewrite-from-evaluation="false"')
})
it('wires result and compare evaluation actions back into optimization', () => {
it('wires result and compare evaluation actions to the current evaluation handlers', () => {
const source = readWorkspaceSource()
expect(source).toContain('@apply-improvement="handleApplyImprovement"')
expect(source).toContain('@apply-patch="handleApplyLocalPatch"')
expect(source).toContain(':can-rewrite-from-evaluation="true"')
expect(source).toContain('@apply-local-patch="handleApplyLocalPatch"')
expect(source).toContain('@rewrite-from-evaluation="handleRewriteFromEvaluation"')
expect(source).toContain('@evaluate="handleEvaluateCompare"')
expect(source).toContain('@evaluate-with-feedback="handleCompareEvaluateWithFeedback"')
expect(source).toContain("@evaluate=\"() => handleEvaluateResult(id)\"")
expect(source).toContain('@evaluate-with-feedback="handleResultEvaluateWithFeedbackEvent(id, $event)"')
})
it('adds an original-input analyze button and a virtual V0 analysis flow', () => {
it('routes original-input analysis through reference action buttons instead of the legacy analyze button', () => {
const source = readWorkspaceSource()
expect(source).toContain('data-testid="image-text2image-analyze-button"')
expect(source).toContain(':loading="isAnalyzing"')
expect(source).toContain('@click="handleAnalyze"')
expect(source).toContain("evaluation.clearResult('prompt-only')")
expect(source).toContain("evaluation.clearResult('prompt-iterate')")
expect(source).toContain("await handleEvaluateInternal('prompt-only')")
expect(source).toContain('v-for="button in referenceActionButtons"')
expect(source).toContain('@click="triggerReferenceAction(button.kind)"')
expect(source).toContain("kind: 'replicate' as ReferenceActionKind")
expect(source).toContain("kind: 'style-learn' as ReferenceActionKind")
expect(source).toContain('referenceAction.requestAction(actionKind)')
expect(source).toContain("extractImageInputRef.value?.click()")
expect(source).not.toContain('data-testid="image-text2image-analyze-button"')
})
})
@@ -243,4 +243,54 @@ describe('Session store (image-multiimage) persistence', () => {
expect(imageStorageService.saveImage).toHaveBeenCalledTimes(3)
expect(imageStorageService.getImage).toHaveBeenCalled()
})
it('fails restore when a persisted input image asset is missing', async () => {
const savedSnapshots = new Map<string, unknown>()
savedSnapshots.set(IMAGE_MULTIIMAGE_SESSION_KEY, {
originalPrompt: '使用两张参考图',
inputImages: [
{
id: 'input-1',
assetId: 'missing-asset',
mimeType: 'image/png',
},
{
id: 'input-2',
assetId: 'existing-asset',
mimeType: 'image/jpeg',
},
],
})
const { pinia } = createTestPinia({
preferenceService: {
get: async <T,>(key: string, defaultValue: T) =>
(savedSnapshots.has(key) ? savedSnapshots.get(key) : defaultValue) as T,
set: async () => {},
delete: async () => {},
keys: async () => [],
clear: async () => {},
getAll: async () => ({}),
exportData: async () => ({}),
importData: async () => {},
getDataType: async () => 'preference',
validateData: async () => true,
} as any,
imageStorageService: {
getImage: vi.fn(async (id: string) =>
id === 'existing-asset'
? {
data: 'BBBB',
metadata: { mimeType: 'image/jpeg' },
}
: null,
),
} as any,
})
const restored = useImageMultiImageSession(pinia)
await expect(restored.restoreSession()).rejects.toThrow(/missing input image asset/i)
})
})
@@ -36,4 +36,38 @@ describe('SessionManager', () => {
null,
)
})
it('cleans the damaged session key when restore fails because a referenced image asset is missing', async () => {
const deleteMock = vi.fn(async () => {})
const { pinia, services } = createTestPinia({
preferenceService: createPreferenceServiceStub({
get: vi.fn(async () => {
throw {
code: 'error.storage.read',
params: {
reason: 'session_referenced_image_missing',
key: 'session/v1/image-multiimage',
assetId: 'missing-asset',
},
}
}),
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,
)
})
})
@@ -187,4 +187,79 @@ describe('favoriteAssetMaintenance', () => {
expect(favoriteManager.updateFavorite).toHaveBeenCalled()
expect(favoriteImageStorageService.deleteImages).toHaveBeenCalledWith(['old-asset'])
})
it('runs GC after importing favorites when overwrite changes image references', async () => {
const favorites = [
{
id: 'fav-1',
content: 'same-content',
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(),
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(async () => {
favorites[0] = {
...favorites[0],
metadata: {
media: {
assetIds: ['new-asset'],
},
},
}
return {
imported: 1,
skipped: 0,
errors: [],
}
}),
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.importFavorites(JSON.stringify({ favorites: [] }), {
mergeStrategy: 'overwrite',
})
expect(favoriteManager.importFavorites).toHaveBeenCalled()
expect(favoriteImageStorageService.deleteImages).toHaveBeenCalledWith(['old-asset'])
})
})
@@ -1,6 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { normalizeImageSourceToPayload } from '../../../src/utils/image-asset-storage'
import {
normalizeImageSourceToPayload,
persistImagePayloadAsAssetId,
} from '../../../src/utils/image-asset-storage'
describe('image asset storage utilities', () => {
afterEach(() => {
@@ -36,4 +39,34 @@ describe('image asset storage utilities', () => {
b64: Buffer.from(pngBytes).toString('base64'),
})
})
it('rejects new asset writes when storage is configured to reject quota overflow', async () => {
const storageService = {
getMetadata: vi.fn(async () => null),
getStorageStats: vi.fn(async () => ({
count: 1000,
totalBytes: 200 * 1024 * 1024,
oldestAt: null,
newestAt: null,
})),
getConfig: vi.fn(() => ({
maxCacheSize: 200 * 1024 * 1024,
maxCount: 1000,
quotaStrategy: 'reject',
})),
saveImage: vi.fn(async () => 'img_new'),
}
await expect(
persistImagePayloadAsAssetId({
payload: {
b64: 'AAAA',
mimeType: 'image/png',
},
storageService: storageService as any,
}),
).rejects.toThrow(/quota/i)
expect(storageService.saveImage).not.toHaveBeenCalled()
})
})