需求:生成图片需要支持大图查看和本地下载;制作视频时需要从当前项目作品选择首帧,或上传本地图片。 实现:新增首帧选择弹窗、JPEG/PNG/WebP 有界上传、附件 Asset ID 透传、Main 到服务端 multipart 转发,并保留上传失败重试与私有图片下载链路。 验证:相关 57 项单测、TypeScript 类型检查、目标 ESLint 和 Vite 生产构建全部通过。
264 lines
7.3 KiB
TypeScript
264 lines
7.3 KiB
TypeScript
import { AppError } from '@/lib/error-model';
|
|
import {
|
|
createHostEventSource,
|
|
ensureHostApiToken,
|
|
getHostApiBase,
|
|
hostApiFetch,
|
|
} from '@/lib/host-api';
|
|
import {
|
|
IMAGE_WORKSPACE_API_PATH,
|
|
IMAGE_WORKSPACE_UNAVAILABLE_CODE,
|
|
IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE,
|
|
designAssetDownloadPath,
|
|
type DesignAsset,
|
|
type DesignAssetSaveResult,
|
|
type DesignGenerationTask,
|
|
type DesignWorkspace,
|
|
type DesignWorkspaceBootstrap,
|
|
} from '../../shared/image-workspace';
|
|
|
|
type ImageWorkspaceEnvelope<T> = {
|
|
success?: boolean;
|
|
status?: number;
|
|
code?: string;
|
|
error?: string;
|
|
data?: T;
|
|
};
|
|
|
|
export const IMAGE_WORKSPACE_CREATE_PROJECT_EVENT = 'niancode:image-workspace:create-project';
|
|
|
|
export class ImageWorkspaceApiError extends Error {
|
|
readonly status: number;
|
|
readonly code: string;
|
|
|
|
constructor(status: number, code: string, message: string) {
|
|
super(message);
|
|
this.name = 'ImageWorkspaceApiError';
|
|
this.status = status;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
function createClientId(prefix: string): string {
|
|
const id = globalThis.crypto?.randomUUID?.()
|
|
?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
return `${prefix}-${id}`;
|
|
}
|
|
|
|
export function createImageWorkspaceTurnId(): string {
|
|
return createClientId('turn');
|
|
}
|
|
|
|
function getErrorStatus(error: unknown): number {
|
|
if (error instanceof AppError && typeof error.details?.status === 'number') {
|
|
return error.details.status;
|
|
}
|
|
return 502;
|
|
}
|
|
|
|
function getErrorCode(error: unknown, status: number): string {
|
|
if (error instanceof AppError && typeof error.details?.backendCode === 'string') {
|
|
return error.details.backendCode;
|
|
}
|
|
if (status === 501) return IMAGE_WORKSPACE_UNAVAILABLE_CODE;
|
|
return 'IMAGE_WORKSPACE_REQUEST_FAILED';
|
|
}
|
|
|
|
async function requestData<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
let response: ImageWorkspaceEnvelope<T>;
|
|
try {
|
|
response = await hostApiFetch<ImageWorkspaceEnvelope<T>>(path, init);
|
|
} catch (error) {
|
|
const status = getErrorStatus(error);
|
|
throw new ImageWorkspaceApiError(
|
|
status,
|
|
getErrorCode(error, status),
|
|
error instanceof Error ? error.message : IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE,
|
|
);
|
|
}
|
|
|
|
if (!response.success || response.data === undefined) {
|
|
throw new ImageWorkspaceApiError(
|
|
response.status ?? 502,
|
|
response.code ?? 'IMAGE_WORKSPACE_REQUEST_FAILED',
|
|
response.error ?? 'AI 设计请求失败',
|
|
);
|
|
}
|
|
return response.data;
|
|
}
|
|
|
|
export function fetchImageWorkspace(): Promise<DesignWorkspaceBootstrap> {
|
|
return requestData(IMAGE_WORKSPACE_API_PATH);
|
|
}
|
|
|
|
export function createImageWorkspaceProject(
|
|
title: string,
|
|
clientWorkspaceId = createClientId('workspace'),
|
|
): Promise<DesignWorkspace> {
|
|
return requestData(`${IMAGE_WORKSPACE_API_PATH}/workspaces`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
clientWorkspaceId,
|
|
title: title.trim(),
|
|
}),
|
|
});
|
|
}
|
|
|
|
export function renameImageWorkspaceProject(
|
|
workspaceId: string,
|
|
title: string,
|
|
): Promise<DesignWorkspace> {
|
|
return requestData(
|
|
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}`,
|
|
{
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ title: title.trim() }),
|
|
},
|
|
);
|
|
}
|
|
|
|
export function fetchImageWorkspaceProject(workspaceId: string): Promise<DesignWorkspace> {
|
|
return requestData(
|
|
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}`,
|
|
);
|
|
}
|
|
|
|
export function sendImageWorkspaceMessage(
|
|
workspaceId: string,
|
|
expectedTurnRevision: number,
|
|
message: string,
|
|
clientTurnId = createImageWorkspaceTurnId(),
|
|
attachmentAssetIds: string[] = [],
|
|
): Promise<DesignWorkspace> {
|
|
return requestData(
|
|
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/messages`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
clientTurnId,
|
|
expectedTurnRevision,
|
|
message: message.trim(),
|
|
attachmentAssetIds,
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
|
|
export function confirmImageWorkspaceGeneration(
|
|
workspaceId: string,
|
|
expectedTurnRevision: number,
|
|
quoteId: string,
|
|
clientTurnId = createImageWorkspaceTurnId(),
|
|
): Promise<DesignWorkspace> {
|
|
return requestData(
|
|
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/quotes/${encodeURIComponent(quoteId)}/confirm`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
clientTurnId,
|
|
expectedTurnRevision,
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
|
|
export function fetchImageWorkspaceTasks(
|
|
workspaceId: string,
|
|
): Promise<DesignGenerationTask[]> {
|
|
return requestData(
|
|
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/tasks`,
|
|
);
|
|
}
|
|
|
|
export async function openImageWorkspaceTaskEvents(workspaceId: string): Promise<EventSource> {
|
|
await ensureHostApiToken();
|
|
return createHostEventSource(
|
|
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/events`,
|
|
);
|
|
}
|
|
|
|
export async function resolveImageWorkspaceAssetUrl(contentPath: string): Promise<string> {
|
|
const token = await ensureHostApiToken();
|
|
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),
|
|
}),
|
|
},
|
|
);
|
|
}
|