fix(ui): harden favorite asset quota and session recovery
This commit is contained in:
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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user