import type { BabyObjectMatchDraft, BabyObjectMatchItemAsset, BabyObjectMatchPublishRequest, BabyObjectMatchPublishResponse, BabyObjectMatchVisualAsset, BabyObjectMatchVisualAssetKind, BabyObjectMatchVisualPackage, CreateBabyObjectMatchDraftRequest, GenerateBabyObjectMatchAssetsResponse, SaveBabyObjectMatchDraftRequest, } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG, BABY_OBJECT_MATCH_TEMPLATE_ID, BABY_OBJECT_MATCH_TEMPLATE_NAME, normalizeBabyObjectMatchTags, validateBabyObjectMatchItemNames, } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import { type ApiRetryOptions, requestJson } from '../apiClient'; import { buildBabyObjectMatchPublicWorkCode } from '../publicWorkCode'; const STORAGE_KEY = 'genarrative.edutainmentBabyObject.localDrafts.v1'; const BABY_OBJECT_MATCH_ASSET_API = '/api/creation/edutainment/baby-object-match/assets'; export const BABY_OBJECT_MATCH_ASSET_REQUEST_TIMEOUT_MS = 1_000_000; const BABY_OBJECT_MATCH_ASSET_REQUEST_RETRY: ApiRetryOptions = { maxRetries: 0, }; const BABY_OBJECT_MATCH_REQUIRED_VISUAL_KINDS: BabyObjectMatchVisualAssetKind[] = ['background', 'ui-frame', 'gift-box', 'basket', 'smoke-puff']; const DRAFT_DB_NAME = 'genarrative-edutainment-baby-object-drafts'; const DRAFT_DB_VERSION = 1; const DRAFT_STORE_NAME = 'drafts'; type LocalDraftStore = Record; let memoryDraftStore: LocalDraftStore = {}; const ignoredLegacyProfileIds = new Set(); function canUseLocalStorage() { return ( typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' ); } function canUseIndexedDb() { return typeof indexedDB !== 'undefined'; } function readLegacyLocalDraftStore(): LocalDraftStore { if (!canUseLocalStorage()) { return {}; } try { const rawValue = window.localStorage.getItem(STORAGE_KEY); if (!rawValue) { return {}; } const parsed = JSON.parse(rawValue) as LocalDraftStore; if (!parsed || typeof parsed !== 'object') { return {}; } return Object.fromEntries( Object.entries(parsed).filter( ([profileId]) => !ignoredLegacyProfileIds.has(profileId), ), ); } catch { return {}; } } function clearLegacyLocalDraftStore() { if (canUseLocalStorage()) { window.localStorage.removeItem(STORAGE_KEY); } } function idbRequestToPromise(request: IDBRequest) { return new Promise((resolve, reject) => { request.addEventListener('success', () => resolve(request.result)); request.addEventListener('error', () => { reject(request.error ?? new Error('IndexedDB request failed')); }); }); } function openDraftDb() { return new Promise((resolve, reject) => { if (!canUseIndexedDb()) { reject(new Error('IndexedDB unavailable')); return; } const request = indexedDB.open(DRAFT_DB_NAME, DRAFT_DB_VERSION); request.addEventListener('upgradeneeded', () => { const db = request.result; if (!db.objectStoreNames.contains(DRAFT_STORE_NAME)) { db.createObjectStore(DRAFT_STORE_NAME, { keyPath: 'profileId' }); } }); request.addEventListener('success', () => resolve(request.result)); request.addEventListener('error', () => { reject(request.error ?? new Error('IndexedDB open failed')); }); }); } async function withDraftStore( mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest, ) { const db = await openDraftDb(); try { const transaction = db.transaction(DRAFT_STORE_NAME, mode); const result = await idbRequestToPromise(run(transaction.objectStore(DRAFT_STORE_NAME))); await new Promise((resolve, reject) => { transaction.addEventListener('complete', () => resolve()); transaction.addEventListener('abort', () => { reject(transaction.error ?? new Error('IndexedDB transaction aborted')); }); transaction.addEventListener('error', () => { reject(transaction.error ?? new Error('IndexedDB transaction failed')); }); }); return result; } finally { db.close(); } } async function readIndexedDbDraftStore(): Promise { if (!canUseIndexedDb()) { return {}; } try { const drafts = await withDraftStore( 'readonly', (store) => store.getAll() as IDBRequest, ); return Object.fromEntries(drafts.map((draft) => [draft.profileId, draft])); } catch { return {}; } } async function putIndexedDbDraft(draft: BabyObjectMatchDraft) { if (!canUseIndexedDb()) { return; } await withDraftStore('readwrite', (store) => store.put(draft)); } async function deleteIndexedDbDraft(profileId: string) { if (!canUseIndexedDb()) { return; } await withDraftStore('readwrite', (store) => store.delete(profileId), ); } async function readLocalDraftStore(): Promise { const indexedDrafts = await readIndexedDbDraftStore(); const legacyDrafts = readLegacyLocalDraftStore(); const merged = { ...legacyDrafts, ...memoryDraftStore, ...indexedDrafts, }; if (canUseIndexedDb() && Object.keys(legacyDrafts).length > 0) { await Promise.all(Object.values(legacyDrafts).map(putIndexedDbDraft)); clearLegacyLocalDraftStore(); } memoryDraftStore = { ...memoryDraftStore, ...merged }; return merged; } function createLocalId(prefix: string) { const randomPart = typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID().replace(/-/gu, '') : Math.random().toString(36).slice(2); return `${prefix}-${Date.now().toString(36)}-${randomPart.slice(0, 12)}`; } function normalizeGeneratedAssets( assets: BabyObjectMatchItemAsset[] | null | undefined, itemNames: [string, string], ): [BabyObjectMatchItemAsset, BabyObjectMatchItemAsset] | null { if (!Array.isArray(assets) || assets.length !== 2) { return null; } const normalizedAssets = assets.map((asset, index) => ({ ...asset, itemId: `baby-object-item-${index + 1}`, itemName: itemNames[index], })); if ( normalizedAssets.some( (asset) => asset.generationProvider !== 'vector-engine-gpt-image-2' || !asset.imageSrc.startsWith('data:image/png;base64,'), ) ) { return null; } return [ normalizedAssets[0] as BabyObjectMatchItemAsset, normalizedAssets[1] as BabyObjectMatchItemAsset, ]; } function normalizeGeneratedVisualPackage( visualPackage: BabyObjectMatchVisualPackage | null | undefined, ): BabyObjectMatchVisualPackage | null { if (!visualPackage || !Array.isArray(visualPackage.assets)) { return null; } const normalizedAssets: BabyObjectMatchVisualAsset[] = []; for (const kind of BABY_OBJECT_MATCH_REQUIRED_VISUAL_KINDS) { const asset = visualPackage.assets.find( (entry) => entry.assetKind === kind, ); if ( !asset || asset.generationProvider !== 'vector-engine-gpt-image-2' || !asset.imageSrc.startsWith('data:image/png;base64,') ) { return null; } normalizedAssets.push({ ...asset, assetId: `baby-object-visual-${kind}`, }); } return { themePrompt: visualPackage.themePrompt.trim(), assets: normalizedAssets, }; } async function generateBabyObjectMatchAssets( itemNames: [string, string], ): Promise<{ assets: [BabyObjectMatchItemAsset, BabyObjectMatchItemAsset]; visualPackage: BabyObjectMatchVisualPackage; }> { const response = await requestJson( BABY_OBJECT_MATCH_ASSET_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemNames }), }, '生成宝贝识物物品素材失败', { retry: BABY_OBJECT_MATCH_ASSET_REQUEST_RETRY, timeoutMs: BABY_OBJECT_MATCH_ASSET_REQUEST_TIMEOUT_MS, }, ); const assets = normalizeGeneratedAssets(response.assets, itemNames); const visualPackage = normalizeGeneratedVisualPackage(response.visualPackage); if (!assets || !visualPackage) { throw new Error('宝贝识物 image-2 资源生成结果不完整,请重试。'); } return { assets, visualPackage }; } async function saveDraftToLocalStore(draft: BabyObjectMatchDraft) { memoryDraftStore[draft.profileId] = draft; await putIndexedDbDraft(draft); } export function normalizeBabyObjectMatchDraft( draft: BabyObjectMatchDraft, ): BabyObjectMatchDraft { const now = new Date().toISOString(); return { ...draft, templateId: BABY_OBJECT_MATCH_TEMPLATE_ID, templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME, workTitle: draft.workTitle.trim() || '宝贝识物', workDescription: draft.workDescription.trim(), itemNames: [draft.itemNames[0].trim(), draft.itemNames[1].trim()], itemAssets: [ { ...draft.itemAssets[0], itemName: draft.itemNames[0].trim(), }, { ...draft.itemAssets[1], itemName: draft.itemNames[1].trim(), }, ], visualPackage: draft.visualPackage ?? null, themeTags: normalizeBabyObjectMatchTags(draft.themeTags), updatedAt: draft.updatedAt || now, }; } export function hasBabyObjectMatchPlaceholderAssets( draft: BabyObjectMatchDraft, ) { const visualAssets = draft.visualPackage?.assets ?? []; return ( draft.itemAssets.some( (asset) => asset.generationProvider !== 'vector-engine-gpt-image-2' || !asset.imageSrc.startsWith('data:image/png;base64,'), ) || !draft.visualPackage || BABY_OBJECT_MATCH_REQUIRED_VISUAL_KINDS.some( (kind) => !visualAssets.some( (asset) => asset.assetKind === kind && asset.generationProvider === 'vector-engine-gpt-image-2' && asset.imageSrc.startsWith('data:image/png;base64,'), ), ) ); } export async function regenerateBabyObjectMatchDraftAssets( draft: BabyObjectMatchDraft, ) { const itemNames: [string, string] = [ draft.itemNames[0].trim(), draft.itemNames[1].trim(), ]; const generated = await generateBabyObjectMatchAssets(itemNames); const nextDraft = normalizeBabyObjectMatchDraft({ ...draft, itemNames, itemAssets: generated.assets, visualPackage: generated.visualPackage, updatedAt: new Date().toISOString(), }); await saveDraftToLocalStore(nextDraft); return { draft: nextDraft }; } export async function createBabyObjectMatchDraft( payload: CreateBabyObjectMatchDraftRequest, ) { const validated = validateBabyObjectMatchItemNames(payload); if (!validated.valid) { throw new Error('请填写两个物品名称。'); } const now = new Date().toISOString(); const draftId = createLocalId('baby-object-draft'); const profileId = createLocalId('baby-object-profile'); const itemNames: [string, string] = [ validated.itemAName, validated.itemBName, ]; const generated = await generateBabyObjectMatchAssets(itemNames); const draft = normalizeBabyObjectMatchDraft({ draftId, profileId, templateId: BABY_OBJECT_MATCH_TEMPLATE_ID, templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME, workTitle: '宝贝识物', workDescription: `${itemNames[0]}和${itemNames[1]}识物分类`, itemNames, itemAssets: generated.assets, visualPackage: generated.visualPackage, themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'], publicationStatus: 'draft', createdAt: now, updatedAt: now, publishedAt: null, }); await saveDraftToLocalStore(draft); return { draft }; } export async function saveBabyObjectMatchDraft( payload: SaveBabyObjectMatchDraftRequest, ) { const draft = normalizeBabyObjectMatchDraft({ ...payload.draft, updatedAt: new Date().toISOString(), }); await saveDraftToLocalStore(draft); return { draft }; } export async function publishBabyObjectMatchWork( payload: BabyObjectMatchPublishRequest, ): Promise { const draft = normalizeBabyObjectMatchDraft({ ...payload.draft, publicationStatus: 'published', publishedAt: payload.draft.publishedAt ?? new Date().toISOString(), updatedAt: new Date().toISOString(), }); await saveDraftToLocalStore(draft); return { draft, publicWorkCode: buildBabyObjectMatchPublicWorkCode(draft.profileId), }; } export async function listLocalBabyObjectMatchDrafts() { return Object.values(await readLocalDraftStore()).sort( (left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(), ); } export async function deleteLocalBabyObjectMatchDraft(profileId: string) { const normalizedProfileId = profileId.trim(); if (!normalizedProfileId) { return await listLocalBabyObjectMatchDrafts(); } ignoredLegacyProfileIds.add(normalizedProfileId); delete memoryDraftStore[normalizedProfileId]; await deleteIndexedDbDraft(normalizedProfileId); return await listLocalBabyObjectMatchDrafts(); } export function __resetBabyObjectMatchLocalDraftStorageForTests() { memoryDraftStore = {}; ignoredLegacyProfileIds.clear(); } export const babyObjectMatchClient = { createDraft: createBabyObjectMatchDraft, deleteDraft: deleteLocalBabyObjectMatchDraft, regenerateDraftAssets: regenerateBabyObjectMatchDraftAssets, saveDraft: saveBabyObjectMatchDraft, publish: publishBabyObjectMatchWork, listLocalDrafts: listLocalBabyObjectMatchDrafts, };