Add VectorEngine image generation config and raise request timeouts (env + scripts) from 180000 to 1000000ms. Introduce a reusable CreativeImageInputPanel component with tests and wire up mobile keyboard-focus helpers; update generation views and related tests (CustomWorldGenerationView, BarkBattle editor, Match3D, Puzzle flows). Improve API error handling / VectorEngine request guidance (packages/shared http.ts and docs), and apply multiple backend/frontend fixes for puzzle/match3d/prompt handling. Also include extensive docs and decision-log updates describing UI/UX decisions and verification steps.
462 lines
13 KiB
TypeScript
462 lines
13 KiB
TypeScript
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<string, BabyObjectMatchDraft>;
|
|
|
|
let memoryDraftStore: LocalDraftStore = {};
|
|
const ignoredLegacyProfileIds = new Set<string>();
|
|
|
|
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<T>(request: IDBRequest<T>) {
|
|
return new Promise<T>((resolve, reject) => {
|
|
request.addEventListener('success', () => resolve(request.result));
|
|
request.addEventListener('error', () => {
|
|
reject(request.error ?? new Error('IndexedDB request failed'));
|
|
});
|
|
});
|
|
}
|
|
|
|
function openDraftDb() {
|
|
return new Promise<IDBDatabase>((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<T>(
|
|
mode: IDBTransactionMode,
|
|
run: (store: IDBObjectStore) => IDBRequest<T>,
|
|
) {
|
|
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<void>((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<LocalDraftStore> {
|
|
if (!canUseIndexedDb()) {
|
|
return {};
|
|
}
|
|
|
|
try {
|
|
const drafts = await withDraftStore<BabyObjectMatchDraft[]>(
|
|
'readonly',
|
|
(store) => store.getAll() as IDBRequest<BabyObjectMatchDraft[]>,
|
|
);
|
|
return Object.fromEntries(drafts.map((draft) => [draft.profileId, draft]));
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function putIndexedDbDraft(draft: BabyObjectMatchDraft) {
|
|
if (!canUseIndexedDb()) {
|
|
return;
|
|
}
|
|
|
|
await withDraftStore<IDBValidKey>('readwrite', (store) => store.put(draft));
|
|
}
|
|
|
|
async function deleteIndexedDbDraft(profileId: string) {
|
|
if (!canUseIndexedDb()) {
|
|
return;
|
|
}
|
|
|
|
await withDraftStore<undefined>('readwrite', (store) =>
|
|
store.delete(profileId),
|
|
);
|
|
}
|
|
|
|
async function readLocalDraftStore(): Promise<LocalDraftStore> {
|
|
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<GenerateBabyObjectMatchAssetsResponse>(
|
|
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<BabyObjectMatchPublishResponse> {
|
|
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,
|
|
};
|