569 lines
18 KiB
TypeScript
569 lines
18 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { createWriteStream } from 'node:fs';
|
|
import { rename, rm } from 'node:fs/promises';
|
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
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 DesignCommandInput,
|
|
type DesignCreateWorkspaceInput,
|
|
type DesignRenameWorkspaceInput,
|
|
type DesignUserFieldOperation,
|
|
type DesignUserInput,
|
|
type DesignWorkspaceEventSubscription,
|
|
} from '../../../shared/image-workspace';
|
|
import { DesignWorkspaceModuleError } from '../../image-workspace/module';
|
|
import type { HostApiContext } from '../context';
|
|
import {
|
|
flushStreamingHeaders,
|
|
parseJsonBody,
|
|
sendJson,
|
|
writeStreamingChunk,
|
|
} from '../route-utils';
|
|
|
|
function decodedSegments(pathname: string): string[] | null {
|
|
const suffix = pathname.slice(IMAGE_WORKSPACE_API_PATH.length).replace(/^\/+/, '');
|
|
if (!suffix) return [];
|
|
try {
|
|
return suffix.split('/').map((segment) => decodeURIComponent(segment));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: {};
|
|
}
|
|
|
|
function asString(value: unknown): string {
|
|
return typeof value === 'string' ? value : '';
|
|
}
|
|
|
|
function invalidCommand(message = 'AI 设计命令无效'): never {
|
|
throw new DesignWorkspaceModuleError(400, 'IMAGE_WORKSPACE_INVALID_COMMAND', message);
|
|
}
|
|
|
|
function requiredString(value: unknown, field: string): string {
|
|
const result = asString(value).trim();
|
|
if (!result) invalidCommand(`${field} 不能为空`);
|
|
return result;
|
|
}
|
|
|
|
function nonNegativeInteger(value: unknown, field: string): number {
|
|
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
invalidCommand(`${field} 必须是非负整数`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function decodeFieldOperation(value: unknown): DesignUserFieldOperation {
|
|
const operation = asRecord(value);
|
|
const kind = operation.kind;
|
|
const path = requiredString(operation.path, '字段路径');
|
|
if (kind === 'set') {
|
|
if (!Object.hasOwn(operation, 'value')) invalidCommand('字段写入缺少值');
|
|
return { kind, path, value: operation.value };
|
|
}
|
|
if (kind === 'clear') {
|
|
const resolution = operation.resolution;
|
|
if (resolution !== undefined && resolution !== 'open' && resolution !== 'not_applicable') {
|
|
invalidCommand('字段清空状态无效');
|
|
}
|
|
return { kind, path, ...(resolution === undefined ? {} : { resolution }) };
|
|
}
|
|
if (kind === 'set_lock') {
|
|
if (typeof operation.locked !== 'boolean') invalidCommand('字段锁定状态无效');
|
|
return { kind, path, locked: operation.locked };
|
|
}
|
|
return invalidCommand('不支持的字段操作');
|
|
}
|
|
|
|
function decodeUserInput(value: unknown): DesignUserInput {
|
|
const input = asRecord(value);
|
|
if (input.kind === 'direct_edit') {
|
|
if (!Array.isArray(input.operations) || input.operations.length === 0) {
|
|
invalidCommand('直接编辑至少需要一个字段操作');
|
|
}
|
|
return { kind: 'direct_edit', operations: input.operations.map(decodeFieldOperation) };
|
|
}
|
|
if (input.kind === 'chat') {
|
|
return { kind: 'chat', message: requiredString(input.message, '消息') };
|
|
}
|
|
if (input.kind === 'prompt_resolution') {
|
|
const action = input.action;
|
|
if (action !== 'select' && action !== 'reject') invalidCommand('决策动作无效');
|
|
const optionId = asString(input.optionId).trim();
|
|
if (action === 'select' && !optionId) invalidCommand('选择决策时必须提供选项');
|
|
return {
|
|
kind: 'prompt_resolution',
|
|
promptId: requiredString(input.promptId, '决策问题'),
|
|
action,
|
|
...(optionId ? { optionId } : {}),
|
|
};
|
|
}
|
|
if (input.kind === 'restore') {
|
|
return {
|
|
kind: 'restore',
|
|
specificationRevisionId: requiredString(
|
|
input.specificationRevisionId,
|
|
'设计版本',
|
|
),
|
|
};
|
|
}
|
|
return invalidCommand('不支持的设计输入');
|
|
}
|
|
|
|
function decodeCommand(workspaceId: string, body: Record<string, unknown>): DesignCommandInput {
|
|
const common = {
|
|
workspaceId,
|
|
sessionId: requiredString(body.sessionId, '会话'),
|
|
expectedDirectionRevision: nonNegativeInteger(
|
|
body.expectedDirectionRevision,
|
|
'设计方向版本',
|
|
),
|
|
clientOperationId: requiredString(body.clientOperationId, '操作标识'),
|
|
};
|
|
if (body.kind === 'apply_input') {
|
|
return { kind: 'apply_input', ...common, input: decodeUserInput(body.input) };
|
|
}
|
|
if (body.kind === 'request_quote') {
|
|
return {
|
|
kind: 'request_quote',
|
|
...common,
|
|
specificationRevision: nonNegativeInteger(
|
|
body.specificationRevision,
|
|
'设计规格版本',
|
|
),
|
|
};
|
|
}
|
|
if (body.kind === 'confirm_generation') {
|
|
return {
|
|
kind: 'confirm_generation',
|
|
...common,
|
|
quoteId: requiredString(body.quoteId, '报价'),
|
|
};
|
|
}
|
|
return invalidCommand('不支持的设计命令');
|
|
}
|
|
|
|
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 ASSET_FILE_EXTENSIONS = new Set([
|
|
'.gif', '.jpeg', '.jpg', '.mov', '.mp4', '.png', '.svg', '.webm', '.webp',
|
|
]);
|
|
|
|
function safeAssetFileName(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 || !ASSET_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 = safeAssetFileName(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);
|
|
const contentType = content.headers.get('content-type');
|
|
if (!content.ok
|
|
|| !content.body
|
|
|| (!contentType?.startsWith('image/') && !contentType?.startsWith('video/'))) {
|
|
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);
|
|
return;
|
|
}
|
|
if (error instanceof DesignWorkspaceModuleError) {
|
|
sendJson(res, error.status, {
|
|
success: false,
|
|
status: error.status,
|
|
code: error.code,
|
|
error: error.message,
|
|
...(error.commandOutcome ? { commandOutcome: error.commandOutcome } : {}),
|
|
});
|
|
return;
|
|
}
|
|
if (error instanceof SyntaxError) {
|
|
sendJson(res, 400, {
|
|
success: false,
|
|
status: 400,
|
|
code: 'IMAGE_WORKSPACE_INVALID_JSON',
|
|
error: '请求内容不是有效 JSON',
|
|
});
|
|
return;
|
|
}
|
|
sendJson(res, 500, {
|
|
success: false,
|
|
status: 500,
|
|
code: 'IMAGE_WORKSPACE_REQUEST_FAILED',
|
|
error: error instanceof Error ? error.message : 'AI 设计请求失败',
|
|
});
|
|
}
|
|
|
|
async function relayAssetContent(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
ctx: HostApiContext,
|
|
workspaceId: string,
|
|
assetId: string,
|
|
): Promise<void> {
|
|
const content = await ctx.imageWorkspace!.openAssetContent(
|
|
workspaceId,
|
|
assetId,
|
|
typeof req.headers.range === 'string' ? req.headers.range : undefined,
|
|
);
|
|
res.statusCode = content.status;
|
|
for (const header of [
|
|
'accept-ranges',
|
|
'cache-control',
|
|
'content-length',
|
|
'content-range',
|
|
'content-type',
|
|
'etag',
|
|
'last-modified',
|
|
]) {
|
|
const value = content.headers.get(header);
|
|
if (value) res.setHeader(header, value);
|
|
}
|
|
if (!content.body || req.method === 'HEAD') {
|
|
res.end();
|
|
return;
|
|
}
|
|
await pipeline(Readable.fromWeb(content.body), res);
|
|
}
|
|
|
|
async function relayWorkspaceEvents(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
ctx: HostApiContext,
|
|
workspaceId: string,
|
|
sessionId: string,
|
|
resumedAfterEventId?: string,
|
|
): Promise<void> {
|
|
const header = req.headers['last-event-id'];
|
|
const afterEventId = (Array.isArray(header) ? header[0] : header) ?? resumedAfterEventId;
|
|
let subscription: DesignWorkspaceEventSubscription | null = null;
|
|
let closed = false;
|
|
const close = () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
subscription?.close();
|
|
};
|
|
res.once('close', close);
|
|
try {
|
|
const opened = await ctx.imageWorkspace!.openWorkspaceEvents({
|
|
workspaceId,
|
|
sessionId,
|
|
...(afterEventId ? { afterEventId } : {}),
|
|
});
|
|
if (closed) {
|
|
opened.close();
|
|
return;
|
|
}
|
|
subscription = opened;
|
|
res.statusCode = 200;
|
|
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
|
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
res.setHeader('X-Accel-Buffering', 'no');
|
|
flushStreamingHeaders(res);
|
|
if (!await writeStreamingChunk(res, ': connected\n\n')) return;
|
|
for await (const event of subscription.events) {
|
|
if (!await writeStreamingChunk(
|
|
res,
|
|
`id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`,
|
|
)) return;
|
|
}
|
|
if (!res.writableEnded) res.end();
|
|
} finally {
|
|
res.off('close', close);
|
|
close();
|
|
}
|
|
}
|
|
|
|
export async function handleImageWorkspaceRoutes(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
url: URL,
|
|
ctx: HostApiContext,
|
|
): Promise<boolean> {
|
|
if (url.pathname !== IMAGE_WORKSPACE_API_PATH
|
|
&& !url.pathname.startsWith(`${IMAGE_WORKSPACE_API_PATH}/`)) {
|
|
return false;
|
|
}
|
|
|
|
if (!ctx.imageWorkspace) {
|
|
sendJson(res, 501, {
|
|
success: false,
|
|
status: 501,
|
|
code: IMAGE_WORKSPACE_UNAVAILABLE_CODE,
|
|
error: IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
const segments = decodedSegments(url.pathname);
|
|
if (!segments) {
|
|
sendJson(res, 400, { success: false, status: 400, error: '无效的 AI 设计路径' });
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
if (segments.length === 0 && req.method === 'GET') {
|
|
sendData(res, await ctx.imageWorkspace.bootstrap());
|
|
return true;
|
|
}
|
|
|
|
if (segments.length === 1 && segments[0] === 'workspaces' && req.method === 'POST') {
|
|
const body = await parseJsonBody<Record<string, unknown>>(req);
|
|
const input: DesignCreateWorkspaceInput = {
|
|
clientWorkspaceId: requiredString(body.clientWorkspaceId, '客户端项目标识'),
|
|
title: requiredString(body.title, '项目名称'),
|
|
};
|
|
sendData(res, await ctx.imageWorkspace.createWorkspace(input));
|
|
return true;
|
|
}
|
|
|
|
if (segments.length === 2 && segments[0] === 'workspaces' && req.method === 'GET') {
|
|
sendData(res, await ctx.imageWorkspace.getWorkspace(segments[1]));
|
|
return true;
|
|
}
|
|
|
|
if (segments.length === 2 && segments[0] === 'workspaces' && req.method === 'DELETE') {
|
|
sendData(res, await ctx.imageWorkspace.deleteWorkspace(segments[1]));
|
|
return true;
|
|
}
|
|
|
|
if (segments.length === 2 && segments[0] === 'workspaces' && req.method === 'PATCH') {
|
|
const body = await parseJsonBody<Record<string, unknown>>(req);
|
|
const input: DesignRenameWorkspaceInput = {
|
|
workspaceId: segments[1],
|
|
title: requiredString(body.title, '项目名称'),
|
|
};
|
|
sendData(res, await ctx.imageWorkspace.renameWorkspace(input));
|
|
return true;
|
|
}
|
|
|
|
if (segments.length === 3
|
|
&& segments[0] === 'workspaces'
|
|
&& segments[2] === 'commands'
|
|
&& req.method === 'POST') {
|
|
const body = await parseJsonBody<Record<string, unknown>>(req);
|
|
sendData(res, await ctx.imageWorkspace.submitCommand(decodeCommand(segments[1], body)));
|
|
return true;
|
|
}
|
|
|
|
if (segments.length === 3
|
|
&& segments[0] === 'workspaces'
|
|
&& segments[2] === 'events'
|
|
&& req.method === 'GET') {
|
|
await relayWorkspaceEvents(
|
|
req,
|
|
res,
|
|
ctx,
|
|
segments[1],
|
|
requiredString(url.searchParams.get('sessionId'), '会话'),
|
|
asString(url.searchParams.get('afterEventId')).trim() || undefined,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (segments.length === 3
|
|
&& segments[0] === 'workspaces'
|
|
&& segments[2] === 'assets'
|
|
&& req.method === 'POST') {
|
|
const body = await parseAssetUploadBody(req);
|
|
sendData(res, await ctx.imageWorkspace.uploadAsset(decodeAssetUpload(segments[1], body)));
|
|
return true;
|
|
}
|
|
|
|
if (segments.length === 5
|
|
&& segments[0] === 'workspaces'
|
|
&& segments[2] === 'assets'
|
|
&& segments[4] === 'content'
|
|
&& (req.method === 'GET' || req.method === 'HEAD')) {
|
|
await relayAssetContent(req, res, ctx, segments[1], segments[3]);
|
|
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,
|
|
code: 'IMAGE_WORKSPACE_ROUTE_NOT_FOUND',
|
|
error: `没有对应的 AI 设计接口:${req.method ?? 'GET'} ${url.pathname}`,
|
|
});
|
|
} catch (error) {
|
|
sendRouteError(res, error);
|
|
}
|
|
return true;
|
|
}
|