完善 AI 设计资产预览与视频首帧选择
需求:生成图片需要支持大图查看和本地下载;制作视频时需要从当前项目作品选择首帧,或上传本地图片。 实现:新增首帧选择弹窗、JPEG/PNG/WebP 有界上传、附件 Asset ID 透传、Main 到服务端 multipart 转发,并保留上传失败重试与私有图片下载链路。 验证:相关 57 项单测、TypeScript 类型检查、目标 ESLint 和 Vite 生产构建全部通过。
This commit is contained in:
@@ -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<Record<string, unknown>> {
|
||||
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<string, unknown>;
|
||||
} catch {
|
||||
throw new DesignWorkspaceModuleError(
|
||||
400,
|
||||
'design_asset_upload_invalid',
|
||||
'上传图片请求无效',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeAssetUpload(
|
||||
workspaceId: string,
|
||||
body: Record<string, unknown>,
|
||||
): 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<void> {
|
||||
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<Record<string, unknown>>(req);
|
||||
await saveAssetContent(
|
||||
res,
|
||||
ctx,
|
||||
segments[1],
|
||||
segments[3],
|
||||
asString(body.defaultFileName),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
sendJson(res, 404, {
|
||||
success: false,
|
||||
status: 404,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
DesignAsset,
|
||||
DesignAssetUploadInput,
|
||||
DesignCapabilities,
|
||||
DesignConfirmGenerationInput,
|
||||
DesignCreateWorkspaceInput,
|
||||
@@ -41,6 +43,7 @@ export interface DesignWorkspaceModule {
|
||||
submitMessage(input: DesignSubmitMessageInput): Promise<DesignWorkspace>;
|
||||
confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignWorkspace>;
|
||||
listTasks(workspaceId: string): Promise<DesignGenerationTask[]>;
|
||||
uploadAsset?(input: DesignAssetUploadInput): Promise<DesignAsset>;
|
||||
openWorkspaceEvents?(
|
||||
input: DesignWorkspaceEventSubscriptionInput,
|
||||
): Promise<DesignWorkspaceEventSubscription>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
DesignAsset,
|
||||
DesignAssetUploadInput,
|
||||
DesignAssistantDeltaEvent,
|
||||
DesignBrief,
|
||||
DesignCapabilities,
|
||||
@@ -266,19 +267,23 @@ function mapTask(task: ServerTask): DesignGenerationTask {
|
||||
quoteId: task.quote_id,
|
||||
quotedDesignPoints: task.quoted_design_points,
|
||||
failureCode: task.failure_code,
|
||||
resultAssets: task.result_assets.map((asset) => ({
|
||||
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: task.workspace_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(task.workspace_id, asset.asset_id),
|
||||
})),
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
contentPath: designAssetContentPath(workspaceId, asset.asset_id),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -913,6 +918,20 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
return tasks.map(mapTask);
|
||||
}
|
||||
|
||||
async uploadAsset(input: DesignAssetUploadInput): Promise<DesignAsset> {
|
||||
const form = new FormData();
|
||||
form.set(
|
||||
'file',
|
||||
new Blob([Uint8Array.from(input.bytes)], { type: input.mimeType }),
|
||||
input.fileName,
|
||||
);
|
||||
const asset = await this.requestJson<ServerAsset>(
|
||||
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/assets`,
|
||||
{ method: 'POST', body: form },
|
||||
);
|
||||
return mapAsset(input.workspaceId, asset);
|
||||
}
|
||||
|
||||
async openWorkspaceEvents(
|
||||
input: DesignWorkspaceEventSubscriptionInput,
|
||||
): Promise<DesignWorkspaceEventSubscription> {
|
||||
@@ -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 ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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<DesignWorkspace> {
|
||||
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<string, string> = {
|
||||
'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<DesignAssetSaveResult> {
|
||||
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<string> {
|
||||
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<DesignAsset> {
|
||||
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),
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<>
|
||||
<div className="bg-surface-subtle">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="放大查看生成图片"
|
||||
className="group relative block w-full cursor-zoom-in overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 focus-visible:ring-inset"
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt="AI 设计生成结果"
|
||||
className="aspect-video w-full bg-surface-subtle object-contain"
|
||||
className="aspect-video w-full bg-surface-subtle object-contain outline outline-1 -outline-offset-1 outline-black/10 transition-transform duration-200 group-hover:scale-[1.015] group-focus-visible:scale-[1.015]"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-center gap-1.5 bg-gradient-to-t from-black/55 to-transparent px-3 pb-2.5 pt-8 text-[11px] font-semibold text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
查看大图
|
||||
</span>
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-border/60 bg-background/95 p-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-10 rounded-xl text-xs font-semibold transition-transform active:scale-[0.96]"
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
>
|
||||
<Maximize2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
查看大图
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={downloading ? '正在保存生成图片' : '下载生成图片'}
|
||||
aria-busy={downloading}
|
||||
className="h-10 rounded-xl text-xs font-semibold transition-transform active:scale-[0.96]"
|
||||
disabled={downloading}
|
||||
onClick={() => void download()}
|
||||
>
|
||||
{downloading
|
||||
? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
: <Download className="mr-1.5 h-3.5 w-3.5" />}
|
||||
{downloading ? '保存中' : '下载图片'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||
<DialogContent className="max-h-[92vh] max-w-6xl gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="flex-row items-center justify-between space-y-0 border-b border-border/70 px-4 py-3 sm:px-5">
|
||||
<div className="min-w-0 text-left">
|
||||
<DialogTitle className="text-base">图片预览</DialogTitle>
|
||||
<DialogDescription className="mt-0.5 text-xs tabular-nums">
|
||||
{asset.width} × {asset.height} · 原始生成结果
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
aria-busy={downloading}
|
||||
className="h-10 rounded-xl bg-brand px-3 text-xs font-semibold text-primary-foreground transition-transform active:scale-[0.96]"
|
||||
disabled={downloading}
|
||||
onClick={() => void download()}
|
||||
>
|
||||
{downloading
|
||||
? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
: <Download className="mr-1.5 h-4 w-4" />}
|
||||
{downloading ? '正在保存' : '下载原图'}
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="关闭图片预览"
|
||||
className="h-10 w-10 rounded-xl transition-transform active:scale-[0.96]"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="flex min-h-0 items-center justify-center bg-black/90 p-3 sm:p-5">
|
||||
<img
|
||||
src={url}
|
||||
alt="AI 设计大图预览"
|
||||
className="max-h-[calc(92vh-76px)] max-w-full rounded-lg object-contain outline outline-1 outline-white/10"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FirstFrameAssetOption({
|
||||
asset,
|
||||
busy,
|
||||
onSelect,
|
||||
}: {
|
||||
asset: DesignAsset;
|
||||
busy: boolean;
|
||||
onSelect(): void;
|
||||
}) {
|
||||
const [url, setUrl] = useState<string | null>(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 (
|
||||
<article className="overflow-hidden rounded-2xl bg-background shadow-soft ring-1 ring-black/10 dark:ring-white/10">
|
||||
<div className="flex aspect-video items-center justify-center bg-surface-subtle">
|
||||
{url ? (
|
||||
<img
|
||||
src={url}
|
||||
alt="可选作品图片"
|
||||
className="h-full w-full object-contain outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
|
||||
/>
|
||||
) : <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 p-3">
|
||||
<span className="text-xs font-medium tabular-nums text-muted-foreground">
|
||||
{asset.width} × {asset.height}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-10 rounded-xl px-3 text-xs font-semibold transition-transform active:scale-[0.96]"
|
||||
disabled={busy || !url}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
选择此图片
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,6 +416,10 @@ export function ImageCanvas() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [confirmingQuoteId, setConfirmingQuoteId] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [firstFramePickerOpen, setFirstFramePickerOpen] = useState(false);
|
||||
const [firstFrameBusyKey, setFirstFrameBusyKey] = useState<string | null>(null);
|
||||
const [firstFrameError, setFirstFrameError] = useState<string | null>(null);
|
||||
const [uploadedFirstFrameAsset, setUploadedFirstFrameAsset] = useState<DesignAsset | null>(null);
|
||||
const conversationEndRef = useRef<HTMLDivElement | null>(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<RenderedDesignMessage[]>(() => {
|
||||
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<HTMLInputElement>) => {
|
||||
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));
|
||||
};
|
||||
@@ -515,6 +758,11 @@ export function ImageCanvas() {
|
||||
size="sm"
|
||||
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() {
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={firstFramePickerOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && firstFrameBusyKey) return;
|
||||
setFirstFramePickerOpen(open);
|
||||
if (!open) setFirstFrameError(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[88vh] max-w-4xl grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="flex-row items-start justify-between space-y-0 px-5 py-4 sm:px-6">
|
||||
<div className="min-w-0 text-left">
|
||||
<DialogTitle className="text-balance text-base">选择视频首帧</DialogTitle>
|
||||
<DialogDescription className="mt-1 text-pretty text-xs leading-5">
|
||||
从本项目作品中选择一张已完成图片,或上传本地图片作为视频起始画面。
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="关闭首帧选择"
|
||||
className="h-10 w-10 shrink-0 rounded-xl transition-transform active:scale-[0.96]"
|
||||
disabled={Boolean(firstFrameBusyKey)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto bg-surface-subtle/55 px-5 py-4 sm:px-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">本项目作品</h3>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
仅展示已成功生成的 PNG 图片
|
||||
</p>
|
||||
</div>
|
||||
<label
|
||||
htmlFor="design-first-frame-upload"
|
||||
aria-busy={firstFrameBusyKey === 'upload'}
|
||||
className={cn(
|
||||
'inline-flex h-10 cursor-pointer items-center justify-center rounded-xl bg-brand px-3 text-xs font-semibold text-primary-foreground shadow-sm transition-transform active:scale-[0.96]',
|
||||
firstFrameBusyKey && 'pointer-events-none opacity-50',
|
||||
)}
|
||||
>
|
||||
{firstFrameBusyKey === 'upload'
|
||||
? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
: <Upload className="mr-1.5 h-4 w-4" />}
|
||||
{firstFrameBusyKey === 'upload' ? '上传并提交中' : '上传本地图片'}
|
||||
<input
|
||||
id="design-first-frame-upload"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp"
|
||||
aria-label="上传本地图片"
|
||||
className="sr-only"
|
||||
disabled={Boolean(firstFrameBusyKey)}
|
||||
onChange={(event) => void handleFirstFrameUpload(event)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{firstFrameError ? (
|
||||
<p role="alert" className="mt-3 rounded-xl bg-destructive/10 px-3 py-2 text-xs font-semibold text-destructive">
|
||||
{firstFrameError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{firstFrameAssets.length > 0 ? (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{firstFrameAssets.map((asset) => (
|
||||
<FirstFrameAssetOption
|
||||
key={asset.assetId}
|
||||
asset={asset}
|
||||
busy={Boolean(firstFrameBusyKey)}
|
||||
onSelect={() => void handleFirstFrameSelect(asset)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-2xl bg-background px-5 py-10 text-center shadow-soft ring-1 ring-black/5 dark:ring-white/10">
|
||||
<ImageIcon className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-semibold">本项目还没有可用图片</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
可以先生成一张图片,或直接上传本地图片。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ type ImageWorkspaceState = {
|
||||
refreshTasks: () => Promise<DesignGenerationTask[]>;
|
||||
connectTaskStream: () => void;
|
||||
disconnectTaskStream: () => void;
|
||||
sendMessage: (message: string) => Promise<DesignWorkspace>;
|
||||
sendMessage: (message: string, attachmentAssetIds?: string[]) => Promise<DesignWorkspace>;
|
||||
confirmGeneration: (quoteId: string) => Promise<DesignWorkspace>;
|
||||
reset: () => void;
|
||||
};
|
||||
@@ -635,14 +635,22 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((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(
|
||||
const updated = attachmentAssetIds.length > 0
|
||||
? await sendImageWorkspaceMessage(
|
||||
workspace.workspaceId,
|
||||
workspace.turnRevision,
|
||||
userText,
|
||||
clientTurnId,
|
||||
attachmentAssetIds,
|
||||
)
|
||||
: await sendImageWorkspaceMessage(
|
||||
workspace.workspaceId,
|
||||
workspace.turnRevision,
|
||||
userText,
|
||||
|
||||
@@ -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<string>) => 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(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
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(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
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(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
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(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
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(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Uint8Array>({
|
||||
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({
|
||||
|
||||
@@ -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<typeof fetch>().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<string, string>).Authorization).toBe('Bearer access-one');
|
||||
expect((init?.headers as Record<string, string>)['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<typeof fetch>(async (input) => {
|
||||
|
||||
Reference in New Issue
Block a user