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 { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } 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): 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> { 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 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 { 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, }); 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 { 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 { 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 { 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>(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>(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>(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>(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; }