229 lines
6.9 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import {
applyCanvasOperationPlan,
CanvasAgentMutationError,
} from "@/lib/canvas/agent-mutations";
import {
canApplyCanvasAgentMutations,
getCanvasAgentFeatureFlags,
} from "@/lib/canvas/agent-feature-flags";
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
const canvasOperationPlanSchema = z
.object({
version: z.literal(1),
operationId: z.string().min(1).max(160),
operation: z.enum([
"create_agent_slot",
"fill_slot",
"insert_beside",
"append_revision",
"replace_node_media",
"add_annotation",
"link_nodes",
"move_nodes",
"delete_nodes",
"update_text",
]),
projectId: z.string().uuid().optional(),
baseCanvasRevision: z.string().max(160).optional(),
baseCanvasUpdatedAt: z.string().max(160).optional(),
sourceNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(),
targetNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(),
targetSlotId: z.string().min(1).max(160).optional(),
anchorNodeId: z.string().min(1).max(160).optional(),
edgeType: z.enum(["default", "reference", "conversation"]).optional(),
textContent: z.string().min(1).max(4000).optional(),
confirmed: z.boolean().optional(),
layoutStrategy: z.enum(["row", "column", "grid"]).optional(),
spacing: z.number().positive().max(240).optional(),
targetPosition: z
.object({
x: z.number(),
y: z.number(),
})
.optional(),
slot: z
.object({
slotType: z.enum(["image", "video", "ui-screen"]),
label: z.string().max(120).optional(),
aspectRatio: z.string().max(32).optional(),
fillPolicy: z
.enum(["replace-empty", "append-version", "replace-existing"])
.optional(),
acceptedSources: z
.array(z.enum(["text", "image", "selection", "annotation"]))
.max(4)
.optional(),
})
.optional(),
annotations: z
.array(
z.object({
id: z.string().min(1).max(128).optional(),
sourceNodeId: z.string().min(1).max(160).optional(),
type: z.enum(["text-note", "arrow-note", "box", "freehand", "crop-box"]),
text: z.string().max(1000).optional(),
bounds: z
.object({
x: z.number(),
y: z.number(),
width: z.number(),
height: z.number(),
})
.optional(),
targetPoint: z
.object({
x: z.number(),
y: z.number(),
})
.optional(),
color: z.string().max(64).optional(),
createdAt: z.string().max(80).optional(),
})
)
.max(20)
.optional(),
placement: z
.enum(["right", "left", "below", "above", "viewport-center"])
.optional(),
placementStrategy: z
.enum(["single", "row", "grid", "overlay", "beside_each_source"])
.optional(),
preserveOriginal: z.boolean().optional(),
media: z
.object({
type: z.enum(["image", "video"]),
url: z.string().url(),
assetId: z.string().uuid().optional(),
width: z.number().positive().optional(),
height: z.number().positive().optional(),
title: z.string().max(256).optional(),
prompt: z.string().max(2000).optional(),
sourceNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(),
metadata: z.record(z.unknown()).optional(),
})
.optional(),
mediaItems: z
.array(
z.object({
mediaItemId: z.string().min(1).max(160),
type: z.enum(["image", "video"]),
url: z.string().url(),
assetId: z.string().uuid().optional(),
width: z.number().positive().optional(),
height: z.number().positive().optional(),
title: z.string().max(256).optional(),
prompt: z.string().max(2000).optional(),
sourceNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(),
metadata: z.record(z.unknown()).optional(),
})
)
.max(20)
.optional(),
metadata: z.record(z.unknown()).optional(),
})
.refine(
(value) =>
value.operation === "create_agent_slot" ||
value.operation === "link_nodes" ||
value.operation === "update_text" ||
value.operation === "add_annotation" ||
value.operation === "move_nodes" ||
value.operation === "delete_nodes" ||
value.media ||
(value.mediaItems?.length ?? 0) > 0,
{
message: "缺少媒体结果",
path: ["mediaItems"],
}
)
.refine(
(value) =>
value.operation !== "add_annotation" ||
Boolean(String(value.textContent || "").trim()) ||
(value.annotations?.length ?? 0) > 0,
{
message: "缺少批注内容",
path: ["annotations"],
}
)
.refine(
(value) =>
value.operation !== "update_text" ||
Boolean(String(value.textContent || "").trim()),
{
message: "缺少文本内容",
path: ["textContent"],
}
);
const requestSchema = z.object({
operationPlan: canvasOperationPlanSchema,
});
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const { id } = await params;
const canvasAgentFeatures = getCanvasAgentFeatureFlags();
if (!canApplyCanvasAgentMutations(canvasAgentFeatures)) {
return NextResponse.json(
{
success: false,
error: "当前画布 Agent 写回处于灰度保护,未修改画布",
code: "CANVAS_AGENT_WRITEBACK_DISABLED",
writebackMode: canvasAgentFeatures.writebackMode,
},
{ status: 409 }
);
}
const body = requestSchema.parse(await request.json());
const result = await applyCanvasOperationPlan({
userId: session.user.id,
projectId: id,
plan: body.operationPlan as CanvasOperationPlan,
});
return NextResponse.json({
success: true,
data: result,
});
} catch (error) {
if (error instanceof CanvasAgentMutationError) {
return NextResponse.json(
{
success: false,
error: error.message,
code: error.code,
},
{ status: error.status }
);
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: "请求参数无效", details: error.flatten() },
{ status: 400 }
);
}
console.error("[CanvasAgentOperations] apply operation failed:", error);
return NextResponse.json(
{ success: false, error: "执行画布操作失败" },
{ status: 500 }
);
}
}