refactor(api-server): narrow puzzle state surface
This commit is contained in:
@@ -14,6 +14,7 @@ export type CreativeImageInputReferenceImage = {
|
||||
id: string;
|
||||
label: string;
|
||||
imageSrc: string;
|
||||
assetObjectId?: string | null;
|
||||
};
|
||||
|
||||
export type CreativeImageInputPanelLabels = {
|
||||
|
||||
@@ -1956,6 +1956,8 @@ function buildPuzzleCompileActionFromFormPayload(
|
||||
...(pictureDescription ? { pictureDescription } : {}),
|
||||
referenceImageSrc: payload?.referenceImageSrc || null,
|
||||
referenceImageSrcs: payload?.referenceImageSrcs ?? [],
|
||||
referenceImageAssetObjectId: payload?.referenceImageAssetObjectId ?? null,
|
||||
referenceImageAssetObjectIds: payload?.referenceImageAssetObjectIds ?? [],
|
||||
imageModel: payload?.imageModel ?? null,
|
||||
aiRedraw: payload?.aiRedraw ?? true,
|
||||
candidateCount: 1,
|
||||
@@ -1978,6 +1980,8 @@ function buildPuzzleFormPayloadFromSession(
|
||||
pictureDescription,
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: null,
|
||||
aiRedraw: true,
|
||||
};
|
||||
@@ -2008,6 +2012,8 @@ function buildPuzzleFormPayloadFromAction(
|
||||
? (payload.referenceImageSrc ?? null)
|
||||
: (payload.referenceImageSrc ?? null),
|
||||
referenceImageSrcs: payload.referenceImageSrcs ?? [],
|
||||
referenceImageAssetObjectId: payload.referenceImageAssetObjectId ?? null,
|
||||
referenceImageAssetObjectIds: payload.referenceImageAssetObjectIds ?? [],
|
||||
imageModel:
|
||||
payload.action === 'compile_puzzle_draft'
|
||||
? (payload.imageModel ?? null)
|
||||
@@ -5542,6 +5548,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
pictureDescription: payload.pictureDescription ?? '',
|
||||
referenceImageSrc: payload.referenceImageSrc ?? null,
|
||||
referenceImageSrcs: payload.referenceImageSrcs ?? [],
|
||||
referenceImageAssetObjectId:
|
||||
payload.referenceImageAssetObjectId ?? null,
|
||||
referenceImageAssetObjectIds:
|
||||
payload.referenceImageAssetObjectIds ?? [],
|
||||
imageModel: payload.imageModel ?? null,
|
||||
aiRedraw: payload.aiRedraw ?? true,
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock('../ResolvedAssetImage', () => ({
|
||||
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
|
||||
puzzleAssetClient: {
|
||||
listHistoryAssets: vi.fn(),
|
||||
uploadReferenceImage: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -90,6 +91,19 @@ beforeEach(() => {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
vi.mocked(puzzleAssetClient.uploadReferenceImage).mockImplementation(
|
||||
async ({ file }) => ({
|
||||
assetObjectId: `asset-reference-${file.name}`,
|
||||
assetKind: 'puzzle_cover_image',
|
||||
objectKey: `generated-puzzle-assets/reference/${file.name}`,
|
||||
imageSrc: `/generated-puzzle-assets/reference/${file.name}`,
|
||||
ownerUserId: 'user-1',
|
||||
profileId: null,
|
||||
entityId: null,
|
||||
createdAt: '1713686400.000000Z',
|
||||
updatedAt: '1713686400.000000Z',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -190,6 +204,8 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
});
|
||||
@@ -325,8 +341,10 @@ test('puzzle workspace selects a history image from the upload card', async () =
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '保留历史图里的主体,改成晴天花园。',
|
||||
pictureDescription: '保留历史图里的主体,改成晴天花园。',
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: 'asset-history-1',
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
});
|
||||
@@ -384,6 +402,8 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
|
||||
promptText: '潮雾中的灯塔与断桥',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
@@ -484,6 +504,8 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
||||
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
});
|
||||
@@ -528,8 +550,10 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: 'first-level.png',
|
||||
pictureDescription: 'first-level.png',
|
||||
referenceImageSrc: uploadedDataUrl,
|
||||
referenceImageSrc: '/generated-puzzle-assets/reference/first-level.png',
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: 'asset-reference-first-level.png',
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: false,
|
||||
});
|
||||
@@ -584,6 +608,8 @@ test('puzzle workspace submits history image when AI redraw is off', async () =>
|
||||
pictureDescription: '历史素材 · image.png',
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: 'asset-history-1',
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: false,
|
||||
});
|
||||
@@ -593,6 +619,17 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
|
||||
const onCreateFromForm = vi.fn();
|
||||
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
|
||||
stubReferenceImageUpload(uploadedDataUrl);
|
||||
vi.mocked(puzzleAssetClient.uploadReferenceImage).mockResolvedValue({
|
||||
assetObjectId: 'asset-reference-main-1',
|
||||
assetKind: 'puzzle_cover_image',
|
||||
objectKey: 'generated-puzzle-assets/reference/main-1.png',
|
||||
imageSrc: '/generated-puzzle-assets/reference/main-1.png',
|
||||
ownerUserId: 'user-1',
|
||||
profileId: null,
|
||||
entityId: null,
|
||||
createdAt: '1713686400.000000Z',
|
||||
updatedAt: '1713686400.000000Z',
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
@@ -612,6 +649,9 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText('拼图图片')).toBeTruthy();
|
||||
});
|
||||
expect(puzzleAssetClient.uploadReferenceImage).toHaveBeenCalledWith({
|
||||
file: expect.any(File),
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('画面AI重绘要求(提示词)'), {
|
||||
target: { value: '保留上传画面的主体和构图,改成雨夜灯街。' },
|
||||
});
|
||||
@@ -621,8 +661,101 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '保留上传画面的主体和构图,改成雨夜灯街。',
|
||||
pictureDescription: '保留上传画面的主体和构图,改成雨夜灯街。',
|
||||
referenceImageSrc: uploadedDataUrl,
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: 'asset-reference-main-1',
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('puzzle workspace uploads prompt references as asset object ids', async () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
const uploadedSources = [
|
||||
'data:image/png;base64,reference-1',
|
||||
'data:image/png;base64,reference-2',
|
||||
];
|
||||
let readIndex = 0;
|
||||
stubReferenceImageUpload(uploadedSources[0] ?? 'data:image/png;base64,reference-1');
|
||||
class MockFileReader {
|
||||
result: string | null = null;
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
|
||||
readAsDataURL() {
|
||||
this.result = uploadedSources[readIndex] ?? uploadedSources[0] ?? '';
|
||||
readIndex += 1;
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
|
||||
vi.mocked(puzzleAssetClient.uploadReferenceImage)
|
||||
.mockResolvedValueOnce({
|
||||
assetObjectId: 'asset-reference-prompt-1',
|
||||
assetKind: 'puzzle_cover_image',
|
||||
objectKey: 'generated-puzzle-assets/reference/prompt-1.png',
|
||||
imageSrc: '/generated-puzzle-assets/reference/prompt-1.png',
|
||||
ownerUserId: 'user-1',
|
||||
profileId: null,
|
||||
entityId: null,
|
||||
createdAt: '1713686400.000000Z',
|
||||
updatedAt: '1713686400.000000Z',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
assetObjectId: 'asset-reference-prompt-2',
|
||||
assetKind: 'puzzle_cover_image',
|
||||
objectKey: 'generated-puzzle-assets/reference/prompt-2.png',
|
||||
imageSrc: '/generated-puzzle-assets/reference/prompt-2.png',
|
||||
ownerUserId: 'user-1',
|
||||
profileId: null,
|
||||
entityId: null,
|
||||
createdAt: '1713686400.000000Z',
|
||||
updatedAt: '1713686400.000000Z',
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('上传参考图', { selector: 'input' }), {
|
||||
target: {
|
||||
files: uploadedSources.map(
|
||||
(_source, index) =>
|
||||
new File(['x'], `reference-${index + 1}.png`, {
|
||||
type: 'image/png',
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('button', { name: /预览参考图/u })).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
confirmPuzzlePointCost();
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '一只猫在雨夜灯牌下回头。',
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [
|
||||
'asset-reference-prompt-1',
|
||||
'asset-reference-prompt-2',
|
||||
],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
});
|
||||
@@ -705,7 +838,15 @@ test('puzzle workspace uploads prompt reference images from the description box'
|
||||
seedText: '一只猫在雨夜灯牌下回头。',
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: uploadedSources.slice(0, 5),
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [
|
||||
'asset-reference-reference-1.png',
|
||||
'asset-reference-reference-2.png',
|
||||
'asset-reference-reference-3.png',
|
||||
'asset-reference-reference-4.png',
|
||||
'asset-reference-reference-5.png',
|
||||
],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
});
|
||||
|
||||
@@ -16,9 +16,11 @@ import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works
|
||||
import {
|
||||
cropPuzzleReferenceImageDataUrl,
|
||||
isPuzzleReferenceImageSquare,
|
||||
puzzleReferenceImageDataUrlToFile,
|
||||
readPuzzleReferenceImageAsDataUrl,
|
||||
readPuzzleReferenceImageForUpload,
|
||||
} from '../../services/puzzleReferenceImage';
|
||||
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
|
||||
import {
|
||||
CreativeImageInputPanel,
|
||||
type CreativeImageInputReferenceImage,
|
||||
@@ -54,6 +56,7 @@ type PuzzleAgentWorkspaceProps = {
|
||||
type PuzzleFormState = {
|
||||
pictureDescription: string;
|
||||
referenceImageSrc: string;
|
||||
referenceImageAssetObjectId: string;
|
||||
referenceImageLabel: string;
|
||||
referenceImageSrcs: CreativeImageInputReferenceImage[];
|
||||
imageModel: PuzzleImageModelId;
|
||||
@@ -63,6 +66,7 @@ type PuzzleFormState = {
|
||||
const EMPTY_FORM_STATE: PuzzleFormState = {
|
||||
pictureDescription: '',
|
||||
referenceImageSrc: '',
|
||||
referenceImageAssetObjectId: '',
|
||||
referenceImageLabel: '',
|
||||
referenceImageSrcs: [],
|
||||
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
@@ -74,6 +78,7 @@ const PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT = 5;
|
||||
type PuzzleImageCropState = {
|
||||
source: string;
|
||||
label: string;
|
||||
fileName: string;
|
||||
imageSize: { width: number; height: number };
|
||||
cropRect: SquareImageCropRect;
|
||||
error: string | null;
|
||||
@@ -97,11 +102,14 @@ function resolveInitialFormState(
|
||||
return {
|
||||
pictureDescription: formDraft.pictureDescription ?? '',
|
||||
referenceImageSrc: initialFormPayload?.referenceImageSrc ?? '',
|
||||
referenceImageAssetObjectId:
|
||||
initialFormPayload?.referenceImageAssetObjectId ?? '',
|
||||
referenceImageLabel: initialFormPayload?.referenceImageSrc
|
||||
? '已选择拼图图片'
|
||||
: '',
|
||||
referenceImageSrcs: createPuzzlePromptReferenceImagesFromSources(
|
||||
initialFormPayload?.referenceImageSrcs,
|
||||
initialFormPayload?.referenceImageAssetObjectIds,
|
||||
),
|
||||
imageModel: normalizePuzzleImageModel(initialFormPayload?.imageModel),
|
||||
aiRedraw: initialFormPayload?.aiRedraw ?? true,
|
||||
@@ -115,11 +123,14 @@ function resolveInitialFormState(
|
||||
initialFormPayload.seedText ??
|
||||
'',
|
||||
referenceImageSrc: initialFormPayload.referenceImageSrc ?? '',
|
||||
referenceImageAssetObjectId:
|
||||
initialFormPayload.referenceImageAssetObjectId ?? '',
|
||||
referenceImageLabel: initialFormPayload.referenceImageSrc
|
||||
? '已选择拼图图片'
|
||||
: '',
|
||||
referenceImageSrcs: createPuzzlePromptReferenceImagesFromSources(
|
||||
initialFormPayload.referenceImageSrcs,
|
||||
initialFormPayload.referenceImageAssetObjectIds,
|
||||
),
|
||||
imageModel: normalizePuzzleImageModel(initialFormPayload.imageModel),
|
||||
aiRedraw: initialFormPayload.aiRedraw ?? true,
|
||||
@@ -138,6 +149,7 @@ function resolveInitialFormState(
|
||||
session.seedText ||
|
||||
'',
|
||||
referenceImageSrc: '',
|
||||
referenceImageAssetObjectId: '',
|
||||
referenceImageLabel: '',
|
||||
referenceImageSrcs: [],
|
||||
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
@@ -166,14 +178,46 @@ function normalizePuzzlePromptReferenceSources(
|
||||
|
||||
function createPuzzlePromptReferenceImagesFromSources(
|
||||
sources: readonly string[] | null | undefined,
|
||||
assetObjectIds: readonly string[] | null | undefined = [],
|
||||
): CreativeImageInputReferenceImage[] {
|
||||
return normalizePuzzlePromptReferenceSources(sources).map(
|
||||
const assetIds = normalizePuzzleAssetObjectIds(assetObjectIds);
|
||||
const sourceImages = normalizePuzzlePromptReferenceSources(sources).map(
|
||||
(imageSrc, index) => ({
|
||||
id: `restored:${index}:${imageSrc}`,
|
||||
label: `参考图 ${index + 1}`,
|
||||
imageSrc,
|
||||
assetObjectId: assetIds[index] ?? null,
|
||||
}),
|
||||
);
|
||||
if (sourceImages.length > 0) {
|
||||
return sourceImages;
|
||||
}
|
||||
|
||||
return assetIds.map((assetObjectId, index) => ({
|
||||
id: `restored-asset:${index}:${assetObjectId}`,
|
||||
label: `参考图 ${index + 1}`,
|
||||
imageSrc: '',
|
||||
assetObjectId,
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizePuzzleAssetObjectIds(
|
||||
assetObjectIds: readonly (string | null | undefined)[] | null | undefined,
|
||||
) {
|
||||
const normalizedIds: string[] = [];
|
||||
for (const assetObjectId of assetObjectIds ?? []) {
|
||||
const normalized = assetObjectId?.trim() ?? '';
|
||||
if (
|
||||
normalized &&
|
||||
!normalizedIds.some((current) => current === normalized)
|
||||
) {
|
||||
normalizedIds.push(normalized);
|
||||
}
|
||||
if (normalizedIds.length >= PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return normalizedIds;
|
||||
}
|
||||
|
||||
function addPuzzlePromptReferenceImage(
|
||||
@@ -256,6 +300,21 @@ export function PuzzleAgentWorkspace({
|
||||
),
|
||||
[formState.referenceImageSrc, formState.referenceImageSrcs],
|
||||
);
|
||||
const promptReferenceAssetObjectIds = useMemo(
|
||||
() =>
|
||||
formState.referenceImageSrc
|
||||
? []
|
||||
: normalizePuzzleAssetObjectIds(
|
||||
formState.referenceImageSrcs.map((image) => image.assetObjectId),
|
||||
),
|
||||
[formState.referenceImageSrc, formState.referenceImageSrcs],
|
||||
);
|
||||
const mainReferenceImageSrcForPayload =
|
||||
formState.referenceImageAssetObjectId && formState.aiRedraw
|
||||
? null
|
||||
: formState.referenceImageSrc || null;
|
||||
const promptReferenceImageSrcsForPayload =
|
||||
promptReferenceAssetObjectIds.length > 0 ? [] : promptReferenceImageSrcs;
|
||||
const canSubmit = formState.aiRedraw
|
||||
? Boolean(pictureDescription) && !isBusy
|
||||
: Boolean(formState.referenceImageSrc) && !isBusy;
|
||||
@@ -263,16 +322,21 @@ export function PuzzleAgentWorkspace({
|
||||
() => ({
|
||||
seedText: pictureDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
referenceImageSrcs: promptReferenceImageSrcs,
|
||||
referenceImageSrc: mainReferenceImageSrcForPayload,
|
||||
referenceImageSrcs: promptReferenceImageSrcsForPayload,
|
||||
referenceImageAssetObjectId:
|
||||
formState.referenceImageAssetObjectId || null,
|
||||
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
|
||||
imageModel: formState.imageModel,
|
||||
aiRedraw: formState.aiRedraw,
|
||||
}),
|
||||
[
|
||||
formState.aiRedraw,
|
||||
formState.referenceImageSrc,
|
||||
formState.referenceImageAssetObjectId,
|
||||
formState.imageModel,
|
||||
promptReferenceImageSrcs,
|
||||
mainReferenceImageSrcForPayload,
|
||||
promptReferenceAssetObjectIds,
|
||||
promptReferenceImageSrcsForPayload,
|
||||
pictureDescription,
|
||||
],
|
||||
);
|
||||
@@ -280,6 +344,8 @@ export function PuzzleAgentWorkspace({
|
||||
autosavePayload.pictureDescription,
|
||||
autosavePayload.referenceImageSrc,
|
||||
autosavePayload.referenceImageSrcs,
|
||||
autosavePayload.referenceImageAssetObjectId,
|
||||
autosavePayload.referenceImageAssetObjectIds,
|
||||
autosavePayload.aiRedraw,
|
||||
autosavePayload.imageModel,
|
||||
]);
|
||||
@@ -333,6 +399,7 @@ export function PuzzleAgentWorkspace({
|
||||
setCropState({
|
||||
source: uploadImage.dataUrl,
|
||||
label: file.name.trim() || '本地拼图图片',
|
||||
fileName: file.name.trim() || 'puzzle-reference.jpg',
|
||||
imageSize,
|
||||
cropRect: buildCenteredSquareImageCropRect(imageSize),
|
||||
error: null,
|
||||
@@ -342,9 +409,11 @@ export function PuzzleAgentWorkspace({
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = await puzzleAssetClient.uploadReferenceImage({ file });
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
referenceImageSrc: uploadImage.dataUrl,
|
||||
referenceImageSrc: asset.imageSrc || uploadImage.dataUrl,
|
||||
referenceImageAssetObjectId: asset.assetObjectId,
|
||||
referenceImageLabel: file.name.trim() || '本地拼图图片',
|
||||
}));
|
||||
setReferenceImageError(null);
|
||||
@@ -372,11 +441,18 @@ export function PuzzleAgentWorkspace({
|
||||
|
||||
try {
|
||||
const images = await Promise.all(
|
||||
files.slice(0, remainingSlots).map(async (file, index) => ({
|
||||
id: `prompt-upload:${Date.now()}:${index}:${file.name}`,
|
||||
label: file.name.trim() || `参考图 ${index + 1}`,
|
||||
imageSrc: await readPuzzleReferenceImageAsDataUrl(file),
|
||||
})),
|
||||
files.slice(0, remainingSlots).map(async (file, index) => {
|
||||
const [imageSrc, asset] = await Promise.all([
|
||||
readPuzzleReferenceImageAsDataUrl(file),
|
||||
puzzleAssetClient.uploadReferenceImage({ file }),
|
||||
]);
|
||||
return {
|
||||
id: `prompt-upload:${Date.now()}:${index}:${file.name}`,
|
||||
label: file.name.trim() || `参考图 ${index + 1}`,
|
||||
imageSrc: asset.imageSrc || imageSrc,
|
||||
assetObjectId: asset.assetObjectId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
@@ -439,9 +515,15 @@ export function PuzzleAgentWorkspace({
|
||||
cropY: currentCropState.cropRect.y,
|
||||
cropSize: currentCropState.cropRect.size,
|
||||
});
|
||||
const file = puzzleReferenceImageDataUrlToFile(
|
||||
dataUrl,
|
||||
currentCropState.fileName,
|
||||
);
|
||||
const asset = await puzzleAssetClient.uploadReferenceImage({ file });
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
referenceImageSrc: dataUrl,
|
||||
referenceImageSrc: asset.imageSrc || dataUrl,
|
||||
referenceImageAssetObjectId: asset.assetObjectId,
|
||||
referenceImageLabel: currentCropState.label,
|
||||
}));
|
||||
setCropState(null);
|
||||
@@ -482,8 +564,11 @@ export function PuzzleAgentWorkspace({
|
||||
const payload = {
|
||||
seedText: payloadPictureDescription,
|
||||
pictureDescription: payloadPictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
referenceImageSrcs: promptReferenceImageSrcs,
|
||||
referenceImageSrc: mainReferenceImageSrcForPayload,
|
||||
referenceImageSrcs: promptReferenceImageSrcsForPayload,
|
||||
referenceImageAssetObjectId:
|
||||
formState.referenceImageAssetObjectId || null,
|
||||
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
|
||||
imageModel: formState.imageModel,
|
||||
aiRedraw: formState.aiRedraw,
|
||||
};
|
||||
@@ -499,8 +584,11 @@ export function PuzzleAgentWorkspace({
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: payloadPictureDescription,
|
||||
pictureDescription: payloadPictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
referenceImageSrcs: promptReferenceImageSrcs,
|
||||
referenceImageSrc: mainReferenceImageSrcForPayload,
|
||||
referenceImageSrcs: promptReferenceImageSrcsForPayload,
|
||||
referenceImageAssetObjectId:
|
||||
formState.referenceImageAssetObjectId || null,
|
||||
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
|
||||
imageModel: formState.imageModel,
|
||||
aiRedraw: formState.aiRedraw,
|
||||
candidateCount: 1,
|
||||
@@ -510,6 +598,7 @@ export function PuzzleAgentWorkspace({
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
referenceImageSrc: '',
|
||||
referenceImageAssetObjectId: '',
|
||||
referenceImageLabel: '',
|
||||
aiRedraw: true,
|
||||
}));
|
||||
@@ -645,6 +734,7 @@ export function PuzzleAgentWorkspace({
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
referenceImageSrc: asset.imageSrc,
|
||||
referenceImageAssetObjectId: asset.assetObjectId,
|
||||
referenceImageLabel: getPuzzleHistoryAssetReferenceLabel(
|
||||
asset.imageSrc,
|
||||
),
|
||||
|
||||
@@ -13,6 +13,153 @@ export type PuzzleHistoryAsset = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PuzzleReferenceAsset = PuzzleHistoryAsset & {
|
||||
objectKey: string;
|
||||
};
|
||||
|
||||
type DirectUploadTicketResponse = {
|
||||
upload: {
|
||||
bucket: string;
|
||||
host: string;
|
||||
objectKey: string;
|
||||
legacyPublicPath: string;
|
||||
formFields: Record<string, string | null | undefined>;
|
||||
};
|
||||
};
|
||||
|
||||
type ConfirmAssetObjectResponse = {
|
||||
assetObject: {
|
||||
assetObjectId: string;
|
||||
objectKey: string;
|
||||
assetKind: 'puzzle_cover_image';
|
||||
ownerUserId?: string | null;
|
||||
profileId?: string | null;
|
||||
entityId?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
|
||||
|
||||
const MIME_BY_EXTENSION: Record<string, string> = {
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
};
|
||||
|
||||
function resolvePuzzleImageContentType(file: File) {
|
||||
if (file.type.trim()) {
|
||||
return file.type.trim();
|
||||
}
|
||||
|
||||
const extension = file.name.split('.').pop()?.trim().toLowerCase() ?? '';
|
||||
return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
function validatePuzzleReferenceImageFile(file: File) {
|
||||
const contentType = resolvePuzzleImageContentType(file);
|
||||
if (file.size <= 0) {
|
||||
throw new Error('参考图文件为空,请重新选择。');
|
||||
}
|
||||
if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) {
|
||||
throw new Error('参考图过大,请压缩后再上传。');
|
||||
}
|
||||
if (!contentType.startsWith('image/')) {
|
||||
throw new Error('参考图必须是图片文件。');
|
||||
}
|
||||
}
|
||||
|
||||
async function postDirectUploadFile(
|
||||
upload: DirectUploadTicketResponse['upload'],
|
||||
file: File,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
Object.entries(upload.formFields).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(upload.host, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('上传拼图参考图失败。');
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadPuzzleReferenceImage(payload: {
|
||||
file: File;
|
||||
}): Promise<PuzzleReferenceAsset> {
|
||||
validatePuzzleReferenceImageFile(payload.file);
|
||||
const contentType = resolvePuzzleImageContentType(payload.file);
|
||||
const uploadedAt = Date.now();
|
||||
const ticket = await requestJson<DirectUploadTicketResponse>(
|
||||
'/api/assets/direct-upload-tickets',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
legacyPrefix: 'generated-puzzle-assets',
|
||||
pathSegments: ['puzzle-reference', 'draft', `${uploadedAt}`],
|
||||
fileName: payload.file.name,
|
||||
contentType,
|
||||
access: 'private',
|
||||
maxSizeBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
|
||||
metadata: {
|
||||
asset_kind: 'puzzle_cover_image',
|
||||
puzzle_slot: 'reference_image',
|
||||
},
|
||||
}),
|
||||
},
|
||||
'创建拼图参考图上传凭证失败',
|
||||
);
|
||||
|
||||
await postDirectUploadFile(ticket.upload, payload.file);
|
||||
|
||||
const confirmed = await requestJson<ConfirmAssetObjectResponse>(
|
||||
'/api/assets/objects/confirm',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
bucket: ticket.upload.bucket,
|
||||
objectKey: ticket.upload.objectKey,
|
||||
contentType,
|
||||
contentLength: payload.file.size,
|
||||
assetKind: 'puzzle_cover_image',
|
||||
accessPolicy: 'private',
|
||||
}),
|
||||
},
|
||||
'确认拼图参考图失败',
|
||||
);
|
||||
|
||||
return {
|
||||
assetObjectId: confirmed.assetObject.assetObjectId,
|
||||
assetKind: confirmed.assetObject.assetKind,
|
||||
objectKey: confirmed.assetObject.objectKey,
|
||||
imageSrc: ticket.upload.legacyPublicPath,
|
||||
ownerUserId: confirmed.assetObject.ownerUserId,
|
||||
ownerLabel: confirmed.assetObject.ownerUserId
|
||||
? `账号 ${confirmed.assetObject.ownerUserId}`
|
||||
: '当前账号',
|
||||
profileId: confirmed.assetObject.profileId,
|
||||
entityId: confirmed.assetObject.entityId,
|
||||
createdAt: confirmed.assetObject.createdAt ?? '',
|
||||
updatedAt: confirmed.assetObject.updatedAt ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export const puzzleReferenceAssetTestUtils = {
|
||||
maxUploadBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
|
||||
validateFile: validatePuzzleReferenceImageFile,
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取历史拼图图片素材。结果页只把它们作为参考图来源,
|
||||
* 不直接替换当前正式图,正式图仍由后端单图生成链路写回。
|
||||
@@ -34,4 +181,5 @@ export async function listPuzzleHistoryAssets(payload: { limit?: number }) {
|
||||
|
||||
export const puzzleAssetClient = {
|
||||
listHistoryAssets: listPuzzleHistoryAssets,
|
||||
uploadReferenceImage: uploadPuzzleReferenceImage,
|
||||
};
|
||||
|
||||
@@ -238,3 +238,18 @@ export async function cropPuzzleReferenceImageDataUrl({
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function puzzleReferenceImageDataUrlToFile(
|
||||
dataUrl: string,
|
||||
fileName = 'puzzle-reference.jpg',
|
||||
) {
|
||||
const [metadata = '', encoded = ''] = dataUrl.split(',', 2);
|
||||
const mimeType =
|
||||
metadata.match(/^data:([^;]+);base64$/u)?.[1] ?? 'image/jpeg';
|
||||
const binary = atob(encoded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return new File([bytes], fileName, { type: mimeType });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user