302 lines
9.3 KiB
TypeScript

import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { isCanvasCapabilityEnabled } from "@/lib/canvas/capabilities/availability";
import {
CanvasCapabilityRuntimeError,
resolveCanvasCapabilityContext,
} from "@/lib/canvas/capabilities/context";
import { shouldExecuteCanvasCapabilityHeadlessly } from "@/lib/canvas/capabilities/execution-mode";
import {
executeCanvasCapabilityHeadlessly,
type CanvasCapabilityMediaResult,
} from "@/lib/canvas/capabilities/headless-executor";
import { getCanvasCapability } from "@/lib/canvas/capabilities/registry";
import { enqueueCanvasCapabilityExecution } from "@/lib/canvas/capabilities/task-queue";
import {
createOrRecoverCanvasCapabilityTask,
MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS,
markCanvasCapabilityTaskFailed,
markCanvasCapabilityTaskRunning,
markCanvasCapabilityTaskSucceeded,
} from "@/lib/canvas/capabilities/task-store";
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
export type CanvasCapabilityRuntimeResult = {
clientCommand?: CanvasCapabilityClientCommand;
mode: "headless" | "interactive";
operation?: {
canvasRevision: string;
canvasUpdatedAt: string;
createdNodeIds: string[];
createdNodeIdsByMediaItemId: Record<string, string>;
updatedNodeIds: string[];
};
outputs?: Array<{
assetId?: string;
sourceNodeId: string;
title: string;
url: string;
}>;
task: {
attempts: number;
error: string | null;
id: string;
requestId: string;
result: unknown;
status: string;
version: number;
};
};
function serializeTask(task: {
attempts: number;
error: string | null;
id: string;
requestId: string;
result: unknown;
status: string;
version: number;
}) {
return {
attempts: task.attempts,
error: task.error,
id: task.id,
requestId: task.requestId,
result: task.result,
status: task.status,
version: task.version,
};
}
function buildMediaOperationPlan(params: {
command: CanvasCapabilityClientCommand;
mediaResults: CanvasCapabilityMediaResult[];
projectId: string;
projectRevision: string;
}) {
return {
version: 1,
operationId: params.command.requestId,
operation: "insert_beside",
projectId: params.projectId,
baseCanvasRevision: params.projectRevision,
sourceNodeIds: params.command.nodeIds,
targetNodeIds: params.command.nodeIds,
placement: "right",
placementStrategy: "beside_each_source",
preserveOriginal: true,
layoutStrategy: params.mediaResults.length > 4 ? "grid" : "row",
spacing: 24,
mediaItems: params.mediaResults.map((item, index) => ({
mediaItemId: `${params.command.requestId}:result-${index + 1}`,
type: item.nodeType || "image",
url: item.url,
assetId: item.assetId,
width: item.width,
height: item.height,
title: item.title,
prompt: item.prompt,
sourceNodeIds: [item.sourceNodeId],
metadata: {
...(item.metadata || {}),
canvasCapabilityId: params.command.capabilityId,
canvasCapabilityRequestId: params.command.requestId,
canvasCapabilitySource: params.command.source,
sourceNodeId: item.sourceNodeId,
},
})),
metadata: {
canvasCapabilityId: params.command.capabilityId,
canvasCapabilityRequestId: params.command.requestId,
canvasCapabilitySource: params.command.source,
canvasCapabilityVersion: params.command.version,
},
} satisfies CanvasOperationPlan;
}
function buildMutationOperationPlan(params: {
command: CanvasCapabilityClientCommand;
projectId: string;
projectRevision: string;
}): CanvasOperationPlan | null {
const common = {
version: 1 as const,
operationId: params.command.requestId,
projectId: params.projectId,
baseCanvasRevision: params.projectRevision,
targetNodeIds: params.command.nodeIds,
sourceNodeIds: params.command.nodeIds,
metadata: {
canvasCapabilityId: params.command.capabilityId,
canvasCapabilityRequestId: params.command.requestId,
canvasCapabilitySource: params.command.source,
},
};
if (params.command.capabilityId === "image.rename") {
return {
...common,
operation: "rename_node",
textContent: String(params.command.args.title || "").trim(),
};
}
if (params.command.capabilityId === "image.note") {
return {
...common,
operation: "update_note",
textContent: String(params.command.args.text || "").trim(),
};
}
if (params.command.capabilityId === "image.delete") {
return {
...common,
operation: "delete_nodes",
confirmed: params.command.confirmed === true,
};
}
return null;
}
function summarizeOperation(operation: Awaited<ReturnType<typeof applyCanvasOperationPlan>>) {
return {
canvasRevision: operation.canvasRevision,
canvasUpdatedAt: operation.canvasUpdatedAt,
createdNodeIds: operation.createdNodeIds,
createdNodeIdsByMediaItemId: operation.createdNodeIdsByMediaItemId,
updatedNodeIds: operation.updatedNodeIds,
};
}
export async function executeCanvasCapabilityRuntime(params: {
command: CanvasCapabilityClientCommand;
projectId: string;
requestHeaders: Headers;
requestUrl: string;
userId: string;
}): Promise<CanvasCapabilityRuntimeResult> {
const command = createCanvasCapabilityClientCommand({
capabilityId: params.command.capabilityId,
nodeIds: params.command.nodeIds,
projectId: params.projectId,
requestId: params.command.requestId,
args: params.command.args,
source: params.command.source,
confirmed: params.command.confirmed,
createdAt: params.command.createdAt,
});
const capability = getCanvasCapability(command.capabilityId);
if (!capability) {
throw new CanvasCapabilityRuntimeError("画布能力不存在", 404);
}
if (!(await isCanvasCapabilityEnabled(capability))) {
throw new CanvasCapabilityRuntimeError(
`${capability.title}已在系统设置中关闭`,
403,
"CANVAS_CAPABILITY_DISABLED"
);
}
const context = await resolveCanvasCapabilityContext({
command,
projectId: params.projectId,
requiresRemoteImage: capability.requiresRemoteImage,
userId: params.userId,
});
let task = await createOrRecoverCanvasCapabilityTask({
command,
userId: params.userId,
projectId: params.projectId,
});
if (task.status === "succeeded") {
const storedResult =
task.result && typeof task.result === "object"
? (task.result as Omit<CanvasCapabilityRuntimeResult, "task">)
: ({ mode: "headless" } as const);
return { ...storedResult, task: serializeTask(task) };
}
if (task.status === "running") {
return {
mode: shouldExecuteCanvasCapabilityHeadlessly({ capability, command })
? "headless"
: "interactive",
task: serializeTask(task),
};
}
if (
task.status === "failed" &&
task.attempts >= MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
) {
throw new CanvasCapabilityRuntimeError(
task.error || "画布能力任务超过最大重试次数",
409,
"CANVAS_CAPABILITY_RETRY_EXHAUSTED"
);
}
const shouldRunHeadlessly = shouldExecuteCanvasCapabilityHeadlessly({
capability,
command,
});
if (!shouldRunHeadlessly) {
task = await markCanvasCapabilityTaskRunning(task.id);
const result = {
mode: "interactive" as const,
clientCommand: command,
};
task = (await markCanvasCapabilityTaskSucceeded(task.id, result)) || task;
return { ...result, task: serializeTask(task) };
}
return enqueueCanvasCapabilityExecution({
projectId: params.projectId,
execute: async () => {
task = await markCanvasCapabilityTaskRunning(task.id);
try {
const projectRevision = context.project.updatedAt.toISOString();
const mutationPlan = buildMutationOperationPlan({
command,
projectId: params.projectId,
projectRevision,
});
const mediaResults = mutationPlan
? []
: await executeCanvasCapabilityHeadlessly({
command,
requestHeaders: params.requestHeaders,
requestUrl: params.requestUrl,
targets: context.targets,
userId: params.userId,
});
const operation = await applyCanvasOperationPlan({
userId: params.userId,
projectId: params.projectId,
plan:
mutationPlan ||
buildMediaOperationPlan({
command,
mediaResults,
projectId: params.projectId,
projectRevision,
}),
});
const result = {
mode: "headless" as const,
operation: summarizeOperation(operation),
outputs: mediaResults.map((item) => ({
assetId: item.assetId,
sourceNodeId: item.sourceNodeId,
title: item.title,
url: item.url,
})),
};
task = (await markCanvasCapabilityTaskSucceeded(task.id, result)) || task;
return { ...result, task: serializeTask(task) };
} catch (error) {
task = (await markCanvasCapabilityTaskFailed(task.id, error)) || task;
throw error;
}
},
});
}