diff --git a/packages/core/src/services/image/storage.ts b/packages/core/src/services/image/storage.ts index 161735dc..de0e70cb 100644 --- a/packages/core/src/services/image/storage.ts +++ b/packages/core/src/services/image/storage.ts @@ -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 { 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 { + 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 { + 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 排序) */ diff --git a/packages/core/src/services/image/types.ts b/packages/core/src/services/image/types.ts index 668d63f0..9928652d 100644 --- a/packages/core/src/services/image/types.ts +++ b/packages/core/src/services/image/types.ts @@ -270,6 +270,7 @@ export interface ImageStorageConfig { maxCount?: number // 最大图像数量,默认 100张 autoCleanupThreshold?: number // 自动清理阈值(达到此比例时触发),默认 0.8 dbName?: string // IndexedDB 数据库名(默认 PromptOptimizerImageDB) + quotaStrategy?: 'evict' | 'reject' // 超额策略:LRU 淘汰 vs 直接报错 } /** diff --git a/packages/ui/src/composables/system/useAppInitializer.ts b/packages/ui/src/composables/system/useAppInitializer.ts index 9d03b8df..2bcb7d0e 100644 --- a/packages/ui/src/composables/system/useAppInitializer.ts +++ b/packages/ui/src/composables/system/useAppInitializer.ts @@ -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', }); diff --git a/packages/ui/src/stores/session/useImageMultiImageSession.ts b/packages/ui/src/stores/session/useImageMultiImageSession.ts index 51e57448..bfc4496e 100644 --- a/packages/ui/src/stores/session/useImageMultiImageSession.ts +++ b/packages/ui/src/stores/session/useImageMultiImageSession.ts @@ -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 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 { diff --git a/packages/ui/src/stores/session/useSessionManager.ts b/packages/ui/src/stores/session/useSessionManager.ts index 87086a0b..9d2045ab 100644 --- a/packages/ui/src/stores/session/useSessionManager.ts +++ b/packages/ui/src/stores/session/useSessionManager.ts @@ -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) } } } diff --git a/packages/ui/src/utils/favorite-asset-maintenance.ts b/packages/ui/src/utils/favorite-asset-maintenance.ts index 3131be75..a957d164 100644 --- a/packages/ui/src/utils/favorite-asset-maintenance.ts +++ b/packages/ui/src/utils/favorite-asset-maintenance.ts @@ -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) => { + 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 }, diff --git a/packages/ui/src/utils/image-asset-storage.ts b/packages/ui/src/utils/image-asset-storage.ts index a5409b22..61dcbb56 100644 --- a/packages/ui/src/utils/image-asset-storage.ts +++ b/packages/ui/src/utils/image-asset-storage.ts @@ -155,6 +155,56 @@ type PersistImagePayloadOptions = { } } +const assertStorageQuotaForPayload = async ( + storageService: IImageStorageService, + imageId: string, + payload: ImagePayload, +): Promise => { + 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 => { @@ -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({ diff --git a/packages/ui/tests/unit/image/image-text2image-evaluation-closure-guard.spec.ts b/packages/ui/tests/unit/image/image-text2image-evaluation-closure-guard.spec.ts index b69d5fe5..56f77705 100644 --- a/packages/ui/tests/unit/image/image-text2image-evaluation-closure-guard.spec.ts +++ b/packages/ui/tests/unit/image/image-text2image-evaluation-closure-guard.spec.ts @@ -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"') }) }) diff --git a/packages/ui/tests/unit/stores/session/image-multiimage-session-persistence.spec.ts b/packages/ui/tests/unit/stores/session/image-multiimage-session-persistence.spec.ts index 5e607869..ddaef1a7 100644 --- a/packages/ui/tests/unit/stores/session/image-multiimage-session-persistence.spec.ts +++ b/packages/ui/tests/unit/stores/session/image-multiimage-session-persistence.spec.ts @@ -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() + + 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 (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) + }) }) diff --git a/packages/ui/tests/unit/stores/session/session-manager.spec.ts b/packages/ui/tests/unit/stores/session/session-manager.spec.ts index 04611e8b..27d6b371 100644 --- a/packages/ui/tests/unit/stores/session/session-manager.spec.ts +++ b/packages/ui/tests/unit/stores/session/session-manager.spec.ts @@ -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, + ) + }) }) diff --git a/packages/ui/tests/unit/utils/favorite-asset-maintenance.spec.ts b/packages/ui/tests/unit/utils/favorite-asset-maintenance.spec.ts index f842064b..ef2c274c 100644 --- a/packages/ui/tests/unit/utils/favorite-asset-maintenance.spec.ts +++ b/packages/ui/tests/unit/utils/favorite-asset-maintenance.spec.ts @@ -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']) + }) }) diff --git a/packages/ui/tests/unit/utils/image-asset-storage.spec.ts b/packages/ui/tests/unit/utils/image-asset-storage.spec.ts index 86043755..5620f4f3 100644 --- a/packages/ui/tests/unit/utils/image-asset-storage.spec.ts +++ b/packages/ui/tests/unit/utils/image-asset-storage.spec.ts @@ -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() + }) })