From 15b17775cb71a0a9f749432622e8e80ee0389bdb Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Thu, 6 Aug 2026 11:31:47 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=20AI=20=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E9=A2=84=E8=A7=88=E4=B8=8E=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E9=A6=96=E5=B8=A7=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:生成图片需要支持大图查看和本地下载;制作视频时需要从当前项目作品选择首帧,或上传本地图片。 实现:新增首帧选择弹窗、JPEG/PNG/WebP 有界上传、附件 Asset ID 透传、Main 到服务端 multipart 转发,并保留上传失败重试与私有图片下载链路。 验证:相关 57 项单测、TypeScript 类型检查、目标 ESLint 和 Vite 生产构建全部通过。 --- electron/api/routes/image-workspace.ts | 203 ++++++++++ electron/image-workspace/module.ts | 3 + .../image-workspace/works-square-workspace.ts | 45 ++- shared/image-workspace.ts | 17 + src/lib/image-workspace.ts | 85 ++++- src/pages/ImageCanvas/index.tsx | 356 +++++++++++++++++- src/stores/image-workspace.ts | 24 +- tests/unit/image-canvas-page.test.tsx | 163 +++++++- tests/unit/image-workspace-api.test.ts | 79 ++++ tests/unit/image-workspace-route.test.ts | 165 +++++++- .../works-square-design-workspace.test.ts | 40 ++ 11 files changed, 1149 insertions(+), 31 deletions(-) diff --git a/electron/api/routes/image-workspace.ts b/electron/api/routes/image-workspace.ts index 4d5b1e2..6dac961 100644 --- a/electron/api/routes/image-workspace.ts +++ b/electron/api/routes/image-workspace.ts @@ -1,10 +1,16 @@ +import { createWriteStream } from 'node:fs'; +import { rename, rm } from 'node:fs/promises'; import type { IncomingMessage, ServerResponse } from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { basename, dirname, extname, join } from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; +import { app, dialog, type SaveDialogOptions } from 'electron'; import { IMAGE_WORKSPACE_API_PATH, IMAGE_WORKSPACE_UNAVAILABLE_CODE, IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE, + type DesignAssetUploadInput, type DesignConfirmGenerationInput, type DesignCreateWorkspaceInput, type DesignRenameWorkspaceInput, @@ -43,10 +49,172 @@ function asStringArray(value: unknown): string[] { : []; } +const DESIGN_IMAGE_UPLOAD_MIME_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', +]); +const MAX_DESIGN_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024; +const MAX_DESIGN_IMAGE_UPLOAD_BODY_BYTES = ( + Math.ceil(MAX_DESIGN_IMAGE_UPLOAD_BYTES / 3) * 4 + 16 * 1024 +); + +async function parseAssetUploadBody(req: IncomingMessage): Promise> { + const declaredLength = Number(req.headers['content-length']); + if (Number.isFinite(declaredLength) && declaredLength > MAX_DESIGN_IMAGE_UPLOAD_BODY_BYTES) { + throw new DesignWorkspaceModuleError( + 413, + 'design_asset_upload_too_large', + '图片大小需在 10 MB 以内', + ); + } + const chunks: Buffer[] = []; + let receivedBytes = 0; + for await (const chunk of req) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + receivedBytes += bytes.length; + if (receivedBytes > MAX_DESIGN_IMAGE_UPLOAD_BODY_BYTES) { + throw new DesignWorkspaceModuleError( + 413, + 'design_asset_upload_too_large', + '图片大小需在 10 MB 以内', + ); + } + chunks.push(bytes); + } + try { + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown; + if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error(); + return body as Record; + } catch { + throw new DesignWorkspaceModuleError( + 400, + 'design_asset_upload_invalid', + '上传图片请求无效', + ); + } +} + +function decodeAssetUpload( + workspaceId: string, + body: Record, +): DesignAssetUploadInput { + const fileName = basename(asString(body.fileName).trim().replace(/\\/g, '/')).slice(0, 160); + const mimeType = asString(body.mimeType); + const encoded = asString(body.dataBase64); + if (!fileName || !DESIGN_IMAGE_UPLOAD_MIME_TYPES.has(mimeType)) { + throw new DesignWorkspaceModuleError( + 400, + 'design_asset_upload_invalid', + '请选择 JPEG、PNG 或 WebP 图片', + ); + } + if (!encoded + || encoded.length % 4 !== 0 + || encoded.length > Math.ceil(MAX_DESIGN_IMAGE_UPLOAD_BYTES / 3) * 4 + || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) { + throw new DesignWorkspaceModuleError( + 413, + 'design_asset_upload_too_large', + '图片大小需在 10 MB 以内', + ); + } + const bytes = Buffer.from(encoded, 'base64'); + if (bytes.length <= 0 || bytes.length > MAX_DESIGN_IMAGE_UPLOAD_BYTES) { + throw new DesignWorkspaceModuleError( + 413, + 'design_asset_upload_too_large', + '图片大小需在 10 MB 以内', + ); + } + return { + workspaceId, + fileName, + mimeType: mimeType as DesignAssetUploadInput['mimeType'], + bytes, + }; +} + function sendData(res: ServerResponse, data: unknown): void { sendJson(res, 200, { success: true, status: 200, data }); } +const IMAGE_FILE_EXTENSIONS = new Set(['.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp']); + +function safeImageFileName(value: string, assetId: string): string { + const fallbackId = assetId + .replace(/[^a-zA-Z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 32) || 'image'; + const fallback = `Makelore-AI-Design-${fallbackId}.png`; + const sanitized = [...value.trim().replace(/[<>:"/\\|?*]/g, '-')] + .map((character) => character.charCodeAt(0) < 32 ? '-' : character) + .join('') + .replace(/[. ]+$/g, '') + .slice(0, 120); + if (!sanitized || !IMAGE_FILE_EXTENSIONS.has(extname(sanitized).toLowerCase())) { + return fallback; + } + return sanitized; +} + +async function saveAssetContent( + res: ServerResponse, + ctx: HostApiContext, + workspaceId: string, + assetId: string, + defaultFileName: string, +): Promise { + const fileName = safeImageFileName(defaultFileName, assetId); + const extension = extname(fileName).slice(1); + const options: SaveDialogOptions = { + defaultPath: join(app.getPath('downloads'), fileName), + filters: [ + { name: '图片', extensions: [extension] }, + { name: '所有文件', extensions: ['*'] }, + ], + }; + const mainWindow = ctx.mainWindow && !ctx.mainWindow.isDestroyed() + ? ctx.mainWindow + : null; + const selection = mainWindow + ? await dialog.showSaveDialog(mainWindow, options) + : await dialog.showSaveDialog(options); + if (selection.canceled || !selection.filePath) { + sendData(res, { status: 'cancelled' }); + return; + } + + const content = await ctx.imageWorkspace!.openAssetContent(workspaceId, assetId); + if (!content.ok || !content.body || !content.headers.get('content-type')?.startsWith('image/')) { + throw new DesignWorkspaceModuleError( + content.status >= 400 ? content.status : 502, + 'DESIGN_ASSET_DOWNLOAD_FAILED', + '生成图片暂时无法下载,请稍后重试', + ); + } + + const temporaryPath = join( + dirname(selection.filePath), + `.${basename(selection.filePath)}.${randomUUID()}.download`, + ); + try { + await pipeline( + Readable.fromWeb(content.body), + createWriteStream(temporaryPath, { flags: 'wx' }), + ); + await rename(temporaryPath, selection.filePath); + } catch { + await rm(temporaryPath, { force: true }).catch(() => undefined); + throw new DesignWorkspaceModuleError( + 500, + 'DESIGN_ASSET_SAVE_FAILED', + '图片保存失败,请重新选择位置后重试', + ); + } + sendData(res, { status: 'saved' }); +} + function sendRouteError(res: ServerResponse, error: unknown): void { if (res.headersSent) { res.destroy(error instanceof Error ? error : undefined); @@ -253,6 +421,25 @@ export async function handleImageWorkspaceRoutes( return true; } + if (segments.length === 3 + && segments[0] === 'workspaces' + && segments[2] === 'assets' + && req.method === 'POST') { + if (!ctx.imageWorkspace.uploadAsset) { + throw new DesignWorkspaceModuleError( + 501, + IMAGE_WORKSPACE_UNAVAILABLE_CODE, + '当前环境暂不支持上传设计图片', + ); + } + const body = await parseAssetUploadBody(req); + sendData( + res, + await ctx.imageWorkspace.uploadAsset(decodeAssetUpload(segments[1], body)), + ); + return true; + } + if (segments.length === 3 && segments[0] === 'workspaces' && segments[2] === 'events' @@ -286,6 +473,22 @@ export async function handleImageWorkspaceRoutes( return true; } + if (segments.length === 5 + && segments[0] === 'workspaces' + && segments[2] === 'assets' + && segments[4] === 'download' + && req.method === 'POST') { + const body = await parseJsonBody>(req); + await saveAssetContent( + res, + ctx, + segments[1], + segments[3], + asString(body.defaultFileName), + ); + return true; + } + sendJson(res, 404, { success: false, status: 404, diff --git a/electron/image-workspace/module.ts b/electron/image-workspace/module.ts index fbc2c00..098dc60 100644 --- a/electron/image-workspace/module.ts +++ b/electron/image-workspace/module.ts @@ -1,4 +1,6 @@ import type { + DesignAsset, + DesignAssetUploadInput, DesignCapabilities, DesignConfirmGenerationInput, DesignCreateWorkspaceInput, @@ -41,6 +43,7 @@ export interface DesignWorkspaceModule { submitMessage(input: DesignSubmitMessageInput): Promise; confirmGeneration(input: DesignConfirmGenerationInput): Promise; listTasks(workspaceId: string): Promise; + uploadAsset?(input: DesignAssetUploadInput): Promise; openWorkspaceEvents?( input: DesignWorkspaceEventSubscriptionInput, ): Promise; diff --git a/electron/image-workspace/works-square-workspace.ts b/electron/image-workspace/works-square-workspace.ts index 80ee307..d7fabbd 100644 --- a/electron/image-workspace/works-square-workspace.ts +++ b/electron/image-workspace/works-square-workspace.ts @@ -1,5 +1,6 @@ import type { DesignAsset, + DesignAssetUploadInput, DesignAssistantDeltaEvent, DesignBrief, DesignCapabilities, @@ -266,22 +267,26 @@ function mapTask(task: ServerTask): DesignGenerationTask { quoteId: task.quote_id, quotedDesignPoints: task.quoted_design_points, failureCode: task.failure_code, - resultAssets: task.result_assets.map((asset) => ({ - assetId: asset.asset_id, - workspaceId: task.workspace_id, - mediaType: asset.media_type, - mimeType: asset.mime_type, - width: asset.width, - height: asset.height, - durationMilliseconds: asset.duration_milliseconds, - createdAt: asset.created_at, - contentPath: designAssetContentPath(task.workspace_id, asset.asset_id), - })), + resultAssets: task.result_assets.map((asset) => mapAsset(task.workspace_id, asset)), createdAt: task.created_at, updatedAt: task.updated_at, }; } +function mapAsset(workspaceId: string, asset: ServerAsset): DesignAsset { + return { + assetId: asset.asset_id, + workspaceId, + mediaType: asset.media_type, + mimeType: asset.mime_type, + width: asset.width, + height: asset.height, + durationMilliseconds: asset.duration_milliseconds, + createdAt: asset.created_at, + contentPath: designAssetContentPath(workspaceId, asset.asset_id), + }; +} + function createClientId(prefix: string): string { const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`; @@ -913,6 +918,20 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { return tasks.map(mapTask); } + async uploadAsset(input: DesignAssetUploadInput): Promise { + const form = new FormData(); + form.set( + 'file', + new Blob([Uint8Array.from(input.bytes)], { type: input.mimeType }), + input.fileName, + ); + const asset = await this.requestJson( + `/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/assets`, + { method: 'POST', body: form }, + ); + return mapAsset(input.workspaceId, asset); + } + async openWorkspaceEvents( input: DesignWorkspaceEventSubscriptionInput, ): Promise { @@ -1141,7 +1160,9 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { ...init, headers: { Accept: 'application/json', - ...(init.body ? { 'Content-Type': 'application/json' } : {}), + ...(init.body && !(init.body instanceof FormData) + ? { 'Content-Type': 'application/json' } + : {}), ...(init.headers ?? {}), }, }); diff --git a/shared/image-workspace.ts b/shared/image-workspace.ts index aca98c7..27fb7e1 100644 --- a/shared/image-workspace.ts +++ b/shared/image-workspace.ts @@ -70,6 +70,19 @@ export type DesignAsset = { contentPath: string; }; +export type DesignAssetSaveResult = { + status: 'saved' | 'cancelled'; +}; + +export type DesignImageUploadMimeType = 'image/jpeg' | 'image/png' | 'image/webp'; + +export type DesignAssetUploadInput = { + workspaceId: string; + fileName: string; + mimeType: DesignImageUploadMimeType; + bytes: Uint8Array; +}; + export type DesignGenerationTask = { taskId: string; workspaceId: string; @@ -150,3 +163,7 @@ export type DesignConfirmGenerationInput = { export function designAssetContentPath(workspaceId: string, assetId: string): string { return `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(assetId)}/content`; } + +export function designAssetDownloadPath(workspaceId: string, assetId: string): string { + return `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(assetId)}/download`; +} diff --git a/src/lib/image-workspace.ts b/src/lib/image-workspace.ts index 8e97a2b..e33ab59 100644 --- a/src/lib/image-workspace.ts +++ b/src/lib/image-workspace.ts @@ -9,6 +9,9 @@ import { IMAGE_WORKSPACE_API_PATH, IMAGE_WORKSPACE_UNAVAILABLE_CODE, IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE, + designAssetDownloadPath, + type DesignAsset, + type DesignAssetSaveResult, type DesignGenerationTask, type DesignWorkspace, type DesignWorkspaceBootstrap, @@ -125,6 +128,7 @@ export function sendImageWorkspaceMessage( expectedTurnRevision: number, message: string, clientTurnId = createImageWorkspaceTurnId(), + attachmentAssetIds: string[] = [], ): Promise { return requestData( `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/messages`, @@ -134,7 +138,7 @@ export function sendImageWorkspaceMessage( clientTurnId, expectedTurnRevision, message: message.trim(), - attachmentAssetIds: [], + attachmentAssetIds, }), }, ); @@ -178,3 +182,82 @@ export async function resolveImageWorkspaceAssetUrl(contentPath: string): Promis const separator = contentPath.includes('?') ? '&' : '?'; return `${getHostApiBase()}${contentPath}${separator}token=${encodeURIComponent(token)}`; } + +const IMAGE_FILE_EXTENSIONS: Record = { + 'image/gif': 'gif', + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/svg+xml': 'svg', + 'image/webp': 'webp', +}; + +function assetDownloadFileName(asset: DesignAsset): string { + const safeAssetId = asset.assetId + .replace(/[^a-zA-Z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 32) || 'image'; + const extension = IMAGE_FILE_EXTENSIONS[asset.mimeType.toLowerCase()] ?? 'png'; + return `Makelore-AI-Design-${safeAssetId}.${extension}`; +} + +export function saveImageWorkspaceAsset(asset: DesignAsset): Promise { + return requestData( + designAssetDownloadPath(asset.workspaceId, asset.assetId), + { + method: 'POST', + body: JSON.stringify({ defaultFileName: assetDownloadFileName(asset) }), + }, + ); +} + +const DESIGN_IMAGE_UPLOAD_MIME_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', +]); +const MAX_DESIGN_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024; + +async function fileToBase64(file: File): Promise { + const bytes = new Uint8Array(await file.arrayBuffer()); + let encoded = ''; + for (let offset = 0; offset < bytes.length; offset += 48 * 1024) { + const chunk = bytes.subarray(offset, offset + 48 * 1024); + let binary = ''; + for (let index = 0; index < chunk.length; index += 0x8000) { + binary += String.fromCharCode(...chunk.subarray(index, index + 0x8000)); + } + encoded += btoa(binary); + } + return encoded; +} + +export async function uploadImageWorkspaceAsset( + workspaceId: string, + file: File, +): Promise { + if (!DESIGN_IMAGE_UPLOAD_MIME_TYPES.has(file.type)) { + throw new ImageWorkspaceApiError( + 400, + 'design_asset_upload_type_invalid', + '请选择 JPEG、PNG 或 WebP 图片', + ); + } + if (file.size <= 0 || file.size > MAX_DESIGN_IMAGE_UPLOAD_BYTES) { + throw new ImageWorkspaceApiError( + 413, + 'design_asset_upload_too_large', + '图片大小需在 10 MB 以内', + ); + } + return requestData( + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/assets`, + { + method: 'POST', + body: JSON.stringify({ + fileName: file.name, + mimeType: file.type, + dataBase64: await fileToBase64(file), + }), + }, + ); +} diff --git a/src/pages/ImageCanvas/index.tsx b/src/pages/ImageCanvas/index.tsx index 83f1c5d..64d5cef 100644 --- a/src/pages/ImageCanvas/index.tsx +++ b/src/pages/ImageCanvas/index.tsx @@ -3,6 +3,7 @@ import { useMemo, useRef, useState, + type ChangeEvent, type KeyboardEvent, } from 'react'; import { @@ -10,20 +11,35 @@ import { Check, Clock3, Cloud, + Download, Film, ImageIcon, Loader2, + Maximize2, Plus, RefreshCw, Send, Sparkles, + Upload, WandSparkles, + X, } from 'lucide-react'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { Textarea } from '@/components/ui/textarea'; import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, resolveImageWorkspaceAssetUrl, + saveImageWorkspaceAsset, + uploadImageWorkspaceAsset, } from '@/lib/image-workspace'; import { cn } from '@/lib/utils'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; @@ -60,6 +76,7 @@ const GENERATION_CONFIRMATION_INTENTS = new Set([ '确认开始制作', '确认并开始制作', ]); +const VIDEO_FIRST_FRAME_PICKER_REPLY = '从作品列表选择图片'; function isGenerationConfirmationIntent(message: string): boolean { const normalized = message.trim().replace(/[\s,,。.!!??、]/g, ''); @@ -94,6 +111,8 @@ type RenderedDesignMessage = DesignMessage & { streaming?: boolean }; function AssetPreview({ asset }: { asset: DesignAsset }) { const [url, setUrl] = useState(null); + const [previewOpen, setPreviewOpen] = useState(false); + const [downloading, setDownloading] = useState(false); useEffect(() => { let cancelled = false; @@ -124,12 +143,172 @@ function AssetPreview({ asset }: { asset: DesignAsset }) { ); } + const download = async () => { + if (downloading) return; + setDownloading(true); + try { + const result = await saveImageWorkspaceAsset(asset); + if (result.status === 'saved') { + toast.success('图片已保存到本地'); + } + } catch (error) { + toast.error('图片下载失败', { + description: error instanceof Error ? error.message : String(error), + }); + } finally { + setDownloading(false); + } + }; + return ( - AI 设计生成结果 + <> +
+ +
+ + +
+
+ + + + +
+ 图片预览 + + {asset.width} × {asset.height} · 原始生成结果 + +
+
+ + + + +
+
+
+ AI 设计大图预览 +
+
+
+ + ); +} + +function FirstFrameAssetOption({ + asset, + busy, + onSelect, +}: { + asset: DesignAsset; + busy: boolean; + onSelect(): void; +}) { + const [url, setUrl] = useState(null); + + useEffect(() => { + let cancelled = false; + void resolveImageWorkspaceAssetUrl(asset.contentPath) + .then((nextUrl) => { + if (!cancelled) setUrl(nextUrl); + }) + .catch(() => { + if (!cancelled) setUrl(null); + }); + return () => { + cancelled = true; + }; + }, [asset.contentPath]); + + return ( +
+
+ {url ? ( + 可选作品图片 + ) : } +
+
+ + {asset.width} × {asset.height} + + +
+
); } @@ -237,6 +416,10 @@ export function ImageCanvas() { const [submitting, setSubmitting] = useState(false); const [confirmingQuoteId, setConfirmingQuoteId] = useState(null); const [actionError, setActionError] = useState(null); + const [firstFramePickerOpen, setFirstFramePickerOpen] = useState(false); + const [firstFrameBusyKey, setFirstFrameBusyKey] = useState(null); + const [firstFrameError, setFirstFrameError] = useState(null); + const [uploadedFirstFrameAsset, setUploadedFirstFrameAsset] = useState(null); const conversationEndRef = useRef(null); const quote = useMemo( @@ -244,6 +427,20 @@ export function ImageCanvas() { [workspace], ); const generationAvailable = bootstrap?.capabilities.generation ?? false; + const firstFrameAssets = useMemo( + () => { + const assets = tasks + .filter((task) => task.status === 'succeeded') + .flatMap((task) => task.resultAssets) + .filter((asset) => asset.mediaType === 'image' && asset.mimeType === 'image/png'); + if (uploadedFirstFrameAsset + && uploadedFirstFrameAsset.workspaceId === workspace?.workspaceId) { + assets.unshift(uploadedFirstFrameAsset); + } + return [...new Map(assets.map((asset) => [asset.assetId, asset])).values()]; + }, + [tasks, uploadedFirstFrameAsset, workspace?.workspaceId], + ); const conversationMessages = useMemo(() => { if (!workspace) return []; const messages: RenderedDesignMessage[] = [...workspace.messages]; @@ -345,6 +542,52 @@ export function ImageCanvas() { } }; + const handleFirstFrameSelect = async (asset: DesignAsset) => { + if (!workspace || submitting || firstFrameBusyKey) return; + const requestedWorkspaceId = workspace.workspaceId; + setFirstFrameBusyKey(asset.assetId); + setFirstFrameError(null); + setSubmitting(true); + try { + await sendMessage('使用作品图片作为视频首帧', [asset.assetId]); + if (useImageWorkspaceStore.getState().activeWorkspaceId === requestedWorkspaceId) { + setFirstFramePickerOpen(false); + } + } catch (error) { + if (useImageWorkspaceStore.getState().activeWorkspaceId === requestedWorkspaceId) { + setFirstFrameError(error instanceof Error ? error.message : String(error)); + } + } finally { + setFirstFrameBusyKey(null); + setSubmitting(false); + } + }; + + const handleFirstFrameUpload = async (event: ChangeEvent) => { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ''; + if (!workspace || !file || submitting || firstFrameBusyKey) return; + const requestedWorkspaceId = workspace.workspaceId; + setFirstFrameBusyKey('upload'); + setFirstFrameError(null); + setSubmitting(true); + try { + const asset = await uploadImageWorkspaceAsset(requestedWorkspaceId, file); + setUploadedFirstFrameAsset(asset); + await sendMessage('使用上传的图片作为视频首帧', [asset.assetId]); + if (useImageWorkspaceStore.getState().activeWorkspaceId === requestedWorkspaceId) { + setFirstFramePickerOpen(false); + } + } catch (error) { + if (useImageWorkspaceStore.getState().activeWorkspaceId === requestedWorkspaceId) { + setFirstFrameError(error instanceof Error ? error.message : String(error)); + } + } finally { + setFirstFrameBusyKey(null); + setSubmitting(false); + } + }; + const openCreateProject = () => { window.dispatchEvent(new Event(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT)); }; @@ -513,9 +756,14 @@ export function ImageCanvas() { type="button" variant="outline" size="sm" - className="h-8 rounded-full px-3 text-xs font-semibold" - onClick={() => { - if (!isGenerationConfirmationIntent(reply)) { + className="h-8 rounded-full px-3 text-xs font-semibold" + onClick={() => { + if (reply === VIDEO_FIRST_FRAME_PICKER_REPLY) { + setFirstFrameError(null); + setFirstFramePickerOpen(true); + return; + } + if (!isGenerationConfirmationIntent(reply)) { setPrompt(reply); return; } @@ -606,6 +854,98 @@ export function ImageCanvas() { + + { + if (!open && firstFrameBusyKey) return; + setFirstFramePickerOpen(open); + if (!open) setFirstFrameError(null); + }} + > + + +
+ 选择视频首帧 + + 从本项目作品中选择一张已完成图片,或上传本地图片作为视频起始画面。 + +
+ + + +
+ +
+
+
+

本项目作品

+

+ 仅展示已成功生成的 PNG 图片 +

+
+ +
+ + {firstFrameError ? ( +

+ {firstFrameError} +

+ ) : null} + + {firstFrameAssets.length > 0 ? ( +
+ {firstFrameAssets.map((asset) => ( + void handleFirstFrameSelect(asset)} + /> + ))} +
+ ) : ( +
+ +

本项目还没有可用图片

+

+ 可以先生成一张图片,或直接上传本地图片。 +

+
+ )} +
+
+
); } diff --git a/src/stores/image-workspace.ts b/src/stores/image-workspace.ts index f48387a..2392167 100644 --- a/src/stores/image-workspace.ts +++ b/src/stores/image-workspace.ts @@ -60,7 +60,7 @@ type ImageWorkspaceState = { refreshTasks: () => Promise; connectTaskStream: () => void; disconnectTaskStream: () => void; - sendMessage: (message: string) => Promise; + sendMessage: (message: string, attachmentAssetIds?: string[]) => Promise; confirmGeneration: (quoteId: string) => Promise; reset: () => void; }; @@ -635,19 +635,27 @@ export const useImageWorkspaceStore = create((set, get) => disconnectTaskStream: stopTaskStream, - sendMessage: async (message) => { + sendMessage: async (message, attachmentAssetIds = []) => { const workspace = get().workspace; const userText = message.trim(); const clientTurnId = createImageWorkspaceTurnId(); if (!workspace) throw new Error('请先选择设计项目'); try { set({ pendingTurn: createPendingTurn(workspace, clientTurnId, userText), error: null }); - const updated = await sendImageWorkspaceMessage( - workspace.workspaceId, - workspace.turnRevision, - userText, - clientTurnId, - ); + const updated = attachmentAssetIds.length > 0 + ? await sendImageWorkspaceMessage( + workspace.workspaceId, + workspace.turnRevision, + userText, + clientTurnId, + attachmentAssetIds, + ) + : await sendImageWorkspaceMessage( + workspace.workspaceId, + workspace.turnRevision, + userText, + clientTurnId, + ); if (get().activeWorkspaceId === workspace.workspaceId) { applyWorkspace(updated); await get().refreshTasks().catch(() => []); diff --git a/tests/unit/image-canvas-page.test.tsx b/tests/unit/image-canvas-page.test.tsx index ffea325..688f1de 100644 --- a/tests/unit/image-canvas-page.test.tsx +++ b/tests/unit/image-canvas-page.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ImageWorkspaceApiError } from '@/lib/image-workspace'; @@ -20,6 +20,8 @@ const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn()); const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn()); const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn()); const resolveImageWorkspaceAssetUrlMock = vi.hoisted(() => vi.fn()); +const saveImageWorkspaceAssetMock = vi.hoisted(() => vi.fn()); +const uploadImageWorkspaceAssetMock = vi.hoisted(() => vi.fn()); type EventListener = (event: MessageEvent) => void; @@ -66,6 +68,12 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => { resolveImageWorkspaceAssetUrl: (...args: unknown[]) => ( resolveImageWorkspaceAssetUrlMock(...args) ), + saveImageWorkspaceAsset: (...args: unknown[]) => ( + saveImageWorkspaceAssetMock(...args) + ), + uploadImageWorkspaceAsset: (...args: unknown[]) => ( + uploadImageWorkspaceAssetMock(...args) + ), }; }); @@ -177,6 +185,11 @@ describe('ImageCanvas Workspace-first design experience', () => { resolveImageWorkspaceAssetUrlMock.mockResolvedValue( 'http://127.0.0.1:13210/content?token=host', ); + saveImageWorkspaceAssetMock.mockResolvedValue({ status: 'saved' }); + uploadImageWorkspaceAssetMock.mockResolvedValue({ + ...taskFixture.resultAssets[0], + assetId: 'asset-uploaded', + }); }); it('shows an honest unavailable state without fabricating projects', async () => { @@ -260,6 +273,42 @@ describe('ImageCanvas Workspace-first design experience', () => { expect(screen.queryByText(/当前 Agent/)).not.toBeInTheDocument(); }); + it('opens a generated image at full size and saves it through Main', async () => { + render(); + + fireEvent.click(await screen.findByRole('button', { name: '放大查看生成图片' })); + + const dialog = screen.getByRole('dialog', { name: '图片预览' }); + expect(dialog).toHaveTextContent('1024 × 1024'); + expect(screen.getByRole('img', { name: 'AI 设计大图预览' })) + .toHaveAttribute('src', 'http://127.0.0.1:13210/content?token=host'); + + fireEvent.click(screen.getByRole('button', { name: '下载原图' })); + + await waitFor(() => expect(saveImageWorkspaceAssetMock).toHaveBeenCalledWith( + taskFixture.resultAssets[0], + )); + }); + + it('announces the image download progress while Main is saving', async () => { + let finishSaving: ((value: { status: 'saved' }) => void) | undefined; + saveImageWorkspaceAssetMock.mockReturnValue(new Promise((resolve) => { + finishSaving = resolve; + })); + render(); + + fireEvent.click(await screen.findByRole('button', { name: '下载生成图片' })); + + const savingButton = screen.getByRole('button', { name: '正在保存生成图片' }); + expect(savingButton).toBeDisabled(); + expect(savingButton).toHaveAttribute('aria-busy', 'true'); + + finishSaving?.({ status: 'saved' }); + await waitFor(() => expect( + screen.getByRole('button', { name: '下载生成图片' }), + ).toBeEnabled()); + }); + it('keeps the task list visible in a 1404px desktop workspace', async () => { const previousWidth = window.innerWidth; Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1404 }); @@ -482,6 +531,118 @@ describe('ImageCanvas Workspace-first design experience', () => { expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled(); }); + it('opens the successful work picker when the Agent asks for a video first frame', async () => { + fetchImageWorkspaceProjectMock.mockResolvedValueOnce({ + ...workspaceFixture(), + phase: 'shaping', + brief: { + version: 2, + status: 'draft', + medium: 'video', + summary: '让海洋公益海报动起来', + ready: false, + missingDecision: '请选择首帧来源', + }, + messages: [ + ...workspaceFixture().messages, + { + id: 'message-video-source', + role: 'assistant', + kind: 'choice', + text: '请选择视频首帧来源。', + quickReplies: ['先生成一张首帧图片(推荐)', '从作品列表选择图片'], + generationQuote: null, + turnRevision: 2, + createdAt: '2026-08-06T09:00:00Z', + }, + ], + } satisfies DesignWorkspace); + render(); + + fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' })); + + const picker = screen.getByRole('dialog', { name: '选择视频首帧' }); + expect(await within(picker).findByRole('img', { name: '可选作品图片' })) + .toHaveAttribute('src', 'http://127.0.0.1:13210/content?token=host'); + expect(within(picker).getByRole('button', { name: '选择此图片' })).toBeEnabled(); + expect(within(picker).getByLabelText('上传本地图片')).toBeInTheDocument(); + expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled(); + }); + + it('submits a successful work Asset id when it is selected as the video first frame', async () => { + fetchImageWorkspaceProjectMock.mockResolvedValueOnce({ + ...workspaceFixture(), + turnRevision: 2, + messages: [ + ...workspaceFixture().messages, + { + id: 'message-video-source', + role: 'assistant', + kind: 'choice', + text: '请选择视频首帧来源。', + quickReplies: ['从作品列表选择图片'], + generationQuote: null, + turnRevision: 2, + createdAt: '2026-08-06T09:00:00Z', + }, + ], + } satisfies DesignWorkspace); + render(); + fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' })); + + const selectButton = within( + screen.getByRole('dialog', { name: '选择视频首帧' }), + ).getByRole('button', { name: '选择此图片' }); + await waitFor(() => expect(selectButton).toBeEnabled()); + fireEvent.click(selectButton); + + await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( + 'workspace-cloud', + 2, + '使用作品图片作为视频首帧', + expect.stringMatching(/^turn-/), + ['asset-one'], + )); + }); + + it('uploads a local first-frame image and submits its Asset id to the Agent', async () => { + fetchImageWorkspaceProjectMock.mockResolvedValueOnce({ + ...workspaceFixture(), + messages: [ + ...workspaceFixture().messages, + { + id: 'message-video-source', + role: 'assistant', + kind: 'choice', + text: '请选择视频首帧来源。', + quickReplies: ['从作品列表选择图片'], + generationQuote: null, + turnRevision: 2, + createdAt: '2026-08-06T09:00:00Z', + }, + ], + turnRevision: 2, + } satisfies DesignWorkspace); + render(); + fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' })); + const picker = screen.getByRole('dialog', { name: '选择视频首帧' }); + const file = new File(['image-bytes'], 'ocean-poster.png', { type: 'image/png' }); + + fireEvent.change(within(picker).getByLabelText('上传本地图片'), { + target: { files: [file] }, + }); + + await waitFor(() => expect(uploadImageWorkspaceAssetMock) + .toHaveBeenCalledWith('workspace-cloud', file)); + await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( + 'workspace-cloud', + 2, + '使用上传的图片作为视频首帧', + expect.stringMatching(/^turn-/), + ['asset-uploaded'], + )); + }); + it('renders policy-blocked generation tasks with a user-facing design message', async () => { fetchImageWorkspaceTasksMock.mockResolvedValue([{ ...taskFixture, diff --git a/tests/unit/image-workspace-api.test.ts b/tests/unit/image-workspace-api.test.ts index 1827e09..709d7a6 100644 --- a/tests/unit/image-workspace-api.test.ts +++ b/tests/unit/image-workspace-api.test.ts @@ -13,7 +13,9 @@ import { ImageWorkspaceApiError, openImageWorkspaceTaskEvents, resolveImageWorkspaceAssetUrl, + saveImageWorkspaceAsset, sendImageWorkspaceMessage, + uploadImageWorkspaceAsset, } from '@/lib/image-workspace'; vi.mock('@/lib/host-api', () => ({ @@ -99,6 +101,52 @@ describe('AI design renderer API boundary', () => { expect(String(init?.body)).not.toMatch(/model|resolution|outputCount/); }); + it('sends the selected first-frame Asset id with the conversation turn', async () => { + await sendImageWorkspaceMessage( + 'workspace-one', + 5, + '使用作品图片作为视频首帧', + 'turn-first-frame', + ['asset-first-frame'], + ); + + const [, init] = hostApiFetchMock.mock.calls[0]; + expect(JSON.parse(String(init?.body))).toMatchObject({ + clientTurnId: 'turn-first-frame', + expectedTurnRevision: 5, + attachmentAssetIds: ['asset-first-frame'], + }); + }); + + it('uploads a supported local image through the authenticated Main boundary', async () => { + const asset = { + assetId: 'asset-uploaded', + workspaceId: 'workspace-one', + mediaType: 'image' as const, + mimeType: 'image/png', + width: 1200, + height: 800, + durationMilliseconds: null, + createdAt: '2026-08-06T10:00:00Z', + contentPath: '/api/works/image-workspace/workspaces/workspace-one/assets/asset-uploaded/content', + }; + hostApiFetchMock.mockResolvedValueOnce({ success: true, status: 200, data: asset }); + const file = new File([new Uint8Array([1, 2, 3])], 'poster.webp', { + type: 'image/webp', + }); + + await expect(uploadImageWorkspaceAsset('workspace-one', file)).resolves.toEqual(asset); + + const [path, init] = hostApiFetchMock.mock.calls[0]; + expect(path).toBe('/api/works/image-workspace/workspaces/workspace-one/assets'); + expect(init).toMatchObject({ method: 'POST' }); + expect(JSON.parse(String(init?.body))).toEqual({ + fileName: 'poster.webp', + mimeType: 'image/webp', + dataBase64: 'AQID', + }); + }); + it('confirms a Quote through a separate structured action route', async () => { await confirmImageWorkspaceGeneration('workspace-one', 5, 'quote/one'); @@ -132,4 +180,35 @@ describe('AI design renderer API boundary', () => { 'http://127.0.0.1:13210/api/works/image-workspace/assets/one?token=host-token', ); }); + + it('asks Main to save a private image asset without exposing its cloud URL', async () => { + hostApiFetchMock.mockResolvedValueOnce({ + success: true, + status: 200, + data: { status: 'saved' }, + }); + + await expect(saveImageWorkspaceAsset({ + assetId: 'asset/one', + workspaceId: 'workspace/one', + mediaType: 'image', + mimeType: 'image/png', + width: 1024, + height: 1024, + durationMilliseconds: null, + createdAt: '2026-08-05T12:00:00Z', + contentPath: '/api/works/image-workspace/private/content', + })).resolves.toEqual({ status: 'saved' }); + + expect(hostApiFetchMock).toHaveBeenCalledWith( + '/api/works/image-workspace/workspaces/workspace%2Fone/assets/asset%2Fone/download', + { + method: 'POST', + body: JSON.stringify({ + defaultFileName: 'Makelore-AI-Design-asset-one.png', + }), + }, + ); + expect(JSON.stringify(hostApiFetchMock.mock.calls[0])).not.toContain('private/content'); + }); }); diff --git a/tests/unit/image-workspace-route.test.ts b/tests/unit/image-workspace-route.test.ts index bfe072d..cbe4f72 100644 --- a/tests/unit/image-workspace-route.test.ts +++ b/tests/unit/image-workspace-route.test.ts @@ -1,9 +1,22 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { Readable, Writable } from 'node:stream'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { handleImageWorkspaceRoutes } from '@electron/api/routes/image-workspace'; import type { HostApiContext } from '@electron/api/context'; +const electronMocks = vi.hoisted(() => ({ + getPath: vi.fn(), + showSaveDialog: vi.fn(), +})); + +vi.mock('electron', () => ({ + app: { getPath: electronMocks.getPath }, + dialog: { showSaveDialog: electronMocks.showSaveDialog }, +})); + function createResponse() { const chunks: string[] = []; const res = { @@ -68,6 +81,21 @@ const bootstrap = { }; describe('AI design Main route boundary', () => { + let temporaryDirectory: string | null = null; + + beforeEach(() => { + electronMocks.getPath.mockReset(); + electronMocks.getPath.mockReturnValue(tmpdir()); + electronMocks.showSaveDialog.mockReset(); + }); + + afterEach(async () => { + if (temporaryDirectory) { + await rm(temporaryDirectory, { recursive: true, force: true }); + temporaryDirectory = null; + } + }); + it('does not claim unrelated routes', async () => { const response = createResponse(); const handled = await handleImageWorkspaceRoutes( @@ -107,6 +135,7 @@ describe('AI design Main route boundary', () => { submitMessage: vi.fn().mockResolvedValue({ workspaceId: 'workspace/one' }), confirmGeneration: vi.fn().mockResolvedValue({ workspaceId: 'workspace/one' }), listTasks: vi.fn().mockResolvedValue([]), + uploadAsset: vi.fn().mockResolvedValue({ assetId: 'asset-uploaded' }), openAssetContent: vi.fn(), getCapabilities: vi.fn(), reset: vi.fn().mockResolvedValue(bootstrap), @@ -160,6 +189,30 @@ describe('AI design Main route boundary', () => { attachmentAssetIds: [], }); + const uploadResponse = createResponse(); + await handleImageWorkspaceRoutes( + createRequest('POST', { + fileName: 'poster.webp', + mimeType: 'image/webp', + dataBase64: 'AQID', + }), + uploadResponse.res, + new URL( + 'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/assets', + ), + ctx, + ); + expect(workspace.uploadAsset).toHaveBeenCalledWith({ + workspaceId: 'workspace/one', + fileName: 'poster.webp', + mimeType: 'image/webp', + bytes: Buffer.from([1, 2, 3]), + }); + expect(uploadResponse.json()).toMatchObject({ + success: true, + data: { assetId: 'asset-uploaded' }, + }); + const confirmResponse = createResponse(); await handleImageWorkspaceRoutes( createRequest('POST', { @@ -216,6 +269,116 @@ describe('AI design Main route boundary', () => { expect(Buffer.concat(response.chunks).toString()).toBe('partial'); }); + it('streams a private image asset into the native save location', async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-design-download-')); + const savedPath = join(temporaryDirectory, 'design.png'); + electronMocks.showSaveDialog.mockResolvedValue({ canceled: false, filePath: savedPath }); + const openAssetContent = vi.fn().mockResolvedValue(new Response('image-bytes', { + status: 200, + headers: { 'Content-Type': 'image/png' }, + })); + const response = createResponse(); + + await handleImageWorkspaceRoutes( + createRequest('POST', { defaultFileName: 'Ocean poster.png' }), + response.res, + new URL( + 'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/assets/asset%2Fone/download', + ), + { imageWorkspace: { openAssetContent } } as unknown as HostApiContext, + ); + + expect(electronMocks.showSaveDialog).toHaveBeenCalledWith(expect.objectContaining({ + defaultPath: expect.stringContaining('Ocean poster.png'), + })); + expect(openAssetContent).toHaveBeenCalledWith('workspace/one', 'asset/one'); + expect(response.json()).toMatchObject({ + success: true, + data: { status: 'saved' }, + }); + await expect(readFile(savedPath, 'utf8')).resolves.toBe('image-bytes'); + }); + + it('preserves an existing destination when the private image stream fails', async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-design-download-')); + const savedPath = join(temporaryDirectory, 'existing.png'); + await writeFile(savedPath, 'original'); + electronMocks.showSaveDialog.mockResolvedValue({ canceled: false, filePath: savedPath }); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('partial')); + controller.error(new Error('connection lost')); + }, + }); + const response = createResponse(); + + await handleImageWorkspaceRoutes( + createRequest('POST', { defaultFileName: 'Ocean poster.png' }), + response.res, + new URL( + 'http://127.0.0.1/api/works/image-workspace/workspaces/workspace-one/assets/asset-one/download', + ), + { + imageWorkspace: { + openAssetContent: vi.fn().mockResolvedValue(new Response(body, { + status: 200, + headers: { 'Content-Type': 'image/png' }, + })), + }, + } as unknown as HostApiContext, + ); + + expect(response.json()).toMatchObject({ + success: false, + code: 'DESIGN_ASSET_SAVE_FAILED', + }); + await expect(readFile(savedPath, 'utf8')).resolves.toBe('original'); + }); + + it('does not fetch private bytes when the native save dialog is cancelled', async () => { + electronMocks.showSaveDialog.mockResolvedValue({ canceled: true }); + const openAssetContent = vi.fn(); + const response = createResponse(); + + await handleImageWorkspaceRoutes( + createRequest('POST', { defaultFileName: 'Ocean poster.png' }), + response.res, + new URL( + 'http://127.0.0.1/api/works/image-workspace/workspaces/workspace-one/assets/asset-one/download', + ), + { imageWorkspace: { openAssetContent } } as unknown as HostApiContext, + ); + + expect(openAssetContent).not.toHaveBeenCalled(); + expect(response.json()).toMatchObject({ + success: true, + data: { status: 'cancelled' }, + }); + }); + + it('rejects an oversized image upload before reading it into the Main process', async () => { + const uploadAsset = vi.fn(); + const request = createRequest('POST', {}); + request.headers['content-length'] = String(14 * 1024 * 1024); + const response = createResponse(); + + await handleImageWorkspaceRoutes( + request, + response.res, + new URL( + 'http://127.0.0.1/api/works/image-workspace/workspaces/workspace-one/assets', + ), + { imageWorkspace: { uploadAsset } } as unknown as HostApiContext, + ); + + expect(response.statusCode).toBe(413); + expect(response.json()).toMatchObject({ + success: false, + code: 'design_asset_upload_too_large', + }); + expect(uploadAsset).not.toHaveBeenCalled(); + }); + it('relays normalized task events over local SSE and forwards the opaque resume cursor', async () => { const close = vi.fn(); const openWorkspaceEvents = vi.fn().mockResolvedValue({ diff --git a/tests/unit/works-square-design-workspace.test.ts b/tests/unit/works-square-design-workspace.test.ts index 0902e6b..b65f706 100644 --- a/tests/unit/works-square-design-workspace.test.ts +++ b/tests/unit/works-square-design-workspace.test.ts @@ -223,6 +223,46 @@ describe('Works Square AI design adapter', () => { ); }); + it('uploads a local reference image as multipart data and maps the returned Asset', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse({ + asset_id: 'asset-uploaded', + media_type: 'image', + mime_type: 'image/png', + width: 1200, + height: 800, + duration_milliseconds: null, + created_at: '2026-08-06T10:00:00Z', + })); + const adapter = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://square.example', + fetchImpl: fetchMock, + }); + + await expect(adapter.uploadAsset({ + workspaceId: 'workspace-one', + fileName: 'poster.webp', + mimeType: 'image/webp', + bytes: new Uint8Array([1, 2, 3]), + })).resolves.toMatchObject({ + assetId: 'asset-uploaded', + workspaceId: 'workspace-one', + mimeType: 'image/png', + contentPath: '/api/works/image-workspace/workspaces/workspace-one/assets/asset-uploaded/content', + }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://square.example/api/design/workspaces/workspace-one/assets'); + expect(init?.method).toBe('POST'); + expect((init?.headers as Record).Authorization).toBe('Bearer access-one'); + expect((init?.headers as Record)['Content-Type']).toBeUndefined(); + const form = init?.body as FormData; + expect(form.get('file')).toBeInstanceOf(File); + expect((form.get('file') as File).name).toBe('poster.webp'); + expect(await (form.get('file') as File).arrayBuffer()).toEqual( + Uint8Array.from([1, 2, 3]).buffer, + ); + }); + it('completes a design turn from the connected WebSocket without polling the run endpoint', async () => { const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]); const fetchMock = vi.fn(async (input) => {