feat(canvas): cut over design agent to living form v2

This commit is contained in:
2026-08-30 17:00:31 +08:00
parent f245603923
commit b0b5a602b5
35 changed files with 5729 additions and 13986 deletions

View File

@@ -1,7 +1,7 @@
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 { randomUUID } from 'node:crypto';
import { basename, dirname, extname, join } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
@@ -11,17 +11,14 @@ import {
IMAGE_WORKSPACE_UNAVAILABLE_CODE,
IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE,
type DesignAssetUploadInput,
type DesignCreateConversationInput,
type DesignConfirmGenerationInput,
type DesignCommandInput,
type DesignCreateWorkspaceInput,
type DesignGenerationParameters,
type DesignRenameWorkspaceInput,
type DesignSubmitMessageInput,
} from '../../../shared/image-workspace';
import {
DesignWorkspaceModuleError,
type DesignUserFieldOperation,
type DesignUserInput,
type DesignWorkspaceEventSubscription,
} from '../../image-workspace/module';
} from '../../../shared/image-workspace';
import { DesignWorkspaceModuleError } from '../../image-workspace/module';
import type { HostApiContext } from '../context';
import {
flushStreamingHeaders,
@@ -40,44 +37,124 @@ function decodedSegments(pathname: string): string[] | null {
}
}
function asString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function asInteger(value: unknown): number {
return typeof value === 'number' && Number.isInteger(value) ? value : -1;
}
function asStringArray(value: unknown): string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string')
? value
: [];
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function generationParametersFromBody(body: Record<string, unknown>): DesignGenerationParameters {
return {
model: asString(body.model),
resolution: asString(body.resolution),
aspectRatio: asString(body.aspectRatio),
durationSeconds: body.durationSeconds === null
? null
: typeof body.durationSeconds === 'number' && Number.isFinite(body.durationSeconds)
? body.durationSeconds
: null,
};
function asString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
const DESIGN_IMAGE_UPLOAD_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
]);
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
@@ -164,15 +241,7 @@ function sendData(res: ServerResponse, data: unknown): void {
}
const ASSET_FILE_EXTENSIONS = new Set([
'.gif',
'.jpeg',
'.jpg',
'.mov',
'.mp4',
'.png',
'.svg',
'.webm',
'.webp',
'.gif', '.jpeg', '.jpg', '.mov', '.mp4', '.png', '.svg', '.webm', '.webp',
]);
function safeAssetFileName(value: string, assetId: string): string {
@@ -320,17 +389,11 @@ async function relayWorkspaceEvents(
res: ServerResponse,
ctx: HostApiContext,
workspaceId: string,
conversationId: string,
sessionId: string,
resumedAfterEventId?: string,
): Promise<void> {
if (!ctx.imageWorkspace?.openWorkspaceEvents) {
throw new DesignWorkspaceModuleError(
501,
'DESIGN_EVENT_STREAM_UNAVAILABLE',
'AI 设计任务实时状态暂时不可用',
);
}
const header = req.headers['last-event-id'];
const afterEventId = Array.isArray(header) ? header[0] : header;
const afterEventId = (Array.isArray(header) ? header[0] : header) ?? resumedAfterEventId;
let subscription: DesignWorkspaceEventSubscription | null = null;
let closed = false;
const close = () => {
@@ -340,9 +403,9 @@ async function relayWorkspaceEvents(
};
res.once('close', close);
try {
const opened = await ctx.imageWorkspace.openWorkspaceEvents({
const opened = await ctx.imageWorkspace!.openWorkspaceEvents({
workspaceId,
conversationId,
sessionId,
...(afterEventId ? { afterEventId } : {}),
});
if (closed) {
@@ -361,9 +424,7 @@ async function relayWorkspaceEvents(
if (!await writeStreamingChunk(
res,
`id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`,
)) {
return;
}
)) return;
}
if (!res.writableEnded) res.end();
} finally {
@@ -408,25 +469,13 @@ export async function handleImageWorkspaceRoutes(
if (segments.length === 1 && segments[0] === 'workspaces' && req.method === 'POST') {
const body = await parseJsonBody<Record<string, unknown>>(req);
const input: DesignCreateWorkspaceInput = {
clientWorkspaceId: asString(body.clientWorkspaceId),
title: asString(body.title),
clientWorkspaceId: requiredString(body.clientWorkspaceId, '客户端项目标识'),
title: requiredString(body.title, '项目名称'),
};
sendData(res, await ctx.imageWorkspace.createWorkspace(input));
return true;
}
if (segments.length === 1 && segments[0] === 'local-data' && req.method === 'DELETE') {
if (!ctx.imageWorkspace.reset) {
throw new DesignWorkspaceModuleError(
405,
'IMAGE_WORKSPACE_RESET_NOT_ALLOWED',
'云端 AI 设计不支持清空本地数据',
);
}
sendData(res, await ctx.imageWorkspace.reset());
return true;
}
if (segments.length === 2 && segments[0] === 'workspaces' && req.method === 'GET') {
sendData(res, await ctx.imageWorkspace.getWorkspace(segments[1]));
return true;
@@ -441,7 +490,7 @@ export async function handleImageWorkspaceRoutes(
const body = await parseJsonBody<Record<string, unknown>>(req);
const input: DesignRenameWorkspaceInput = {
workspaceId: segments[1],
title: asString(body.title),
title: requiredString(body.title, '项目名称'),
};
sendData(res, await ctx.imageWorkspace.renameWorkspace(input));
return true;
@@ -449,69 +498,25 @@ export async function handleImageWorkspaceRoutes(
if (segments.length === 3
&& segments[0] === 'workspaces'
&& segments[2] === 'conversations'
&& segments[2] === 'commands'
&& req.method === 'POST') {
const body = await parseJsonBody<Record<string, unknown>>(req);
const input: DesignCreateConversationInput = {
workspaceId: segments[1],
clientConversationId: asString(body.clientConversationId),
title: asString(body.title),
};
sendData(res, await ctx.imageWorkspace.createConversation(input));
return true;
}
if (segments.length === 4
&& segments[0] === 'workspaces'
&& segments[2] === 'conversations'
&& req.method === 'GET') {
const before = url.searchParams.get('before');
sendData(
res,
before === null
? await ctx.imageWorkspace.getConversation(segments[1], segments[3])
: await ctx.imageWorkspace.getConversation(segments[1], segments[3], before),
);
return true;
}
if (segments.length === 5
&& segments[0] === 'workspaces'
&& segments[2] === 'conversations'
&& segments[4] === 'messages'
&& req.method === 'POST') {
const body = await parseJsonBody<Record<string, unknown>>(req);
const input: DesignSubmitMessageInput = {
workspaceId: segments[1],
conversationId: segments[3],
clientTurnId: asString(body.clientTurnId),
expectedTurnRevision: asInteger(body.expectedTurnRevision),
message: asString(body.message),
attachmentAssetIds: asStringArray(body.attachmentAssetIds),
};
sendData(res, await ctx.imageWorkspace.submitMessage(input));
sendData(res, await ctx.imageWorkspace.submitCommand(decodeCommand(segments[1], body)));
return true;
}
if (segments.length === 3
&& segments[0] === 'workspaces'
&& segments[2] === 'tasks'
&& segments[2] === 'events'
&& req.method === 'GET') {
sendData(res, await ctx.imageWorkspace.listTasks(segments[1]));
return true;
}
if (segments.length === 4
&& segments[0] === 'workspaces'
&& segments[2] === 'generation-quotes'
&& req.method === 'PATCH') {
const body = await parseJsonBody<Record<string, unknown>>(req);
sendData(res, await ctx.imageWorkspace.updateGenerationQuote({
workspaceId: segments[1],
quoteId: segments[3],
finalPrompt: typeof body.finalPrompt === 'string' ? body.finalPrompt : '',
generationParameters: generationParametersFromBody(body),
}));
await relayWorkspaceEvents(
req,
res,
ctx,
segments[1],
requiredString(url.searchParams.get('sessionId'), '会话'),
asString(url.searchParams.get('afterEventId')).trim() || undefined,
);
return true;
}
@@ -519,47 +524,8 @@ export async function handleImageWorkspaceRoutes(
&& 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 === 5
&& segments[0] === 'workspaces'
&& segments[2] === 'conversations'
&& segments[4] === 'events'
&& req.method === 'GET') {
await relayWorkspaceEvents(req, res, ctx, segments[1], segments[3]);
return true;
}
if (segments.length === 7
&& segments[0] === 'workspaces'
&& segments[2] === 'conversations'
&& segments[4] === 'quotes'
&& segments[6] === 'confirm'
&& req.method === 'POST') {
const body = await parseJsonBody<Record<string, unknown>>(req);
const input: DesignConfirmGenerationInput = {
workspaceId: segments[1],
conversationId: segments[3],
quoteId: segments[5],
clientTurnId: asString(body.clientTurnId),
expectedTurnRevision: asInteger(body.expectedTurnRevision),
finalPrompt: typeof body.finalPrompt === 'string' ? body.finalPrompt : '',
generationParameters: generationParametersFromBody(asRecord(body.generationParameters)),
};
sendData(res, await ctx.imageWorkspace.confirmGeneration(input));
sendData(res, await ctx.imageWorkspace.uploadAsset(decodeAssetUpload(segments[1], body)));
return true;
}