304 lines
9.5 KiB
TypeScript
304 lines
9.5 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,
|
|
allowUnsavedDownloadTargets:
|
|
capability.id === "image.download" && capability.executionMode === "local" && capability.risk === "read",
|
|
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;
|
|
}
|
|
},
|
|
});
|
|
}
|