Files
makelore/src/lib/image-workspace.ts

254 lines
7.6 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 DesignCommandFailureOutcome,
type DesignCommandInput,
type DesignCommandResult,
type DesignDeleteWorkspaceResult,
type DesignWorkspace,
type DesignWorkspaceBootstrap,
} from '../../shared/image-workspace';
type ImageWorkspaceEnvelope<T> = {
success?: boolean;
status?: number;
code?: string;
error?: string;
commandOutcome?: DesignCommandFailureOutcome;
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;
readonly commandOutcome?: DesignCommandFailureOutcome;
constructor(
status: number,
code: string,
message: string,
commandOutcome?: DesignCommandFailureOutcome,
) {
super(message);
this.name = 'ImageWorkspaceApiError';
this.status = status;
this.code = code;
this.commandOutcome = commandOutcome;
}
}
function createClientId(prefix: string): string {
const id = globalThis.crypto?.randomUUID?.()
?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${prefix}-${id}`;
}
export function createImageWorkspaceOperationId(): string {
return createClientId('design-operation');
}
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';
}
function getCommandOutcome(error: unknown): DesignCommandFailureOutcome | undefined {
const outcome = error instanceof AppError ? error.details?.commandOutcome : undefined;
return outcome === 'definitive_failure' || outcome === 'unknown' ? outcome : undefined;
}
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,
getCommandOutcome(error),
);
}
if (!response.success || response.data === undefined) {
throw new ImageWorkspaceApiError(
response.status ?? 502,
response.code ?? 'IMAGE_WORKSPACE_REQUEST_FAILED',
response.error ?? 'AI 设计请求失败',
response.commandOutcome,
);
}
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 deleteImageWorkspaceProject(
workspaceId: string,
): Promise<DesignDeleteWorkspaceResult> {
return requestData(
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}`,
{ method: 'DELETE' },
);
}
export function fetchImageWorkspaceProject(workspaceId: string): Promise<DesignWorkspace> {
return requestData(
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}`,
);
}
export function submitImageWorkspaceCommand(
command: DesignCommandInput,
): Promise<DesignCommandResult> {
const { workspaceId, ...body } = command;
return requestData(
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/commands`,
{ method: 'POST', body: JSON.stringify(body) },
);
}
export async function openImageWorkspaceEvents(
workspaceId: string,
sessionId: string,
afterEventId?: string,
): Promise<EventSource> {
await ensureHostApiToken();
const query = new URLSearchParams({ sessionId });
if (afterEventId) query.set('afterEventId', afterEventId);
return createHostEventSource(
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/events?${query}`,
);
}
export async function resolveImageWorkspaceAssetUrl(contentPath: string): Promise<string> {
const token = await ensureHostApiToken();
const separator = contentPath.includes('?') ? '&' : '?';
return `${getHostApiBase()}${contentPath}${separator}token=${encodeURIComponent(token)}`;
}
const ASSET_FILE_EXTENSIONS: Record<string, string> = {
'image/gif': 'gif',
'image/jpeg': 'jpg',
'image/png': 'png',
'image/svg+xml': 'svg',
'image/webp': 'webp',
'video/mp4': 'mp4',
'video/quicktime': 'mov',
'video/webm': 'webm',
};
function assetDownloadFileName(asset: DesignAsset): string {
const safeAssetId = asset.assetId
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 32) || 'image';
const extension = ASSET_FILE_EXTENSIONS[asset.mimeType.toLowerCase()]
?? (asset.mediaType === 'video' ? 'mp4' : '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),
}),
},
);
}