feat: add canvas agent operation protocol

This commit is contained in:
134 2026-06-22 21:45:23 +08:00
parent 12b21d6f3b
commit df0daf4d27
22 changed files with 5195 additions and 234 deletions

View File

@ -67,6 +67,12 @@ export function writeStructuredDataChunk(
);
}
function normalizeStreamIdList(value: unknown) {
return Array.isArray(value)
? value.map((id) => String(id || "").trim()).filter(Boolean)
: [];
}
export function writeProtocolDebugChunk(
writer: StreamWriter,
enabled: boolean,
@ -245,16 +251,10 @@ export function writeCanvasMediaChunk(
agentCategoryName: String(params.agentCategoryName || "").trim() || undefined,
agentExecutionSnapshotId:
String(params.agentExecutionSnapshotId || "").trim() || undefined,
mediaItemIds: Array.isArray(params.mediaItemIds)
? params.mediaItemIds
.map((id) => String(id || "").trim())
.filter(Boolean)
: [],
handledCanvasMediaItemIds: Array.isArray(params.handledCanvasMediaItemIds)
? params.handledCanvasMediaItemIds
.map((id) => String(id || "").trim())
.filter(Boolean)
: [],
mediaItemIds: normalizeStreamIdList(params.mediaItemIds),
handledCanvasMediaItemIds: normalizeStreamIdList(
params.handledCanvasMediaItemIds
),
},
label
);
@ -266,6 +266,7 @@ export function writeCanvasOperationChunk(
projectId: string;
operationId: string;
operation: string;
status?: "applied" | "failed" | "shadowed";
createdNodeIds: string[];
createdNodeIdsByMediaItemId?: Record<string, string>;
updatedNodeIds?: string[];
@ -273,6 +274,10 @@ export function writeCanvasOperationChunk(
canvasRevision?: string;
anchorNodeId?: string | null;
handledCanvasMediaItemIds?: string[];
mediaItemIds?: string[];
errorCode?: string;
errorMessage?: string;
fallbackToCanvasMedia?: boolean;
message?: string;
},
label: string
@ -284,13 +289,21 @@ export function writeCanvasOperationChunk(
projectId: params.projectId,
operationId: params.operationId,
operation: params.operation,
status: params.status || "applied",
createdNodeIds: params.createdNodeIds || [],
createdNodeIdsByMediaItemId: params.createdNodeIdsByMediaItemId || {},
updatedNodeIds: params.updatedNodeIds || [],
createdEdgeIds: params.createdEdgeIds || [],
canvasRevision: params.canvasRevision,
anchorNodeId: params.anchorNodeId || undefined,
handledCanvasMediaItemIds: params.handledCanvasMediaItemIds || [],
handledCanvasMediaItemIds:
params.status && params.status !== "applied"
? []
: normalizeStreamIdList(params.handledCanvasMediaItemIds),
mediaItemIds: normalizeStreamIdList(params.mediaItemIds),
errorCode: params.errorCode,
errorMessage: params.errorMessage,
fallbackToCanvasMedia: params.fallbackToCanvasMedia,
message: params.message,
},
label

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,10 @@ 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
@ -12,11 +16,15 @@ const canvasOperationPlanSchema = z
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(),
@ -37,6 +45,47 @@ const canvasOperationPlanSchema = z
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(),
@ -78,9 +127,12 @@ const canvasOperationPlanSchema = z
})
.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,
{
@ -88,6 +140,16 @@ const canvasOperationPlanSchema = z
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" ||
@ -116,6 +178,19 @@ export async function POST(
}
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,
@ -130,7 +205,11 @@ export async function POST(
} catch (error) {
if (error instanceof CanvasAgentMutationError) {
return NextResponse.json(
{ success: false, error: error.message },
{
success: false,
error: error.message,
code: error.code,
},
{ status: error.status }
);
}

View File

@ -29,6 +29,7 @@ import {
Loader2,
type LucideIcon,
MessageCircle,
MonitorSmartphone,
MousePointer,
Music2,
Palette,
@ -234,6 +235,7 @@ import type {
AgentWorkflowUiConfig,
} from "@/types/agent";
import type {
AgentSlotNodeData,
CanvasEdge,
CanvasNode,
CanvasState,
@ -1785,6 +1787,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
const updateNode = useCanvasStore((state) => state.updateNode);
const pushHistory = useCanvasStore((state) => state.pushHistory);
const setGenerating = useCanvasStore((state) => state.setGenerating);
const isAgentOperationRefreshActive = useCanvasStore(
(state) => state.isAgentOperationRefreshActive
);
// Local state
const [isLoading, setIsLoading] = useState(true);
@ -4477,7 +4482,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
// 自动保存逻辑
useEffect(() => {
if (isLoading || isAgentMode) return;
if (isLoading || isAgentMode || isAgentOperationRefreshActive()) return;
const saveTimer = setTimeout(async () => {
setIsSaving(true);
@ -4490,11 +4495,11 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
}, 3000); // 3秒自动保存
return () => clearTimeout(saveTimer);
}, [isAgentMode, nodes, isLoading, persistCanvasState]);
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
// 删除节点时立即保存(按节点ID差集检测),避免删除后快速刷新被回滚
useEffect(() => {
if (isLoading || isAgentMode) {
if (isLoading || isAgentMode || isAgentOperationRefreshActive()) {
previousNodeIdsRef.current = new Set(nodes.map((node) => node.id));
return;
}
@ -4521,7 +4526,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
};
void persistAfterDelete();
}
}, [isAgentMode, nodes, isLoading, persistCanvasState]);
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
// 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率
useEffect(() => {
@ -4529,6 +4534,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
const handleBeforeUnload = () => {
try {
if (isAgentOperationRefreshActive()) return;
const canvasData = buildPersistedCanvasData();
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
void fetch(`/api/canvas/${id}`, {
@ -4557,6 +4563,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
activeCanvasWorkspaceType,
buildPersistedCanvasData,
id,
isAgentOperationRefreshActive,
isAgentMode,
isLoading,
]);
@ -6773,9 +6780,34 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
});
}, [createCanvasNode, handleAddWorkflowNode, isVideoMode]);
const handleAddAgentImageSlotNode = useCallback(() => {
const handleAddAgentSlotNode = useCallback((slotType: AgentSlotNodeData["slotType"]) => {
if (isVideoMode) return;
const slotDefaults: Record<
AgentSlotNodeData["slotType"],
{
label: string;
size: CanvasNode["size"];
aspectRatio: string;
}
> = {
image: {
label: "图片槽位",
size: { width: 320, height: 320 },
aspectRatio: "1:1",
},
video: {
label: "视频槽位",
size: { width: 360, height: 220 },
aspectRatio: "16:9",
},
"ui-screen": {
label: "UI 槽位",
size: { width: 360, height: 260 },
aspectRatio: "16:9",
},
};
const defaults = slotDefaults[slotType];
const origin = getVisibleCanvasOrigin();
createCanvasNode({
type: "agent-slot",
@ -6783,17 +6815,18 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
x: origin.x + 40,
y: origin.y + 40,
},
size: { width: 320, height: 320 },
size: defaults.size,
data: {
slotType: "image",
label: "图片槽位",
slotType,
label: defaults.label,
status: "empty",
aspectRatio: "1:1",
aspectRatio: defaults.aspectRatio,
fillPolicy: "replace-empty",
acceptedSources: ["text", "image", "selection", "annotation"],
filledNodeIds: [],
metadata: {
createdBy: "toolbar",
createdSlotType: slotType,
createdAt: new Date().toISOString(),
},
},
@ -6801,6 +6834,10 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
});
}, [createCanvasNode, getVisibleCanvasOrigin, isVideoMode]);
const handleAddAgentImageSlotNode = useCallback(() => {
handleAddAgentSlotNode("image");
}, [handleAddAgentSlotNode]);
const handleAddVideoNode = useCallback(() => {
if (isVideoMode) {
handleAddWorkflowNode("video-input");
@ -10403,33 +10440,53 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
canvasToolbarConfig,
"addAgentImageSlot"
) ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-9 w-9 rounded-xl"
onClick={handleAddAgentImageSlotNode}
size="icon"
variant="ghost"
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={ImagePlus}
item={getCanvasToolbarItem(
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button className="h-9 w-9 rounded-xl" size="icon" variant="ghost">
<CanvasToolbarIcon
className="h-4 w-4"
fallback={ImagePlus}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"addAgentImageSlot"
)}
/>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">
{
getCanvasToolbarItem(
canvasToolbarConfig,
"addAgentImageSlot"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{
getCanvasToolbarItem(
canvasToolbarConfig,
"addAgentImageSlot"
).label
}
</TooltipContent>
</Tooltip>
).label
}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" side="bottom">
<DropdownMenuItem
onSelect={() => handleAddAgentImageSlotNode()}
>
<ImagePlus className="h-4 w-4" />
图片槽位
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleAddAgentSlotNode("video")}
>
<Video className="h-4 w-4" />
视频槽位
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleAddAgentSlotNode("ui-screen")}
>
<MonitorSmartphone className="h-4 w-4" />
UI 槽位
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Tooltip>

View File

@ -96,6 +96,11 @@ import {
isCanvasImageReadyForAi,
isCanvasImageUploadPending,
} from "@/lib/canvas/local-upload-state";
import {
computeCanvasAgentPanViewport,
resolveCanvasViewOperationTargetNodeIds,
} from "@/lib/canvas/agent-view-operations";
import { getCanvasAgentOperationErrorMessage } from "@/lib/canvas/agent-operation-errors";
import { getCanvasSkillIconComponent } from "@/components/canvas/canvas-skill-icons";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar";
@ -122,7 +127,10 @@ import {
getAgentProgressFallbackTitle,
getAgentTargetLockTitle,
getAgentTargetResolutionLabel,
getCanvasMediaPayloadHandledItemIds,
getCanvasOperationHandledMediaItemIds,
type ImageGenerationStatusStreamPayload,
shouldSkipCanvasMediaPayloadForHandledItems,
} from "@/components/canvas/chat-panel/canvas-agent-stream";
import {
type DetailArchive,
@ -1825,9 +1833,6 @@ export function CanvasChatPanel({
const store = useCanvasStore.getState();
const currentNodes = store.nodes;
const nodeIds = Array.isArray(payload.nodeIds)
? payload.nodeIds.map((id) => String(id || "").trim()).filter(Boolean)
: [];
const selectedIds = store.selectedNodeIds.filter(Boolean);
const zoomByFactor = (factor: number) => {
if (typeof window === "undefined") return;
@ -1859,15 +1864,31 @@ export function CanvasChatPanel({
});
return;
}
if (
payload.operation === "pan_to" &&
payload.center &&
typeof payload.center === "object" &&
typeof window !== "undefined"
) {
setTransientViewport(
computeCanvasAgentPanViewport({
currentViewport: useCanvasStore.getState().viewport,
center: payload.center,
stage: {
width: window.innerWidth,
height: window.innerHeight,
},
zoom: payload.zoom,
})
);
return;
}
const targetIds =
payload.operation === "focus_selection" && nodeIds.length === 0
? selectedIds
: nodeIds.length > 0
? nodeIds
: payload.anchorNodeId
? [payload.anchorNodeId]
: [];
const targetIds = resolveCanvasViewOperationTargetNodeIds({
plan: payload,
nodes: currentNodes,
selectedNodeIds: selectedIds,
});
const targetNodes =
payload.operation === "fit_all"
? currentNodes.filter((node) =>
@ -3593,6 +3614,48 @@ export function CanvasChatPanel({
]
);
useEffect(() => {
if (!effectiveAgentEnabled || typeof window === "undefined") return;
const handleAgentSlotFillRequest = (event: Event) => {
const detail = (event as CustomEvent<{ nodeId?: string; label?: string }>).detail;
const label = String(detail?.label || "这个槽位").trim();
const prompt = `在${label === "这个槽位" ? label : `「${label}」`}生成一张图`;
handleSelectGenerationMode("agent");
setInput(prompt);
window.setTimeout(() => {
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (!editableDiv) return;
editableDiv.innerHTML = "";
editableDiv.appendChild(document.createTextNode(prompt));
editableDiv.focus();
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(editableDiv);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}, 0);
toast.message("已选中槽位,可补充生成要求后发送");
};
window.addEventListener(
"canvas-agent-slot-fill-request",
handleAgentSlotFillRequest
);
return () => {
window.removeEventListener(
"canvas-agent-slot-fill-request",
handleAgentSlotFillRequest
);
};
}, [effectiveAgentEnabled, handleSelectGenerationMode]);
const applyVideoWorkspacePreset = useCallback(
(mode: VideoGenerationParams["mode"]) => {
if (!preferredVideoWorkspaceModel) {
@ -4002,19 +4065,19 @@ export function CanvasChatPanel({
try {
const result = await canvasService.getProject(targetProjectId);
if (!result.success || !result.data?.canvasData) {
useCanvasStore.getState().markAgentOperationRefresh();
void mutate(`/api/canvas/${targetProjectId}`);
return false;
}
useCanvasStore.getState().loadCanvas(result.data.canvasData);
useCanvasStore
.getState()
.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
const store = useCanvasStore.getState();
store.markAgentOperationRefresh();
store.loadCanvas(result.data.canvasData);
store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
if (createdNodeIds.length > 0) {
useCanvasStore.getState().selectNodes(createdNodeIds);
const focusNode = useCanvasStore
.getState()
.nodes.find((node) => node.id === createdNodeIds[0]);
const refreshedStore = useCanvasStore.getState();
refreshedStore.selectNodes(createdNodeIds);
const focusNode = refreshedStore.nodes.find((node) => node.id === createdNodeIds[0]);
if (focusNode) {
focusCanvasOnNode(focusNode);
}
@ -4023,6 +4086,7 @@ export function CanvasChatPanel({
return true;
} catch (error) {
console.error("[CanvasChat] Failed to refresh canvas after agent operation:", error);
useCanvasStore.getState().markAgentOperationRefresh();
void mutate(`/api/canvas/${targetProjectId}`);
return false;
}
@ -4156,16 +4220,29 @@ export function CanvasChatPanel({
if (dataPart.type === "data-canvasOperation") {
try {
const payload: CanvasOperationStreamPayload =
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
if (!payload || typeof payload !== "object") return;
const handledIds = Array.isArray(payload.handledCanvasMediaItemIds)
? payload.handledCanvasMediaItemIds
.map((id) => String(id || "").trim())
.filter(Boolean)
: [];
handledIds.forEach((id) => handledCanvasMediaItemIdsRef.current.add(id));
const targetProjectId = String(payload.projectId || "").trim();
const payload: CanvasOperationStreamPayload =
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
if (!payload || typeof payload !== "object") return;
if (payload.status === "failed") {
toast.warning(
getCanvasAgentOperationErrorMessage({
code: payload.errorCode,
fallbackMessage: payload.errorMessage || payload.message,
})
);
return;
}
if (payload.status === "shadowed") {
console.info("[CanvasChat] Canvas operation shadowed:", {
operationId: payload.operationId,
operation: payload.operation,
mediaItemIds: payload.mediaItemIds,
});
return;
}
const handledIds = getCanvasOperationHandledMediaItemIds(payload);
handledIds.forEach((id) => handledCanvasMediaItemIdsRef.current.add(id));
const targetProjectId = String(payload.projectId || "").trim();
if (targetProjectId) {
void refreshCanvasAfterAgentOperation({
projectId: targetProjectId,
@ -4194,27 +4271,20 @@ export function CanvasChatPanel({
if (dataPart.type === "data-canvasMedia") {
try {
const payload: CanvasMediaStreamPayload =
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
if (!payload || typeof payload !== "object") return;
const handledIds = Array.isArray(payload.handledCanvasMediaItemIds)
? payload.handledCanvasMediaItemIds
.map((id) => String(id || "").trim())
.filter(Boolean)
: [];
handledIds.forEach((id) => handledCanvasMediaItemIdsRef.current.add(id));
const mediaItemIds = Array.isArray(payload.mediaItemIds)
? payload.mediaItemIds
.map((id) => String(id || "").trim())
.filter(Boolean)
: [];
if (
mediaItemIds.length > 0 &&
mediaItemIds.every((id) => handledCanvasMediaItemIdsRef.current.has(id))
) {
clearStreamImageGenerationStateForChat(eventChatId);
return;
}
const payload: CanvasMediaStreamPayload =
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
if (!payload || typeof payload !== "object") return;
const handledIds = getCanvasMediaPayloadHandledItemIds(payload);
handledIds.forEach((id) => handledCanvasMediaItemIdsRef.current.add(id));
if (
shouldSkipCanvasMediaPayloadForHandledItems({
payload,
handledCanvasMediaItemIds: handledCanvasMediaItemIdsRef.current,
})
) {
clearStreamImageGenerationStateForChat(eventChatId);
return;
}
const imageUrls = Array.isArray(payload.imageUrls)
? payload.imageUrls.filter(
(url): url is string =>

View File

@ -4,7 +4,7 @@ import React, { useRef, useState, useCallback, useMemo } from "react";
import { Rnd } from "react-rnd";
import { motion } from "framer-motion";
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
import type { CanvasEdge, CanvasNode, Position } from "@/types/canvas";
import type { AgentSlotNodeData, CanvasEdge, CanvasNode, Position } from "@/types/canvas";
import { ImageNode } from "./nodes/ImageNode";
import { AgentSlotNode } from "./nodes/AgentSlotNode";
import { Model3DNode } from "./nodes/Model3DNode";
@ -26,14 +26,18 @@ import {
GitBranch,
Heart,
HelpCircle,
ImagePlus,
LayoutGrid,
Lightbulb,
Minus,
Pin,
Plus,
RefreshCw,
Star,
Tag,
type LucideIcon,
UserRound,
Video,
Wand2,
Zap,
} from "lucide-react";
@ -299,6 +303,12 @@ export function CanvasWorkspace({
vertical?: { x: number; top: number; bottom: number };
horizontal?: { y: number; left: number; right: number };
};
type AgentSlotContextMenuState = {
nodeId: string;
label: string;
x: number;
y: number;
} | null;
const containerRef = useRef<HTMLDivElement>(null);
const contentLayerRef = useRef<HTMLDivElement>(null);
const [isPanning, setIsPanning] = useState(false);
@ -310,6 +320,8 @@ export function CanvasWorkspace({
const [isDownloadSubmitting, setIsDownloadSubmitting] = useState(false);
const [snapGuides, setSnapGuides] = useState<SnapGuideLines | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [agentSlotContextMenu, setAgentSlotContextMenu] =
useState<AgentSlotContextMenuState>(null);
const [connectionDraft, setConnectionDraft] = useState<{
sourceId: string;
current: Position;
@ -578,6 +590,115 @@ export function CanvasWorkspace({
});
}, [updateNode]);
const handleAgentSlotDataChange = useCallback(
(node: CanvasNode, updates: Partial<AgentSlotNodeData>) => {
const currentData = (node.data || {}) as AgentSlotNodeData;
updateNode(node.id, {
data: {
...currentData,
...updates,
metadata: {
...(currentData.metadata || {}),
...(updates.metadata || {}),
},
},
});
},
[updateNode]
);
const openAgentSlotContextMenu = useCallback(
(event: React.MouseEvent, node: CanvasNode) => {
if (node.type !== "agent-slot") return;
event.preventDefault();
event.stopPropagation();
const rect = containerRef.current?.getBoundingClientRect();
const menuWidth = 236;
const menuHeight = 240;
const rawX = rect ? event.clientX - rect.left : event.clientX;
const rawY = rect ? event.clientY - rect.top : event.clientY;
const label =
String((node.data as AgentSlotNodeData | undefined)?.label || "").trim() ||
"Agent 槽位";
clearLatestGeneratedImageHighlights();
setSelectedEdgeId(null);
selectNode(node.id, false);
setAgentSlotContextMenu({
nodeId: node.id,
label,
x: Math.max(8, Math.min(rawX, Math.max(8, containerSize.width - menuWidth - 8))),
y: Math.max(8, Math.min(rawY, Math.max(8, containerSize.height - menuHeight - 8))),
});
},
[
clearLatestGeneratedImageHighlights,
containerSize.height,
containerSize.width,
selectNode,
]
);
const updateAgentSlotFromContextMenu = useCallback(
(updates: Partial<AgentSlotNodeData>) => {
if (!agentSlotContextMenu) return;
const node = nodes.find((item) => item.id === agentSlotContextMenu.nodeId);
if (!node || node.type !== "agent-slot") return;
handleAgentSlotDataChange(node, updates);
if (updates.label) {
setAgentSlotContextMenu((current) =>
current ? { ...current, label: String(updates.label) } : current
);
}
},
[agentSlotContextMenu, handleAgentSlotDataChange, nodes]
);
const updateAgentSlotTypeFromContextMenu = useCallback(
(slotType: NonNullable<AgentSlotNodeData["slotType"]>) => {
if (!agentSlotContextMenu) return;
const node = nodes.find((item) => item.id === agentSlotContextMenu.nodeId);
if (!node || node.type !== "agent-slot") return;
const data = (node.data || {}) as AgentSlotNodeData;
const currentLabel = String(data.label || "").trim();
const defaultLabels = new Set(["图片槽位", "视频槽位", "UI 槽位", "Agent 槽位"]);
const defaults =
slotType === "video"
? { label: "视频槽位", aspectRatio: "16:9" }
: slotType === "ui-screen"
? { label: "UI 槽位", aspectRatio: "16:9" }
: { label: "图片槽位", aspectRatio: "1:1" };
const nextLabel =
!currentLabel || defaultLabels.has(currentLabel) ? defaults.label : currentLabel;
handleAgentSlotDataChange(node, {
slotType,
label: nextLabel,
aspectRatio: defaults.aspectRatio,
});
setAgentSlotContextMenu((current) =>
current ? { ...current, label: nextLabel } : current
);
},
[agentSlotContextMenu, handleAgentSlotDataChange, nodes]
);
const requestFillAgentSlotFromContextMenu = useCallback(() => {
if (!agentSlotContextMenu || typeof window === "undefined") return;
selectNode(agentSlotContextMenu.nodeId, false);
window.dispatchEvent(
new CustomEvent("canvas-agent-slot-fill-request", {
detail: {
nodeId: agentSlotContextMenu.nodeId,
label: agentSlotContextMenu.label,
},
})
);
setAgentSlotContextMenu(null);
}, [agentSlotContextMenu, selectNode]);
const isMacPlatform = React.useMemo(() => {
if (typeof navigator === "undefined") return false;
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
@ -947,6 +1068,7 @@ export function CanvasWorkspace({
// 处理画布点击(取消选择)
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
if (hasBlockingDialogOpen()) return;
setAgentSlotContextMenu(null);
if (!isEventFromCanvasContainer(e.target)) return;
if (suppressCanvasClickRef.current) {
suppressCanvasClickRef.current = false;
@ -1738,8 +1860,14 @@ export function CanvasWorkspace({
)}
data-canvas-node-id={node.id}
style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部
onContextMenu={(event: React.MouseEvent) => {
if (node.type === "agent-slot") {
openAgentSlotContextMenu(event, node);
}
}}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setAgentSlotContextMenu(null);
clearLatestGeneratedImageHighlights();
setSelectedEdgeId(null);
selectNode(node.id, e.shiftKey);
@ -1822,6 +1950,7 @@ export function CanvasWorkspace({
<AgentSlotNode
node={node}
isSelected={isSelected}
onDataChange={(updates) => handleAgentSlotDataChange(node, updates)}
/>
)}
{node.type === 'text' && (
@ -1876,6 +2005,17 @@ export function CanvasWorkspace({
);
};
const agentSlotContextNode = agentSlotContextMenu
? nodes.find((node) => node.id === agentSlotContextMenu.nodeId)
: null;
const agentSlotContextData =
agentSlotContextNode?.type === "agent-slot"
? ((agentSlotContextNode.data || {}) as AgentSlotNodeData)
: null;
const agentSlotContextType = agentSlotContextData?.slotType || "image";
const agentSlotContextFillPolicy =
agentSlotContextData?.fillPolicy || "replace-empty";
return (
<div
ref={containerRef}
@ -2242,6 +2382,107 @@ export function CanvasWorkspace({
</div>
)}
{agentSlotContextMenu ? (
<div
className="absolute z-40 w-[236px] overflow-hidden rounded-lg border border-border/70 bg-background/96 p-1.5 text-foreground shadow-xl backdrop-blur"
data-canvas-overlay
style={{
left: agentSlotContextMenu.x,
top: agentSlotContextMenu.y,
}}
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseDown={(event) => event.stopPropagation()}
>
<div className="truncate px-2 py-1.5 text-[11px] text-muted-foreground">
{agentSlotContextMenu.label}
</div>
<button
type="button"
className="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition hover:bg-muted"
onClick={requestFillAgentSlotFromContextMenu}
>
<Wand2 className="h-4 w-4 text-[#5b76fe]" />
<span>填充此槽位</span>
</button>
<div className="my-1 h-px bg-border/70" />
<button
type="button"
className={cn(
"flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition hover:bg-muted",
agentSlotContextType === "image" && "bg-muted"
)}
onClick={() => updateAgentSlotTypeFromContextMenu("image")}
>
<ImagePlus className="h-4 w-4 text-[#5b76fe]" />
<span>设为图片槽</span>
{agentSlotContextType === "image" ? (
<Check className="ml-auto h-3.5 w-3.5 text-emerald-500" />
) : null}
</button>
<button
type="button"
className={cn(
"flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition hover:bg-muted",
agentSlotContextType === "video" && "bg-muted"
)}
onClick={() => updateAgentSlotTypeFromContextMenu("video")}
>
<Video className="h-4 w-4 text-[#5b76fe]" />
<span>设为视频槽</span>
{agentSlotContextType === "video" ? (
<Check className="ml-auto h-3.5 w-3.5 text-emerald-500" />
) : null}
</button>
<button
type="button"
className={cn(
"flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition hover:bg-muted",
agentSlotContextType === "ui-screen" && "bg-muted"
)}
onClick={() => updateAgentSlotTypeFromContextMenu("ui-screen")}
>
<LayoutGrid className="h-4 w-4 text-[#5b76fe]" />
<span>设为 UI 槽</span>
{agentSlotContextType === "ui-screen" ? (
<Check className="ml-auto h-3.5 w-3.5 text-emerald-500" />
) : null}
</button>
<div className="my-1 h-px bg-border/70" />
<button
type="button"
className={cn(
"flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition hover:bg-muted",
agentSlotContextFillPolicy === "append-version" && "bg-muted"
)}
onClick={() => updateAgentSlotFromContextMenu({ fillPolicy: "append-version" })}
>
<Plus className="h-4 w-4 text-[#5b76fe]" />
<span>保留版本</span>
{agentSlotContextFillPolicy === "append-version" ? (
<Check className="ml-auto h-3.5 w-3.5 text-emerald-500" />
) : null}
</button>
<button
type="button"
className={cn(
"flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition hover:bg-muted",
agentSlotContextFillPolicy === "replace-existing" && "bg-muted"
)}
onClick={() => updateAgentSlotFromContextMenu({ fillPolicy: "replace-existing" })}
>
<RefreshCw className="h-4 w-4 text-[#5b76fe]" />
<span>允许替换</span>
{agentSlotContextFillPolicy === "replace-existing" ? (
<Check className="ml-auto h-3.5 w-3.5 text-emerald-500" />
) : null}
</button>
</div>
) : null}
{showZoomControls ? (
<div
className={cn(

View File

@ -19,6 +19,7 @@ export type CanvasOperationStreamPayload = {
projectId?: string;
operationId?: string;
operation?: string;
status?: "applied" | "failed" | "shadowed";
createdNodeIds?: string[];
createdNodeIdsByMediaItemId?: Record<string, string>;
updatedNodeIds?: string[];
@ -26,11 +27,43 @@ export type CanvasOperationStreamPayload = {
canvasRevision?: string;
anchorNodeId?: string;
handledCanvasMediaItemIds?: string[];
mediaItemIds?: string[];
errorCode?: string;
errorMessage?: string;
fallbackToCanvasMedia?: boolean;
message?: string;
};
export type CanvasViewOperationStreamPayload = CanvasViewOperationPlan;
export function normalizeCanvasStreamIdList(value: unknown) {
return Array.isArray(value)
? value.map((id) => String(id || "").trim()).filter(Boolean)
: [];
}
export function getCanvasOperationHandledMediaItemIds(
payload: CanvasOperationStreamPayload
) {
if (payload.status === "failed" || payload.status === "shadowed") return [];
return normalizeCanvasStreamIdList(payload.handledCanvasMediaItemIds);
}
export function getCanvasMediaPayloadHandledItemIds(payload: CanvasMediaStreamPayload) {
return normalizeCanvasStreamIdList(payload.handledCanvasMediaItemIds);
}
export function shouldSkipCanvasMediaPayloadForHandledItems(params: {
payload: CanvasMediaStreamPayload;
handledCanvasMediaItemIds: ReadonlySet<string>;
}) {
const mediaItemIds = normalizeCanvasStreamIdList(params.payload.mediaItemIds);
return (
mediaItemIds.length > 0 &&
mediaItemIds.every((id) => params.handledCanvasMediaItemIds.has(id))
);
}
export type ImageGenerationStatusStreamPayload = {
phase?: "queued" | "processing" | "finalizing" | "failed" | "completed";
providerType?: string;

View File

@ -1,14 +1,72 @@
"use client";
import { ImagePlus, MonitorSmartphone, Sparkles, Video } from "lucide-react";
import {
FileText,
ImagePlus,
Layers,
MonitorSmartphone,
MousePointer2,
PencilLine,
Settings2,
Sparkles,
Video,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import type { AgentSlotNodeData, CanvasNode } from "@/types/canvas";
import { cn } from "@/lib/utils";
interface AgentSlotNodeProps {
node: CanvasNode;
isSelected: boolean;
onDataChange?: (updates: Partial<AgentSlotNodeData>) => void;
}
type SlotType = AgentSlotNodeData["slotType"];
type SlotFillPolicy = NonNullable<AgentSlotNodeData["fillPolicy"]>;
type SlotAcceptedSource = NonNullable<AgentSlotNodeData["acceptedSources"]>[number];
const SLOT_TYPE_OPTIONS: Array<{
value: SlotType;
label: string;
icon: typeof ImagePlus;
}> = [
{ value: "image", label: "图片槽位", icon: ImagePlus },
{ value: "video", label: "视频槽位", icon: Video },
{ value: "ui-screen", label: "UI 槽位", icon: MonitorSmartphone },
];
const FILL_POLICY_OPTIONS: Array<{
value: SlotFillPolicy;
label: string;
}> = [
{ value: "replace-empty", label: "填充空槽" },
{ value: "append-version", label: "追加版本" },
{ value: "replace-existing", label: "允许替换" },
];
const ACCEPTED_SOURCE_OPTIONS: Array<{
value: SlotAcceptedSource;
label: string;
icon: typeof FileText;
}> = [
{ value: "text", label: "文本", icon: FileText },
{ value: "image", label: "图片", icon: ImagePlus },
{ value: "selection", label: "选中对象", icon: MousePointer2 },
{ value: "annotation", label: "标注", icon: PencilLine },
];
const ASPECT_RATIO_OPTIONS = ["1:1", "4:3", "3:4", "16:9", "9:16"];
function getSlotIcon(slotType: AgentSlotNodeData["slotType"]) {
if (slotType === "video") return Video;
if (slotType === "ui-screen") return MonitorSmartphone;
@ -28,18 +86,87 @@ function getStatusLabel(status: AgentSlotNodeData["status"]) {
return "等待生成";
}
export function AgentSlotNode({ node, isSelected }: AgentSlotNodeProps) {
function getFillPolicyLabel(fillPolicy: AgentSlotNodeData["fillPolicy"]) {
if (fillPolicy === "append-version") return "追加版本";
if (fillPolicy === "replace-existing") return "允许替换";
return "填充空槽";
}
function normalizeAcceptedSources(
acceptedSources: AgentSlotNodeData["acceptedSources"]
): SlotAcceptedSource[] {
const normalized = (Array.isArray(acceptedSources) ? acceptedSources : [])
.filter((value): value is SlotAcceptedSource =>
ACCEPTED_SOURCE_OPTIONS.some((option) => option.value === value)
);
if (normalized.length > 0) {
return Array.from(new Set(normalized));
}
return ACCEPTED_SOURCE_OPTIONS.map((option) => option.value);
}
function getAcceptedSourcesLabel(acceptedSources: SlotAcceptedSource[]) {
return ACCEPTED_SOURCE_OPTIONS.filter((option) =>
acceptedSources.includes(option.value)
)
.map((option) => option.label)
.join("、");
}
function isSlotType(value: string): value is SlotType {
return SLOT_TYPE_OPTIONS.some((option) => option.value === value);
}
function isSlotFillPolicy(value: string): value is SlotFillPolicy {
return FILL_POLICY_OPTIONS.some((option) => option.value === value);
}
export function AgentSlotNode({
node,
isSelected,
onDataChange,
}: AgentSlotNodeProps) {
const data = node.data as AgentSlotNodeData;
const slotType = data.slotType || "image";
const acceptedSources = normalizeAcceptedSources(data.acceptedSources);
const SlotIcon = getSlotIcon(slotType);
const filledCount = Array.isArray(data.filledNodeIds)
? data.filledNodeIds.length
: 0;
const fillPolicy = data.fillPolicy || "replace-empty";
const aspectRatio = data.aspectRatio || "1:1";
const canEdit = Boolean(onDataChange);
const updateSlotData = (updates: Partial<AgentSlotNodeData>) => {
if (!onDataChange) return;
onDataChange({
...updates,
metadata: {
...(data.metadata || {}),
...(updates.metadata || {}),
agentSlotConfiguredAt: new Date().toISOString(),
},
});
};
const toggleAcceptedSource = (
source: SlotAcceptedSource,
checked: boolean
) => {
const next = checked
? Array.from(new Set([...acceptedSources, source]))
: acceptedSources.filter((value) => value !== source);
if (next.length === 0) return;
updateSlotData({ acceptedSources: next });
};
return (
<div
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-none border-2 border-dashed bg-white/92 text-slate-700 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition-all dark:bg-zinc-950/92 dark:text-slate-100",
"relative flex h-full w-full flex-col overflow-hidden rounded-none border-2 border-dashed bg-white/92 text-slate-700 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition-all dark:bg-zinc-950/92 dark:text-slate-100",
isSelected
? "border-[#5b76fe] ring-4 ring-[#5b76fe]/12"
: "border-[#b8c3d8] hover:border-[#8ea2d6] dark:border-zinc-700",
@ -49,6 +176,145 @@ export function AgentSlotNode({ node, isSelected }: AgentSlotNodeProps) {
data-agent-slot-node="true"
data-slot-type={slotType}
>
{canEdit ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="配置 Agent 槽位"
className={cn(
"absolute top-2 right-2 z-10 flex h-8 w-8 items-center justify-center border border-[#dbe2ee] bg-white/95 text-slate-500 shadow-sm transition hover:border-[#a9b8df] hover:text-[#5b76fe] dark:border-zinc-700 dark:bg-zinc-900 dark:text-slate-300",
isSelected
? "opacity-100"
: "opacity-0 group-hover:opacity-100"
)}
data-canvas-no-drag="true"
title="配置 Agent 槽位"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<Settings2 className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-72"
data-canvas-no-drag="true"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
sideOffset={8}
>
<div className="px-2 py-1.5">
<label
className="mb-1 block font-medium text-[12px] text-muted-foreground"
htmlFor={`agent-slot-label-${node.id}`}
>
槽位名称
</label>
<Input
id={`agent-slot-label-${node.id}`}
className="h-8 text-[13px]"
value={data.label || ""}
placeholder={getSlotTypeLabel(slotType)}
onChange={(event) =>
updateSlotData({ label: event.target.value })
}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
/>
</div>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[12px] text-muted-foreground">
槽位类型
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={slotType}
onValueChange={(value) => {
if (!isSlotType(value)) return;
updateSlotData({ slotType: value });
}}
>
{SLOT_TYPE_OPTIONS.map((option) => {
const OptionIcon = option.icon;
return (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<OptionIcon className="h-4 w-4" />
{option.label}
</DropdownMenuRadioItem>
);
})}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[12px] text-muted-foreground">
画幅
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={aspectRatio}
onValueChange={(value) => {
if (!ASPECT_RATIO_OPTIONS.includes(value)) return;
updateSlotData({ aspectRatio: value });
}}
>
{ASPECT_RATIO_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option} value={option}>
<Layers className="h-4 w-4" />
{option}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[12px] text-muted-foreground">
填充策略
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={fillPolicy}
onValueChange={(value) => {
if (!isSlotFillPolicy(value)) return;
updateSlotData({ fillPolicy: value });
}}
>
{FILL_POLICY_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[12px] text-muted-foreground">
可用来源
</DropdownMenuLabel>
{ACCEPTED_SOURCE_OPTIONS.map((option) => {
const OptionIcon = option.icon;
const checked = acceptedSources.includes(option.value);
return (
<DropdownMenuCheckboxItem
key={option.value}
checked={checked}
disabled={checked && acceptedSources.length === 1}
onCheckedChange={(nextChecked) =>
toggleAcceptedSource(option.value, Boolean(nextChecked))
}
onSelect={(event) => event.preventDefault()}
>
<OptionIcon className="mr-2 h-4 w-4" />
{option.label}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-5 py-4 text-center">
<div
className={cn(
@ -74,14 +340,11 @@ export function AgentSlotNode({ node, isSelected }: AgentSlotNodeProps) {
</div>
</div>
<div className="flex items-center justify-between border-[#e6ebf5] border-t px-3 py-2 text-[11px] text-slate-500 dark:border-zinc-800 dark:text-slate-400">
<span className="truncate">{data.aspectRatio || "1:1"}</span>
<span className="truncate">
{data.fillPolicy === "append-version"
? "追加版本"
: data.fillPolicy === "replace-existing"
? "允许替换"
: "填充空槽"}
<span className="truncate">{aspectRatio}</span>
<span className="min-w-0 flex-1 truncate px-2 text-center">
{getAcceptedSourcesLabel(acceptedSources)}
</span>
<span className="truncate">{getFillPolicyLabel(fillPolicy)}</span>
</div>
</div>
);

View File

@ -115,8 +115,16 @@ import {
ThreeDGenerationDialog,
} from "@/components/canvas/nodes/image-node/image-node-dialogs";
import { ImageNodeSelectedTitle } from "@/components/canvas/nodes/image-node/image-node-title";
import { ImageAnnotator } from "@/components/image-annotator";
import {
ImageAnnotator,
type ImageAnnotatorConfirmPayload,
} from "@/components/image-annotator";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
import {
appendCanvasVisualAnnotationsMetadata,
syncCanvasLocalEditRegionAnnotationsMetadata,
syncCanvasNodeNoteAnnotationMetadata,
} from "@/lib/canvas/agent-annotation-metadata";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -245,6 +253,25 @@ interface ImageOverlayRectStyle {
height: number;
}
interface CanvasNodeAnnotationDisplayItem {
id: string;
type: "text-note" | "arrow-note" | "box" | "freehand" | "crop-box";
text?: string;
bounds?: {
x: number;
y: number;
width: number;
height: number;
};
targetPoint?: {
x: number;
y: number;
};
color?: string;
createdAt?: string;
createdByAgent?: boolean;
}
interface LayerSplitBounds {
x: number;
y: number;
@ -1127,6 +1154,120 @@ async function drawMarkerOnImage(
});
}
function normalizeCanvasNodeAnnotations(
metadata: unknown
): CanvasNodeAnnotationDisplayItem[] {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
return [];
}
const rawAnnotations = (metadata as Record<string, unknown>).annotations;
if (!Array.isArray(rawAnnotations)) return [];
return rawAnnotations
.map((annotation, index): CanvasNodeAnnotationDisplayItem | null => {
if (!annotation || typeof annotation !== "object" || Array.isArray(annotation)) {
return null;
}
const raw = annotation as Record<string, unknown>;
const type = String(raw.type || "text-note");
if (
type !== "text-note" &&
type !== "arrow-note" &&
type !== "box" &&
type !== "freehand" &&
type !== "crop-box"
) {
return null;
}
const normalizePoint = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const point = value as Record<string, unknown>;
const x = Number(point.x);
const y = Number(point.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined;
return { x, y };
};
const normalizeBounds = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const bounds = value as Record<string, unknown>;
const x = Number(bounds.x);
const y = Number(bounds.y);
const width = Number(bounds.width);
const height = Number(bounds.height);
if (
!Number.isFinite(x) ||
!Number.isFinite(y) ||
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return undefined;
}
return { x, y, width, height };
};
return {
id: String(raw.id || `annotation-${index + 1}`).slice(0, 128),
type: type as CanvasNodeAnnotationDisplayItem["type"],
text:
typeof raw.text === "string"
? String(raw.text).trim().slice(0, 180)
: undefined,
bounds: normalizeBounds(raw.bounds),
targetPoint: normalizePoint(raw.targetPoint),
color:
typeof raw.color === "string" ? String(raw.color).slice(0, 64) : undefined,
createdAt:
typeof raw.createdAt === "string"
? String(raw.createdAt).slice(0, 64)
: undefined,
createdByAgent: raw.createdByAgent === true,
};
})
.filter((annotation): annotation is CanvasNodeAnnotationDisplayItem =>
Boolean(annotation)
)
.slice(-12);
}
function getCanvasAnnotationBoundsStyle(
annotation: CanvasNodeAnnotationDisplayItem,
metrics: ImageDisplayMetrics | null
): React.CSSProperties | null {
if (!metrics || !annotation.bounds) return null;
const { bounds } = annotation;
const normalized =
Math.abs(bounds.x) <= 1 &&
Math.abs(bounds.y) <= 1 &&
Math.abs(bounds.width) <= 1 &&
Math.abs(bounds.height) <= 1;
if (normalized) {
return {
left: metrics.offsetX + bounds.x * metrics.renderedWidth,
top: metrics.offsetY + bounds.y * metrics.renderedHeight,
width: Math.max(bounds.width * metrics.renderedWidth, 1),
height: Math.max(bounds.height * metrics.renderedHeight, 1),
};
}
const scaleX =
metrics.naturalWidth > 0 ? metrics.renderedWidth / metrics.naturalWidth : 1;
const scaleY =
metrics.naturalHeight > 0 ? metrics.renderedHeight / metrics.naturalHeight : 1;
return {
left: metrics.offsetX + bounds.x * scaleX,
top: metrics.offsetY + bounds.y * scaleY,
width: Math.max(bounds.width * scaleX, 1),
height: Math.max(bounds.height * scaleY, 1),
};
}
interface ImageNodeProps {
node: CanvasNode;
isSelected: boolean;
@ -1184,6 +1325,14 @@ export function ImageNode({
"/api/user/membership-summary",
fetcher
);
const canvasAgentAnnotations = useMemo(
() => normalizeCanvasNodeAnnotations(data.metadata),
[data.metadata]
);
const visibleCanvasAgentAnnotations = useMemo(
() => canvasAgentAnnotations.slice(-3),
[canvasAgentAnnotations]
);
const imageNodeMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNodeMore");
const imageAddToChatItem = getCanvasToolbarItem(canvasToolbarConfig, "imageAddToChat");
const imagePickObjectItem = getCanvasToolbarItem(canvasToolbarConfig, "imagePickObject");
@ -2579,10 +2728,20 @@ export function ImageNode({
delete nextMetadata[LOCAL_EDIT_METADATA_KEY];
}
const syncedAnnotationMetadata =
syncCanvasLocalEditRegionAnnotationsMetadata({
metadata: nextMetadata,
nodeId: node.id,
regions: nextRegions,
});
updateNode(node.id, {
data: {
...data,
metadata: Object.keys(nextMetadata).length > 0 ? nextMetadata : undefined,
metadata:
Object.keys(syncedAnnotationMetadata.metadata).length > 0
? syncedAnnotationMetadata.metadata
: undefined,
},
});
},
@ -5557,10 +5716,19 @@ export function ImageNode({
const handleSaveNote = useCallback(() => {
const nextNote = draftNote.trim();
const noteAnnotationMetadata = syncCanvasNodeNoteAnnotationMetadata({
metadata: data.metadata,
nodeId: node.id,
note: nextNote,
});
updateNode(node.id, {
data: {
...data,
note: nextNote || undefined,
metadata:
Object.keys(noteAnnotationMetadata.metadata).length > 0
? noteAnnotationMetadata.metadata
: undefined,
},
});
setDraftNote(nextNote);
@ -6700,7 +6868,10 @@ export function ImageNode({
);
// 图片标注完成回调
const handleAnnotateComplete = async (annotatedImageUrl: string) => {
const handleAnnotateComplete = async (
annotatedImageUrl: string,
payload?: ImageAnnotatorConfirmPayload
) => {
setAnnotatorOpen(false);
// 如果仍然是 base64 格式,先上传到服务器(ImageAnnotator 应该已经处理了,这里作为保护)
@ -6728,6 +6899,14 @@ export function ImageNode({
}
}
const visualAnnotationMetadata = appendCanvasVisualAnnotationsMetadata({
metadata: data.metadata,
nodeId: node.id,
actions: payload?.actions || [],
imageWidth: payload?.imageWidth,
imageHeight: payload?.imageHeight,
});
// 更新当前节点,标记为已标注图片
// 标注后的图片会带有视觉标记(箭头、圆圈、文字等)
// 这些标记指示了用户希望 AI 重点关注/修改的区域
@ -6738,6 +6917,10 @@ export function ImageNode({
hasAnnotations: true, // 🆕 标记为已标注
originalSrc: data.originalSrc || data.src, // 保留原始图片
prompt: data.prompt ? `${data.prompt} (已标注)` : "已标注图片",
metadata:
Object.keys(visualAnnotationMetadata.metadata).length > 0
? visualAnnotationMetadata.metadata
: undefined,
},
});
@ -8161,6 +8344,64 @@ export function ImageNode({
/>
)
) : null}
{canvasAgentAnnotations.length > 0 &&
!boxCutoutMode &&
!localEditMode &&
!cropPanelOpen &&
!isThisNodePicking ? (
<div
className="pointer-events-none absolute inset-0 z-[132]"
data-canvas-agent-annotations="true"
>
{boxCutoutMetrics
? canvasAgentAnnotations.map((annotation) => {
const boundsStyle = getCanvasAnnotationBoundsStyle(
annotation,
boxCutoutMetrics
);
if (!boundsStyle) return null;
const color = annotation.color || "#f59e0b";
return (
<div
className="absolute rounded-[3px] border-2 border-dashed bg-transparent"
key={`agent-annotation-bounds-${annotation.id}`}
style={{
...boundsStyle,
borderColor: color,
boxShadow: `0 0 0 1px ${color}33`,
}}
/>
);
})
: null}
<div className="absolute right-2 bottom-2 flex max-w-[calc(100%-1rem)] flex-col items-end gap-1">
<div
className="inline-flex max-w-full items-center gap-1 rounded-full border border-amber-200 bg-white/95 px-2 py-1 font-medium text-[11px] text-amber-700 shadow-[0_8px_20px_rgba(15,23,42,0.14)] dark:border-amber-400/30 dark:bg-zinc-950/88 dark:text-amber-200"
title={visibleCanvasAgentAnnotations
.map((annotation) => annotation.text)
.filter(Boolean)
.join(" / ")}
>
<MessageSquarePlus className="h-3 w-3 shrink-0" />
<span className="shrink-0">批注</span>
<span className="shrink-0">{canvasAgentAnnotations.length}</span>
</div>
{isSelected
? visibleCanvasAgentAnnotations
.filter((annotation) => annotation.text)
.map((annotation) => (
<div
className="max-w-[220px] truncate rounded-md border border-amber-200 bg-white/95 px-2 py-1 text-right text-[11px] text-slate-700 shadow-[0_8px_20px_rgba(15,23,42,0.12)] dark:border-amber-400/25 dark:bg-zinc-950/88 dark:text-slate-100"
key={`agent-annotation-text-${annotation.id}`}
title={annotation.text}
>
{annotation.text}
</div>
))
: null}
</div>
</div>
) : null}
</>
)}
@ -8495,7 +8736,9 @@ export function ImageNode({
</div>
);
})}
{typeof document !== "undefined" && localEditPromptPortalFrame
{(localEditOperationMode as CanvasLocalEditOperationMode) !== "scale_adjust" &&
typeof document !== "undefined" &&
localEditPromptPortalFrame
? createPortal(
<div
className="pointer-events-none fixed z-[9998] overflow-visible"
@ -8523,7 +8766,7 @@ export function ImageNode({
style={style}
>
<div className="flex items-center gap-1.5">
{isEditing && localEditOperationMode !== "scale_adjust" ? (
{isEditing ? (
<input
autoFocus={editingLocalEditRegionId === region.id}
className="nodrag nopan nowheel h-9 min-w-0 flex-1 rounded-full border border-slate-300 bg-white/95 px-3 text-[12px] font-medium leading-5 text-slate-950 caret-slate-950 shadow-[0_10px_24px_rgba(15,23,42,0.14)] outline-none ring-2 ring-transparent transition placeholder:text-slate-400 focus:border-slate-950 focus:ring-slate-950/15 dark:border-white/20 dark:bg-slate-950/88 dark:text-white dark:caret-white dark:placeholder:text-white/42 dark:focus:border-white/40 dark:focus:ring-white/18"
@ -8642,9 +8885,7 @@ export function ImageNode({
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
if (localEditOperationMode !== "scale_adjust") {
setEditingLocalEditRegionId(region.id);
}
setEditingLocalEditRegionId(region.id);
}}
onMouseDown={(event) => {
event.preventDefault();
@ -8655,36 +8896,12 @@ export function ImageNode({
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
if (localEditOperationMode !== "scale_adjust") {
setEditingLocalEditRegionId(region.id);
}
setEditingLocalEditRegionId(region.id);
}}
title={
localEditOperationMode === "scale_adjust"
? region.targetRect
? getCanvasScaleAdjustLabel(
deriveCanvasScalePercentFromRects(
region.rect,
region.targetRect
)
)
: "请框选青色目标框"
: region.prompt
}
title={region.prompt}
type="button"
>
{localEditOperationMode === "scale_adjust"
? `${region.index}. ${
region.targetRect
? getCanvasScaleAdjustLabel(
deriveCanvasScalePercentFromRects(
region.rect,
region.targetRect
)
)
: "请框选目标框"
}`
: `${region.index}. ${region.prompt}`}
{`${region.index}. ${region.prompt}`}
</button>
)}
<input
@ -8705,9 +8922,8 @@ export function ImageNode({
/>
<button
className={cn(
"nodrag nopan nowheel flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white/95 text-slate-600 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-slate-950 hover:text-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-white dark:hover:text-slate-950",
localEditOperationMode === "scale_adjust" && "hidden"
)}
"nodrag nopan nowheel flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white/95 text-slate-600 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-slate-950 hover:text-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-white dark:hover:text-slate-950"
)}
data-canvas-no-drag="true"
data-local-edit-control="true"
disabled={

View File

@ -67,7 +67,7 @@ type Tool = "pen" | "arrow" | "circle" | "text";
const logger = createClientLogger("image-annotator");
type DrawingAction = {
export type ImageAnnotatorDrawingAction = {
type: Tool;
color: string;
lineWidth: number;
@ -81,9 +81,18 @@ type DrawingAction = {
fontSize?: number;
};
export interface ImageAnnotatorConfirmPayload {
actions: ImageAnnotatorDrawingAction[];
imageWidth?: number;
imageHeight?: number;
}
interface ImageAnnotatorProps {
imageUrl?: string;
onConfirm: (annotatedImageUrl: string) => void | Promise<void>;
onConfirm: (
annotatedImageUrl: string,
payload?: ImageAnnotatorConfirmPayload
) => void | Promise<void>;
onCancel: () => void;
onHomeClick?: () => void;
initialResizeMode?: boolean; // 是否直接进入尺寸调整模式
@ -115,10 +124,9 @@ export function ImageAnnotator({
const [tool] = useState<Tool>("pen");
const color = "#FF0000";
const lineWidth = 4;
const [actions, setActions] = useState<DrawingAction[]>([]);
const [currentAction, setCurrentAction] = useState<DrawingAction | null>(
null
);
const [actions, setActions] = useState<ImageAnnotatorDrawingAction[]>([]);
const [currentAction, setCurrentAction] =
useState<ImageAnnotatorDrawingAction | null>(null);
const [textInput, setTextInput] = useState("");
const [textPosition, setTextPosition] = useState<{
x: number;
@ -610,7 +618,7 @@ export function ImageAnnotator({
context: CanvasRenderingContext2D,
img: HTMLImageElement,
currentScale: number,
drawActions: DrawingAction[]
drawActions: ImageAnnotatorDrawingAction[]
) => {
const canvas = context.canvas;
context.clearRect(0, 0, canvas.width, canvas.height);
@ -636,7 +644,7 @@ export function ImageAnnotator({
// 绘制单个动作
const drawAction = (
context: CanvasRenderingContext2D,
action: DrawingAction,
action: ImageAnnotatorDrawingAction,
currentScale: number
) => {
context.strokeStyle = action.color;
@ -770,7 +778,7 @@ export function ImageAnnotator({
setIsDrawing(true);
const pos = getPosition(e);
const newAction: DrawingAction = {
const newAction: ImageAnnotatorDrawingAction = {
type: tool,
color,
lineWidth,
@ -833,7 +841,7 @@ export function ImageAnnotator({
const handleAddText = () => {
if (!textInput.trim() || !textPosition) return;
const textAction: DrawingAction = {
const textAction: ImageAnnotatorDrawingAction = {
type: "text",
color,
lineWidth,
@ -1742,7 +1750,11 @@ export function ImageAnnotator({
return;
}
await onConfirm(finalUrl);
await onConfirm(finalUrl, {
actions,
imageWidth: image?.width,
imageHeight: image?.height,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "处理失败,请重试";

View File

@ -0,0 +1,491 @@
import type { CanvasAgentAnnotationRef } from "@/types/canvas/agent-protocol";
export const CANVAS_NODE_NOTE_ANNOTATION_SOURCE = "image-node-note";
export const CANVAS_VISUAL_ANNOTATION_SOURCE = "image-visual-annotation";
export const CANVAS_LOCAL_EDIT_REGION_ANNOTATION_SOURCE =
"image-local-edit-region";
type CanvasAnnotationMetadataRecord = Partial<CanvasAgentAnnotationRef> & {
id?: string;
source?: string;
createdByUser?: boolean;
visualActionType?: string;
localEditRegionId?: string;
updatedAt?: string;
};
interface CanvasVisualAnnotationActionInput {
type?: string;
color?: string;
points?: Array<{ x?: number; y?: number }>;
startX?: number;
startY?: number;
endX?: number;
endY?: number;
radius?: number;
text?: string;
}
interface CanvasLocalEditRegionAnnotationInput {
id?: string;
index?: number;
color?: string;
prompt?: string;
rect?: {
x?: number;
y?: number;
width?: number;
height?: number;
};
targetRect?: {
x?: number;
y?: number;
width?: number;
height?: number;
} | null;
createdAt?: number;
}
interface SyncCanvasNodeNoteAnnotationMetadataParams {
metadata: unknown;
nodeId: string;
note: string;
now?: string;
}
interface SyncCanvasNodeNoteAnnotationMetadataResult {
metadata: Record<string, unknown>;
annotations: CanvasAnnotationMetadataRecord[];
hasStructuredAnnotations: boolean;
noteAnnotationId: string;
}
interface AppendCanvasVisualAnnotationsMetadataParams {
metadata: unknown;
nodeId: string;
actions: CanvasVisualAnnotationActionInput[];
imageWidth?: number;
imageHeight?: number;
now?: string;
}
interface AppendCanvasVisualAnnotationsMetadataResult {
metadata: Record<string, unknown>;
annotations: CanvasAnnotationMetadataRecord[];
appendedAnnotations: CanvasAnnotationMetadataRecord[];
hasStructuredAnnotations: boolean;
}
interface SyncCanvasLocalEditRegionAnnotationsMetadataParams {
metadata: unknown;
nodeId: string;
regions: CanvasLocalEditRegionAnnotationInput[];
now?: string;
}
interface SyncCanvasLocalEditRegionAnnotationsMetadataResult {
metadata: Record<string, unknown>;
annotations: CanvasAnnotationMetadataRecord[];
regionAnnotations: CanvasAnnotationMetadataRecord[];
hasStructuredAnnotations: boolean;
}
function normalizeCanvasMetadata(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value)
? { ...(value as Record<string, unknown>) }
: {};
}
function normalizeCanvasAnnotationRecord(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value)
? ({ ...(value as Record<string, unknown>) } as CanvasAnnotationMetadataRecord)
: null;
}
export function buildCanvasNodeNoteAnnotationId(nodeId: string) {
const normalizedNodeId = String(nodeId || "node").trim().slice(0, 96) || "node";
return `${CANVAS_NODE_NOTE_ANNOTATION_SOURCE}:${normalizedNodeId}`.slice(0, 128);
}
function clampCanvasAnnotationNumber(value: number, min: number, max: number) {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
function roundCanvasAnnotationCoordinate(value: number) {
return Number(value.toFixed(6));
}
function normalizeImageCoordinate(value: unknown, imageSize: number) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) return 0;
if (imageSize > 0) {
return roundCanvasAnnotationCoordinate(
clampCanvasAnnotationNumber(numericValue / imageSize, 0, 1)
);
}
return roundCanvasAnnotationCoordinate(clampCanvasAnnotationNumber(numericValue, 0, 1));
}
function normalizeCanvasAnnotationBounds(
bounds: { minX: number; minY: number; maxX: number; maxY: number },
imageWidth: number,
imageHeight: number
) {
const left = normalizeImageCoordinate(Math.min(bounds.minX, bounds.maxX), imageWidth);
const top = normalizeImageCoordinate(Math.min(bounds.minY, bounds.maxY), imageHeight);
const right = normalizeImageCoordinate(Math.max(bounds.minX, bounds.maxX), imageWidth);
const bottom = normalizeImageCoordinate(Math.max(bounds.minY, bounds.maxY), imageHeight);
const width = clampCanvasAnnotationNumber(right - left, 0, 1 - left);
const height = clampCanvasAnnotationNumber(bottom - top, 0, 1 - top);
if (width <= 0 && height <= 0) return undefined;
return {
x: left,
y: top,
width: roundCanvasAnnotationCoordinate(Math.max(width, 0.001)),
height: roundCanvasAnnotationCoordinate(Math.max(height, 0.001)),
};
}
function getCanvasVisualAnnotationBounds(
action: CanvasVisualAnnotationActionInput,
imageWidth: number,
imageHeight: number
) {
if (action.type === "circle" && Number.isFinite(Number(action.radius))) {
const radius = Math.max(Number(action.radius), 0);
const centerX = Number(action.startX);
const centerY = Number(action.startY);
if (Number.isFinite(centerX) && Number.isFinite(centerY) && radius > 0) {
return normalizeCanvasAnnotationBounds(
{
minX: centerX - radius,
minY: centerY - radius,
maxX: centerX + radius,
maxY: centerY + radius,
},
imageWidth,
imageHeight
);
}
}
const points =
action.type === "pen" && Array.isArray(action.points)
? action.points
.map((point) => ({
x: Number(point?.x),
y: Number(point?.y),
}))
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
: [];
if (points.length > 0) {
return normalizeCanvasAnnotationBounds(
{
minX: Math.min(...points.map((point) => point.x)),
minY: Math.min(...points.map((point) => point.y)),
maxX: Math.max(...points.map((point) => point.x)),
maxY: Math.max(...points.map((point) => point.y)),
},
imageWidth,
imageHeight
);
}
const startX = Number(action.startX);
const startY = Number(action.startY);
const endX = Number(action.endX);
const endY = Number(action.endY);
if (
Number.isFinite(startX) &&
Number.isFinite(startY) &&
Number.isFinite(endX) &&
Number.isFinite(endY)
) {
return normalizeCanvasAnnotationBounds(
{ minX: startX, minY: startY, maxX: endX, maxY: endY },
imageWidth,
imageHeight
);
}
return undefined;
}
function getCanvasVisualAnnotationTargetPoint(
action: CanvasVisualAnnotationActionInput,
imageWidth: number,
imageHeight: number
) {
if (action.type === "arrow") {
return {
x: normalizeImageCoordinate(action.endX, imageWidth),
y: normalizeImageCoordinate(action.endY, imageHeight),
};
}
if (action.type === "text") {
return {
x: normalizeImageCoordinate(action.startX, imageWidth),
y: normalizeImageCoordinate(action.startY, imageHeight),
};
}
return undefined;
}
function getCanvasVisualAnnotationText(action: CanvasVisualAnnotationActionInput) {
const text = typeof action.text === "string" ? action.text.trim().slice(0, 1000) : "";
if (text) return text;
if (action.type === "arrow") return "用户在图片上绘制了箭头,请重点关注箭头指向区域";
if (action.type === "circle") return "用户圈选了需要关注或修改的区域";
if (action.type === "pen") return "用户手绘标注了需要关注或修改的区域";
return "用户在图片上添加了视觉标注";
}
function getCanvasVisualAnnotationType(
action: CanvasVisualAnnotationActionInput
): CanvasAgentAnnotationRef["type"] {
if (action.type === "arrow") return "arrow-note";
if (action.type === "circle") return "box";
if (action.type === "pen") return "freehand";
return "text-note";
}
function normalizeCanvasNormalizedRect(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const rect = value as Record<string, unknown>;
const x = normalizeImageCoordinate(rect.x, 0);
const y = normalizeImageCoordinate(rect.y, 0);
const width = roundCanvasAnnotationCoordinate(
clampCanvasAnnotationNumber(Number(rect.width), 0, 1 - x)
);
const height = roundCanvasAnnotationCoordinate(
clampCanvasAnnotationNumber(Number(rect.height), 0, 1 - y)
);
if (width <= 0 || height <= 0) return null;
return { x, y, width, height };
}
function formatCanvasAnnotationRect(rect: CanvasAgentAnnotationRef["bounds"]) {
if (!rect) return "";
return `x=${rect.x}, y=${rect.y}, width=${rect.width}, height=${rect.height}`;
}
function getCanvasLocalEditRegionText(
region: CanvasLocalEditRegionAnnotationInput,
index: number
) {
const prompt = typeof region.prompt === "string" ? region.prompt.trim() : "";
const label = prompt || `局部编辑区域 ${index + 1}`;
const targetRect = normalizeCanvasNormalizedRect(region.targetRect);
if (!targetRect) return label.slice(0, 1000);
return `${label};目标区域:${formatCanvasAnnotationRect(targetRect)}`.slice(0, 1000);
}
export function syncCanvasNodeNoteAnnotationMetadata({
metadata,
nodeId,
note,
now,
}: SyncCanvasNodeNoteAnnotationMetadataParams): SyncCanvasNodeNoteAnnotationMetadataResult {
const normalizedMetadata = normalizeCanvasMetadata(metadata);
const normalizedNodeId = String(nodeId || "").trim();
const noteAnnotationId = buildCanvasNodeNoteAnnotationId(normalizedNodeId);
const updatedAt = now || new Date().toISOString();
const existingAnnotations = Array.isArray(normalizedMetadata.annotations)
? normalizedMetadata.annotations
.map(normalizeCanvasAnnotationRecord)
.filter((annotation): annotation is CanvasAnnotationMetadataRecord =>
Boolean(annotation)
)
: [];
const isCurrentNoteAnnotation = (annotation: CanvasAnnotationMetadataRecord) =>
annotation.id === noteAnnotationId ||
(annotation.source === CANVAS_NODE_NOTE_ANNOTATION_SOURCE &&
annotation.sourceNodeId === normalizedNodeId);
const existingNoteAnnotation = existingAnnotations.find(isCurrentNoteAnnotation);
const otherAnnotations = existingAnnotations.filter(
(annotation) => !isCurrentNoteAnnotation(annotation)
);
const nextNote = note.trim().slice(0, 1000);
const nextAnnotations = nextNote
? [
...otherAnnotations,
{
...(existingNoteAnnotation || {}),
id: noteAnnotationId,
source: CANVAS_NODE_NOTE_ANNOTATION_SOURCE,
sourceNodeId: normalizedNodeId,
type: "text-note" as const,
text: nextNote,
color:
typeof existingNoteAnnotation?.color === "string"
? existingNoteAnnotation.color
: "#f59e0b",
createdAt:
typeof existingNoteAnnotation?.createdAt === "string"
? existingNoteAnnotation.createdAt
: updatedAt,
updatedAt,
createdByUser: true,
},
].slice(-50)
: otherAnnotations.slice(-50);
return {
metadata: {
...normalizedMetadata,
annotations: nextAnnotations,
annotationsUpdatedAt: updatedAt,
},
annotations: nextAnnotations,
hasStructuredAnnotations: nextAnnotations.length > 0,
noteAnnotationId,
};
}
export function appendCanvasVisualAnnotationsMetadata({
metadata,
nodeId,
actions,
imageWidth,
imageHeight,
now,
}: AppendCanvasVisualAnnotationsMetadataParams): AppendCanvasVisualAnnotationsMetadataResult {
const normalizedMetadata = normalizeCanvasMetadata(metadata);
const normalizedNodeId = String(nodeId || "").trim();
const updatedAt = now || new Date().toISOString();
const existingAnnotations = Array.isArray(normalizedMetadata.annotations)
? normalizedMetadata.annotations
.map(normalizeCanvasAnnotationRecord)
.filter((annotation): annotation is CanvasAnnotationMetadataRecord =>
Boolean(annotation)
)
: [];
const width = Math.max(Number(imageWidth || 0), 0);
const height = Math.max(Number(imageHeight || 0), 0);
const timestamp = String(Date.parse(updatedAt) || Date.now()).slice(0, 32);
const appendedAnnotations = actions
.slice(0, 20)
.map((action, index): CanvasAnnotationMetadataRecord | null => {
if (!action || typeof action !== "object") return null;
const type = getCanvasVisualAnnotationType(action);
const bounds = getCanvasVisualAnnotationBounds(action, width, height);
const targetPoint = getCanvasVisualAnnotationTargetPoint(action, width, height);
if (!bounds && !targetPoint && type !== "text-note") return null;
return {
id: `${CANVAS_VISUAL_ANNOTATION_SOURCE}:${normalizedNodeId}:${timestamp}:${
index + 1
}`.slice(0, 128),
source: CANVAS_VISUAL_ANNOTATION_SOURCE,
sourceNodeId: normalizedNodeId,
type,
text: getCanvasVisualAnnotationText(action),
...(bounds ? { bounds } : {}),
...(targetPoint ? { targetPoint } : {}),
color: typeof action.color === "string" ? action.color.slice(0, 64) : "#ef4444",
createdAt: updatedAt,
updatedAt,
createdByUser: true,
visualActionType: typeof action.type === "string" ? action.type.slice(0, 32) : "",
};
})
.filter((annotation): annotation is CanvasAnnotationMetadataRecord =>
Boolean(annotation)
);
const nextAnnotations =
appendedAnnotations.length > 0
? [...existingAnnotations, ...appendedAnnotations].slice(-50)
: existingAnnotations.slice(-50);
return {
metadata: {
...normalizedMetadata,
annotations: nextAnnotations,
...(appendedAnnotations.length > 0 ? { annotationsUpdatedAt: updatedAt } : {}),
},
annotations: nextAnnotations,
appendedAnnotations,
hasStructuredAnnotations: nextAnnotations.length > 0,
};
}
export function syncCanvasLocalEditRegionAnnotationsMetadata({
metadata,
nodeId,
regions,
now,
}: SyncCanvasLocalEditRegionAnnotationsMetadataParams): SyncCanvasLocalEditRegionAnnotationsMetadataResult {
const normalizedMetadata = normalizeCanvasMetadata(metadata);
const normalizedNodeId = String(nodeId || "").trim();
const updatedAt = now || new Date().toISOString();
const existingAnnotations = Array.isArray(normalizedMetadata.annotations)
? normalizedMetadata.annotations
.map(normalizeCanvasAnnotationRecord)
.filter((annotation): annotation is CanvasAnnotationMetadataRecord =>
Boolean(annotation)
)
: [];
const isCurrentLocalEditAnnotation = (annotation: CanvasAnnotationMetadataRecord) =>
annotation.source === CANVAS_LOCAL_EDIT_REGION_ANNOTATION_SOURCE &&
annotation.sourceNodeId === normalizedNodeId;
const otherAnnotations = existingAnnotations.filter(
(annotation) => !isCurrentLocalEditAnnotation(annotation)
);
const regionAnnotations = regions
.slice(0, 20)
.map((region, index): CanvasAnnotationMetadataRecord | null => {
if (!region || typeof region !== "object") return null;
const bounds = normalizeCanvasNormalizedRect(region.rect);
if (!bounds) return null;
const regionId =
typeof region.id === "string" && region.id.trim()
? region.id.trim().slice(0, 80)
: `region-${index + 1}`;
const createdAt =
typeof region.createdAt === "number" && Number.isFinite(region.createdAt)
? new Date(region.createdAt).toISOString()
: updatedAt;
return {
id: `${CANVAS_LOCAL_EDIT_REGION_ANNOTATION_SOURCE}:${normalizedNodeId}:${regionId}`.slice(
0,
128
),
source: CANVAS_LOCAL_EDIT_REGION_ANNOTATION_SOURCE,
sourceNodeId: normalizedNodeId,
type: "crop-box",
text: getCanvasLocalEditRegionText(region, index),
bounds,
color: typeof region.color === "string" ? region.color.slice(0, 64) : "#06b6d4",
createdAt,
updatedAt,
createdByUser: true,
localEditRegionId: regionId,
};
})
.filter((annotation): annotation is CanvasAnnotationMetadataRecord =>
Boolean(annotation)
);
const nextAnnotations = [...otherAnnotations, ...regionAnnotations].slice(-50);
return {
metadata: {
...normalizedMetadata,
annotations: nextAnnotations,
annotationsUpdatedAt: updatedAt,
},
annotations: nextAnnotations,
regionAnnotations,
hasStructuredAnnotations: nextAnnotations.length > 0,
};
}

View File

@ -0,0 +1,63 @@
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
function trimText(value: unknown, maxLength: number) {
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
}
function uniqueStrings(values: Array<string | undefined | null>) {
return Array.from(
new Set(values.map((value) => trimText(value, 160)).filter(Boolean))
);
}
export function hasCanvasAgentAnnotationEditIntent(userText: string) {
return /按.*(?:批注|标注|箭头|框选|备注)|(?:批注|标注|箭头|框选|圈选|annotation|note)/i.test(
userText
);
}
export function buildCanvasAgentAnnotationEditPrompt(params: {
context?: CanvasAgentContext | null;
targetNodeIds?: string[];
userText: string;
maxAnnotations?: number;
}) {
const context = params.context;
if (!context || !hasCanvasAgentAnnotationEditIntent(params.userText)) {
return "";
}
const targetNodeIds = uniqueStrings([
...(params.targetNodeIds || []),
...context.selectedNodeIds,
...context.selectedNodes.filter((node) => node.isSelected).map((node) => node.nodeId),
]);
if (targetNodeIds.length === 0) return "";
const targetSet = new Set(targetNodeIds);
const annotations = context.annotations
.filter((annotation) => targetSet.has(annotation.sourceNodeId))
.slice(0, Math.max(1, params.maxAnnotations || 8));
if (annotations.length === 0) return "";
const lines = annotations.map((annotation, index) => {
const parts = [
`${index + 1}. 节点 ${annotation.sourceNodeId}`,
`类型 ${annotation.type}`,
annotation.text ? `说明:${annotation.text}` : "",
annotation.bounds
? `框选区域:x=${annotation.bounds.x}, y=${annotation.bounds.y}, width=${annotation.bounds.width}, height=${annotation.bounds.height}`
: "",
annotation.targetPoint
? `目标点:x=${annotation.targetPoint.x}, y=${annotation.targetPoint.y}`
: "",
].filter(Boolean);
return parts.join(";");
});
return [
"【画布批注编辑要求】用户要求按画布批注/标注执行编辑。请把以下批注作为本轮编辑的主要约束:",
...lines,
"执行要求:优先修改批注指向或框选的区域;未被批注要求修改的主体、背景、构图与文字尽量保持不变;如果批注文字与用户当前文字冲突,以用户当前文字为准。",
].join("\n");
}

View File

@ -0,0 +1,82 @@
export type CanvasAgentWritebackMode =
| "legacy_canvas_media"
| "operation_shadow"
| "operation_authoritative";
export type CanvasAgentFeatureFlags = {
operationProtocolEnabled: boolean;
writebackMode: CanvasAgentWritebackMode;
slotEnabled: boolean;
viewOperationEnabled: boolean;
annotationEditEnabled: boolean;
};
type EnvLike = Record<string, string | undefined>;
const WRITEBACK_MODES = new Set<CanvasAgentWritebackMode>([
"legacy_canvas_media",
"operation_shadow",
"operation_authoritative",
]);
export function normalizeCanvasAgentBoolean(
value: unknown,
fallback: boolean
) {
if (typeof value !== "string") return fallback;
const normalized = value.trim().toLowerCase();
if (!normalized) return fallback;
if (["1", "true", "yes", "on", "enabled"].includes(normalized)) return true;
if (["0", "false", "no", "off", "disabled"].includes(normalized)) return false;
return fallback;
}
export function normalizeCanvasAgentWritebackMode(
value: unknown,
fallback: CanvasAgentWritebackMode = "operation_authoritative"
): CanvasAgentWritebackMode {
const normalized = typeof value === "string" ? value.trim() : "";
return WRITEBACK_MODES.has(normalized as CanvasAgentWritebackMode)
? (normalized as CanvasAgentWritebackMode)
: fallback;
}
export function getCanvasAgentFeatureFlags(
env: EnvLike = process.env
): CanvasAgentFeatureFlags {
return {
operationProtocolEnabled: normalizeCanvasAgentBoolean(
env.CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED,
true
),
writebackMode: normalizeCanvasAgentWritebackMode(
env.CANVAS_AGENT_WRITEBACK_MODE,
"operation_authoritative"
),
slotEnabled: normalizeCanvasAgentBoolean(
env.CANVAS_AGENT_SLOT_ENABLED,
true
),
viewOperationEnabled: normalizeCanvasAgentBoolean(
env.CANVAS_AGENT_VIEW_OPERATION_ENABLED,
true
),
annotationEditEnabled: normalizeCanvasAgentBoolean(
env.CANVAS_AGENT_ANNOTATION_EDIT_ENABLED,
true
),
};
}
export function canApplyCanvasAgentMutations(flags: CanvasAgentFeatureFlags) {
return (
flags.operationProtocolEnabled &&
flags.writebackMode === "operation_authoritative"
);
}
export function shouldStreamCanvasAgentShadowOperations(
flags: CanvasAgentFeatureFlags
) {
return flags.operationProtocolEnabled && flags.writebackMode === "operation_shadow";
}

View File

@ -15,10 +15,15 @@ const plannerDecisionSchema = z.object({
"zoom_out",
"link_nodes",
"update_text",
"add_annotation",
"create_agent_slot",
"confirmation_required",
]),
nodeIds: z.array(z.string().min(1).max(160)).max(20).optional(),
textContent: z.string().max(4000).optional(),
slotType: z.enum(["image", "video", "ui-screen"]).optional(),
slotLabel: z.string().max(120).optional(),
aspectRatio: z.string().max(32).optional(),
riskOperation: z
.enum([
"replace_node_media",
@ -35,6 +40,14 @@ const plannerDecisionSchema = z.object({
type PlannerDecision = z.infer<typeof plannerDecisionSchema>;
export type CanvasAgentSlotCreationIntent = {
shouldCreate: boolean;
combinedWithMediaExecution: boolean;
slotType: "image" | "video" | "ui-screen";
label: string;
aspectRatio: string;
};
export type CanvasAgentModelPlannerOutcome =
| {
kind: "view";
@ -152,6 +165,145 @@ function inferMoveLayoutStrategy(userText: string): "row" | "column" | "grid" {
return "row";
}
function hasCombinedMediaExecutionIntent(userText: string) {
return /并生成|然后生成|填充|出图|做一版|再出|换背景|抠图|去水印|高清|扩图|重绘|变体|图层|多角度|洗图/.test(
userText.toLowerCase()
);
}
function inferAgentSlotType(
userText: string,
modelSlotType?: "image" | "video" | "ui-screen"
): "image" | "video" | "ui-screen" {
if (modelSlotType === "video" || modelSlotType === "ui-screen") {
return modelSlotType;
}
const text = userText.toLowerCase();
if (/视频|video/.test(text)) return "video";
if (/ui|界面|页面|画面|screen/.test(text)) return "ui-screen";
return "image";
}
function getAgentSlotDefaults(slotType: "image" | "video" | "ui-screen") {
if (slotType === "video") {
return {
label: "视频槽位",
aspectRatio: "16:9",
size: { width: 360, height: 220 },
};
}
if (slotType === "ui-screen") {
return {
label: "UI 槽位",
aspectRatio: "16:9",
size: { width: 360, height: 260 },
};
}
return {
label: "图片槽位",
aspectRatio: "1:1",
size: { width: 320, height: 320 },
};
}
function inferAspectRatioFromText(userText: string) {
const match = userText.match(/\b(?:1:1|4:3|3:4|16:9|9:16)\b/);
return match?.[0] || "";
}
export function resolveCanvasAgentSlotCreationIntent(params: {
userText: string;
slotType?: "image" | "video" | "ui-screen";
slotLabel?: string;
aspectRatio?: string;
}): CanvasAgentSlotCreationIntent {
const text = params.userText.trim();
const lowerText = text.toLowerCase();
const hasCreationVerb =
/创建|新增|新建|添加|放一个|加一个|建一个|开一个|create|add|new/.test(
lowerText
);
const hasSlotWord = /slot|槽位|空框|占位|holder/.test(lowerText);
const slotType = inferAgentSlotType(text, params.slotType);
const defaults = getAgentSlotDefaults(slotType);
return {
shouldCreate: hasCreationVerb && hasSlotWord,
combinedWithMediaExecution: hasCombinedMediaExecutionIntent(text),
slotType,
label: trimString(params.slotLabel, 120) || defaults.label,
aspectRatio:
trimString(params.aspectRatio, 32) ||
inferAspectRatioFromText(text) ||
defaults.aspectRatio,
};
}
function getViewportCenteredSlotPosition(params: {
context: CanvasAgentContext;
size: { width: number; height: number };
}) {
const viewport = params.context.viewport || { x: 0, y: 0, zoom: 1 };
const zoom = Math.max(0.1, Number(viewport.zoom || 1));
const viewportWidth = Math.max(320, Number(viewport.width || 1200));
const viewportHeight = Math.max(320, Number(viewport.height || 800));
return {
x: (viewportWidth / 2 - Number(viewport.x || 0)) / zoom - params.size.width / 2,
y: (viewportHeight / 2 - Number(viewport.y || 0)) / zoom - params.size.height / 2,
};
}
export function buildCanvasAgentSlotCreationPlan(params: {
context: CanvasAgentContext;
projectId: string;
messageId: string;
chatId: string;
userText: string;
operationId?: string;
slotType?: "image" | "video" | "ui-screen";
slotLabel?: string;
aspectRatio?: string;
}): CanvasOperationPlan | null {
const intent = resolveCanvasAgentSlotCreationIntent({
userText: params.userText,
slotType: params.slotType,
slotLabel: params.slotLabel,
aspectRatio: params.aspectRatio,
});
if (!intent.shouldCreate) return null;
const defaults = getAgentSlotDefaults(intent.slotType);
return {
version: 1,
operationId: params.operationId || `${params.messageId}:canvas-create-agent-slot`,
operation: "create_agent_slot",
projectId: params.projectId,
baseCanvasRevision: params.context.canvasRevision,
baseCanvasUpdatedAt: params.context.canvasUpdatedAt,
targetPosition: getViewportCenteredSlotPosition({
context: params.context,
size: defaults.size,
}),
slot: {
slotType: intent.slotType,
label: intent.label,
aspectRatio: intent.aspectRatio,
fillPolicy: "replace-empty",
acceptedSources: ["text", "image", "selection", "annotation"],
},
preserveOriginal: true,
metadata: {
messageId: params.messageId,
chatId: params.chatId,
planner: "model",
prompt: params.userText,
},
};
}
function hasCanvasAgentSlotCreationIntent(userText: string) {
return resolveCanvasAgentSlotCreationIntent({ userText }).shouldCreate;
}
function buildViewPlan(params: {
action: PlannerDecision["action"];
context: CanvasAgentContext;
@ -257,9 +409,11 @@ export function shouldConsiderCanvasAgentModelPlanner(params: {
/文本|文字|文案|标题|标签|改写|润色|优化|改成|改为|设置为|设为|替换为/.test(
text
);
const hasAnnotationIntent = /批注|标注|备注|note|annotation/.test(text);
const hasSlotIntent = hasCanvasAgentSlotCreationIntent(text);
const hasHighRiskIntent = Boolean(inferHighRiskOperation(text));
const hasMediaGenerationIntent =
/生成|出图|做一版|再出|换背景|背景|抠图|去水印|高清|扩图|重绘|视频|变体|图层|多角度|洗图/.test(
/生成|出图|做一版|再出|换背景|背景|抠图|去水印|高清|扩图|重绘|变体|图层|多角度|洗图/.test(
text
);
@ -268,12 +422,21 @@ export function shouldConsiderCanvasAgentModelPlanner(params: {
!hasViewIntent &&
!hasLinkIntent &&
!hasTextIntent &&
!hasAnnotationIntent &&
!hasSlotIntent &&
!hasHighRiskIntent
) {
return false;
}
return hasViewIntent || hasLinkIntent || hasTextIntent || hasHighRiskIntent;
return (
hasViewIntent ||
hasLinkIntent ||
hasTextIntent ||
hasAnnotationIntent ||
hasSlotIntent ||
hasHighRiskIntent
);
}
export function normalizeCanvasAgentModelPlannerDecision(params: {
@ -308,6 +471,7 @@ export function normalizeCanvasAgentModelPlannerDecision(params: {
const canBuildMovePlan =
(riskOperation === "move_nodes" || riskOperation === "batch_move") &&
targetNodeIds.length > 0;
const canBuildDeletePlan = riskOperation === "delete_nodes" && targetNodeIds.length > 0;
const operationPlan: CanvasOperationPlan | undefined = canBuildMovePlan
? {
version: 1,
@ -329,13 +493,35 @@ export function normalizeCanvasAgentModelPlannerDecision(params: {
riskOperation,
},
}
: canBuildDeletePlan
? {
version: 1,
operationId: operationId.replace(
`canvas-model-${decision.action}`,
"canvas-delete"
),
operation: "delete_nodes",
projectId: params.projectId,
baseCanvasRevision: params.context.canvasRevision,
baseCanvasUpdatedAt: params.context.canvasUpdatedAt,
targetNodeIds,
preserveOriginal: false,
metadata: {
messageId: params.messageId,
chatId: params.chatId,
planner: "model",
prompt: params.userText,
pendingConfirmation: true,
riskOperation,
},
}
: undefined;
return {
kind: "confirmation_required",
riskOperation,
targetNodeIds,
operationPlan,
viewPlan: operationPlan
viewPlan: operationPlan && operationPlan.operation !== "delete_nodes"
? buildViewPlan({
action: "focus_nodes",
context: params.context,
@ -354,6 +540,35 @@ export function normalizeCanvasAgentModelPlannerDecision(params: {
return { kind: "none", reason };
}
if (decision.action === "create_agent_slot") {
if (hasCombinedMediaExecutionIntent(params.userText)) {
return { kind: "none", reason: "slot_creation_combined_with_media_execution" };
}
const operationPlan = buildCanvasAgentSlotCreationPlan({
context: params.context,
projectId: params.projectId,
messageId: params.messageId,
chatId: params.chatId,
userText: params.userText,
operationId,
slotType: decision.slotType,
slotLabel: decision.slotLabel,
aspectRatio: decision.aspectRatio,
});
if (!operationPlan) return { kind: "none", reason: "missing_slot_creation_intent" };
const slotLabel = operationPlan.slot?.label || "Agent 槽位";
return {
kind: "operation",
operationPlan,
reply:
trimString(decision.reply, 280) ||
`已在当前视口创建${slotLabel}。`,
reason,
};
}
if (
decision.action === "focus_nodes" ||
decision.action === "fit_all" ||
@ -434,6 +649,62 @@ export function normalizeCanvasAgentModelPlannerDecision(params: {
};
}
if (decision.action === "add_annotation") {
const knownNodes = buildKnownNodeMap(params.context);
const targetNodeIds = getDecisionNodeIds({
decision,
context: params.context,
allowSelectedFallback: true,
maxItems: 10,
}).filter((nodeId) => {
const type = knownNodes.get(nodeId)?.type;
return type === "image" || type === "video";
});
const textContent = trimString(decision.textContent, 1000);
if (targetNodeIds.length === 0 || !textContent) {
return { kind: "none", reason: "add_annotation_missing_target_or_content" };
}
const operationPlan: CanvasOperationPlan = {
version: 1,
operationId,
operation: "add_annotation",
projectId: params.projectId,
baseCanvasRevision: params.context.canvasRevision,
baseCanvasUpdatedAt: params.context.canvasUpdatedAt,
targetNodeIds,
textContent,
annotations: [
{
type: "text-note",
text: textContent,
color: "#f59e0b",
},
],
preserveOriginal: true,
metadata: {
messageId: params.messageId,
chatId: params.chatId,
planner: "model",
prompt: params.userText,
},
};
return {
kind: "operation",
operationPlan,
viewPlan: buildViewPlan({
action: "focus_nodes",
context: params.context,
projectId: params.projectId,
operationId,
nodeIds: targetNodeIds,
reason: "focus_model_annotated_nodes",
}) || undefined,
reply: trimString(decision.reply, 280) || "已给选中的画布节点添加批注。",
reason,
};
}
if (decision.action === "update_text") {
const knownNodes = buildKnownNodeMap(params.context);
const requestedIds = getDecisionNodeIds({
@ -510,8 +781,9 @@ export async function planCanvasAgentOperationWithModel(params: {
abortSignal: params.abortSignal || AbortSignal.timeout(18_000),
system: [
"你是无限画布 Agent 的受控操作规划器。",
"你只能规划低风险画布操作:视角聚焦/缩放/看全局、连接节点、更新文本节点。",
"你只能规划低风险画布操作:视角聚焦/缩放/看全局、连接节点、更新文本节点、给图片或视频节点添加批注、创建空 Agent 槽位。",
"图片/视频生成、换背景、抠图、高清、扩图、重绘等媒体任务必须返回 action=none,交给其他执行器。",
"当用户明确要求新建/添加空图片槽位、视频槽位、UI槽位、空框或占位时,可以返回 action=create_agent_slot;如果同时要求生成或填充媒体,返回 action=none。",
"删除、覆盖、替换现有图片、移动/重排节点等高风险动作必须返回 action=confirmation_required。",
"只能使用上下文中出现的 nodeId。不要编造节点 ID。",
"如果用户只是聊天或目标不明确,返回 action=none。",
@ -522,9 +794,12 @@ export async function planCanvasAgentOperationWithModel(params: {
context: buildContextSummary(params.context),
outputContract: {
action:
"none | focus_nodes | fit_all | zoom_in | zoom_out | link_nodes | update_text | confirmation_required",
"none | focus_nodes | fit_all | zoom_in | zoom_out | link_nodes | update_text | add_annotation | create_agent_slot | confirmation_required",
nodeIds: "只填上下文存在的 nodeId",
textContent: "仅 update_text 时填写最终文本",
textContent: "update_text 时填写最终文本;add_annotation 时填写批注内容",
slotType: "create_agent_slot 时填写 image/video/ui-screen",
slotLabel: "create_agent_slot 时可填写槽位名称",
aspectRatio: "create_agent_slot 时可填写 1:1/16:9 等比例",
riskOperation:
"confirmation_required 时填写 replace_node_media/delete_nodes/move_nodes/overwrite_slot/batch_move/other",
reply: "一句给用户看的中文短回复",

View File

@ -1,16 +1,21 @@
import "server-only";
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { syncCanvasNodesForProject } from "@/lib/db/queries";
import { resolveCanvasAgentMediaPlacements } from "@/lib/canvas/agent-placement";
import { shouldRejectStaleCanvasAgentHighRiskMutation } from "@/lib/canvas/agent-risk";
import {
appendCanvasAgentOperationAudit,
appendCanvasAgentOperationAuditIfMissing,
buildIdempotentCanvasAgentAuditResult,
findCanvasAgentOperationAudit,
normalizeCanvasAgentAuditStringList,
} from "@/lib/canvas/agent-operation-audit";
import type {
CanvasMediaItem,
CanvasOperationPlan,
} from "@/types/canvas/agent-protocol";
type CanvasDataLike = {
export type CanvasDataLike = {
nodes?: any[];
edges?: any[];
selectedNodeIds?: string[];
@ -31,11 +36,13 @@ export type CanvasOperationResult = {
export class CanvasAgentMutationError extends Error {
status: number;
code?: string;
constructor(message: string, status = 400) {
constructor(message: string, status = 400, code?: string) {
super(message);
this.name = "CanvasAgentMutationError";
this.status = status;
this.code = code;
}
}
@ -198,10 +205,105 @@ function normalizeSlotFillPolicy(value: unknown) {
: "replace-empty";
}
function normalizeAgentSlotType(value: unknown): "image" | "video" | "ui-screen" {
return value === "video" || value === "ui-screen" ? value : "image";
}
function normalizeAgentSlotAcceptedSources(value: unknown) {
const allowed = new Set(["text", "image", "selection", "annotation"]);
const sources = Array.isArray(value)
? value
.map((item) => String(item || "").trim())
.filter((item) => allowed.has(item))
: [];
return sources.length > 0
? Array.from(new Set(sources))
: ["text", "image", "selection", "annotation"];
}
function getAgentSlotDefaults(slotType: "image" | "video" | "ui-screen") {
if (slotType === "video") {
return {
label: "视频槽位",
aspectRatio: "16:9",
size: { width: 360, height: 220 },
};
}
if (slotType === "ui-screen") {
return {
label: "UI 槽位",
aspectRatio: "16:9",
size: { width: 360, height: 260 },
};
}
return {
label: "图片槽位",
aspectRatio: "1:1",
size: { width: 320, height: 320 },
};
}
function getCanvasViewportCenterPosition(params: {
canvasData: CanvasDataLike;
size: { width: number; height: number };
}) {
const viewport = params.canvasData.viewport || { x: 0, y: 0, zoom: 1 };
const zoom = Math.max(0.1, Number(viewport.zoom || 1));
const viewportWidth = Math.max(320, Number((viewport as any).width || 1200));
const viewportHeight = Math.max(320, Number((viewport as any).height || 800));
return {
x: (viewportWidth / 2 - Number(viewport.x || 0)) / zoom - params.size.width / 2,
y: (viewportHeight / 2 - Number(viewport.y || 0)) / zoom - params.size.height / 2,
};
}
function normalizeEdgeType(value: unknown): "default" | "reference" | "conversation" {
return value === "default" || value === "conversation" ? value : "reference";
}
function normalizeAnnotationType(
value: unknown
): "text-note" | "arrow-note" | "box" | "freehand" | "crop-box" {
return value === "arrow-note" ||
value === "box" ||
value === "freehand" ||
value === "crop-box"
? value
: "text-note";
}
function normalizeAnnotationPoint(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const raw = value as Record<string, unknown>;
const x = Number(raw.x);
const y = Number(raw.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined;
return { x, y };
}
function normalizeAnnotationBounds(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const raw = value as Record<string, unknown>;
const x = Number(raw.x);
const y = Number(raw.y);
const width = Number(raw.width);
const height = Number(raw.height);
if (
!Number.isFinite(x) ||
!Number.isFinite(y) ||
!Number.isFinite(width) ||
!Number.isFinite(height)
) {
return undefined;
}
return {
x,
y,
width: Math.max(0, width),
height: Math.max(0, height),
};
}
function edgeMatches(edge: any, source: string, target: string) {
return (
(edge?.source === source && edge?.target === target) ||
@ -221,6 +323,12 @@ function getOperationTargetNodeIds(plan: CanvasOperationPlan) {
);
}
function normalizeCanvasMetadata(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value)
? { ...(value as Record<string, unknown>) }
: {};
}
function getNodeSize(node: any) {
return {
width: Math.max(1, Number(node?.size?.width || 220) || 220),
@ -240,6 +348,26 @@ function isConfirmedHighRiskPlan(plan: CanvasOperationPlan) {
return plan.confirmed === true || metadata.confirmedByUser === true;
}
function assertFreshBaseForCanvasAgentHighRiskMutation(
plan: CanvasOperationPlan,
baseCanvasWasStale?: boolean
) {
if (
shouldRejectStaleCanvasAgentHighRiskMutation({
operation: plan.operation,
confirmed: plan.confirmed,
metadata: plan.metadata,
baseCanvasWasStale,
})
) {
throw new CanvasAgentMutationError(
"画布已更新,高风险操作需要刷新后重新确认",
409,
"STALE_CANVAS_REVISION"
);
}
}
function normalizeLayoutStrategy(value: unknown): "row" | "column" | "grid" {
return value === "column" || value === "grid" ? value : "row";
}
@ -271,7 +399,11 @@ function applyMoveNodesOperation(params: {
plan: CanvasOperationPlan;
}) {
if (!isConfirmedHighRiskPlan(params.plan)) {
throw new CanvasAgentMutationError("移动画布节点需要用户确认", 409);
throw new CanvasAgentMutationError(
"移动画布节点需要用户确认",
409,
"CANVAS_AGENT_CONFIRMATION_REQUIRED"
);
}
const targetNodeIds = getOperationTargetNodeIds(params.plan);
@ -352,6 +484,389 @@ function applyMoveNodesOperation(params: {
};
}
function getNodeMediaSnapshot(node: any) {
const data = (node?.data || {}) as Record<string, any>;
return {
type: node?.type,
src: typeof data.src === "string" ? data.src : "",
assetId: typeof data.assetId === "string" ? data.assetId : undefined,
title: typeof data.title === "string" ? data.title : undefined,
prompt: typeof data.prompt === "string" ? data.prompt : undefined,
status: typeof data.status === "string" ? data.status : undefined,
};
}
function getReplacementMediaItemsForTargets(params: {
mediaItems: CanvasMediaItem[];
targetNodeIds: string[];
}) {
if (params.mediaItems.length === params.targetNodeIds.length) {
return params.targetNodeIds.map((targetNodeId, index) => {
const explicit = params.mediaItems.find((item) =>
Array.isArray(item.sourceNodeIds)
? item.sourceNodeIds.includes(targetNodeId)
: false
);
return explicit || params.mediaItems[index];
});
}
if (params.mediaItems.length === 1 && params.targetNodeIds.length === 1) {
return params.mediaItems;
}
throw new CanvasAgentMutationError("替换媒体数量必须与目标节点数量一致");
}
function applyReplaceNodeMediaOperation(params: {
canvasData: CanvasDataLike;
nodes: any[];
edges: any[];
plan: CanvasOperationPlan;
}) {
if (!isConfirmedHighRiskPlan(params.plan)) {
throw new CanvasAgentMutationError(
"替换画布媒体需要用户确认",
409,
"CANVAS_AGENT_CONFIRMATION_REQUIRED"
);
}
const targetNodeIds = getOperationTargetNodeIds(params.plan);
if (targetNodeIds.length === 0) {
throw new CanvasAgentMutationError("缺少要替换的媒体节点");
}
const mediaItems = normalizeMediaItems(params.plan);
if (mediaItems.length === 0) {
throw new CanvasAgentMutationError("缺少替换媒体结果");
}
const replacementItems = getReplacementMediaItemsForTargets({
mediaItems,
targetNodeIds,
});
const now = new Date().toISOString();
const createdNodeIdsByMediaItemId: Record<string, string> = {};
const updatedNodeIds: string[] = [];
targetNodeIds.forEach((nodeId, index) => {
const node = findNode(params.nodes, nodeId);
if (!node) {
throw new CanvasAgentMutationError(`替换节点不存在:${nodeId}`);
}
if (node.type !== "image" && node.type !== "video") {
throw new CanvasAgentMutationError("replace_node_media 只能替换图片或视频节点");
}
const item = replacementItems[index];
if (!item) {
throw new CanvasAgentMutationError("缺少替换媒体结果");
}
if (item.type !== node.type) {
throw new CanvasAgentMutationError("替换媒体类型必须与目标节点类型一致");
}
const data = (node.data || {}) as Record<string, any>;
const previousMedia = getNodeMediaSnapshot(node);
node.data = {
...data,
src: item.url,
assetId: item.assetId || data.assetId,
status: "completed",
prompt: item.prompt || data.prompt,
title: item.title || data.title,
metadata: {
...(data.metadata || {}),
...(item.metadata || {}),
replacedByAgent: true,
canvasAgentOperationId: params.plan.operationId,
canvasAgentOperation: params.plan.operation,
canvasAgentMediaItemId: item.mediaItemId,
previousMedia,
agentReplacedAt: now,
},
};
node.updatedAt = now;
updatedNodeIds.push(node.id);
createdNodeIdsByMediaItemId[item.mediaItemId] = node.id;
});
rebuildConnections(params.nodes, params.edges);
return {
canvasData: {
...params.canvasData,
nodes: params.nodes,
edges: params.edges,
selectedNodeIds: updatedNodeIds,
},
createdNodeIds: [],
createdNodeIdsByMediaItemId,
updatedNodeIds,
createdEdgeIds: [],
};
}
function applyDeleteNodesOperation(params: {
canvasData: CanvasDataLike;
nodes: any[];
edges: any[];
plan: CanvasOperationPlan;
}) {
if (!isConfirmedHighRiskPlan(params.plan)) {
throw new CanvasAgentMutationError(
"删除画布节点需要用户确认",
409,
"CANVAS_AGENT_CONFIRMATION_REQUIRED"
);
}
const targetNodeIds = getOperationTargetNodeIds(params.plan);
if (targetNodeIds.length === 0) {
throw new CanvasAgentMutationError("缺少要删除的节点");
}
const targetNodes = targetNodeIds.map((nodeId) => findNode(params.nodes, nodeId));
const missingIndex = targetNodes.findIndex((node) => !node);
if (missingIndex >= 0) {
throw new CanvasAgentMutationError(`删除节点不存在:${targetNodeIds[missingIndex]}`);
}
if (targetNodes.some((node) => node?.type === "agent-slot")) {
throw new CanvasAgentMutationError("Agent slot 默认不允许删除,请手动处理 slot");
}
const targetSet = new Set(targetNodeIds);
const deletedNodes = targetNodes.map((node) => JSON.parse(JSON.stringify(node)));
const deletedEdges = params.edges
.filter((edge) => targetSet.has(edge.source) || targetSet.has(edge.target))
.map((edge) => JSON.parse(JSON.stringify(edge)));
const remainingNodes = params.nodes.filter((node) => !targetSet.has(node.id));
const remainingEdges = params.edges.filter(
(edge) => !targetSet.has(edge.source) && !targetSet.has(edge.target)
);
rebuildConnections(remainingNodes, remainingEdges);
const auditedCanvasData = appendCanvasAgentOperationAudit({
canvasData: params.canvasData,
auditEntry: {
operationId: params.plan.operationId,
operation: params.plan.operation,
targetNodeIds,
deletedNodeIds: targetNodeIds,
deletedNodes,
deletedEdges,
deletedAt: new Date().toISOString(),
metadata: params.plan.metadata || {},
},
});
return {
canvasData: {
...auditedCanvasData,
nodes: remainingNodes,
edges: remainingEdges,
selectedNodeIds: [],
},
createdNodeIds: [],
createdNodeIdsByMediaItemId: {},
updatedNodeIds: targetNodeIds,
createdEdgeIds: [],
};
}
function normalizePlanAnnotations(plan: CanvasOperationPlan, fallbackText: string) {
const rawAnnotations = Array.isArray(plan.annotations) ? plan.annotations : [];
const normalized = rawAnnotations
.map((annotation) => {
if (!annotation || typeof annotation !== "object") return null;
const raw = annotation as Record<string, unknown>;
const text =
typeof raw.text === "string"
? String(raw.text).trim().slice(0, 1000)
: undefined;
return {
id: typeof raw.id === "string" ? String(raw.id).trim().slice(0, 128) : "",
sourceNodeId:
typeof raw.sourceNodeId === "string"
? String(raw.sourceNodeId).trim().slice(0, 128)
: "",
type: normalizeAnnotationType(raw.type),
text,
bounds: normalizeAnnotationBounds(raw.bounds),
targetPoint: normalizeAnnotationPoint(raw.targetPoint),
color:
typeof raw.color === "string" ? String(raw.color).trim().slice(0, 64) : undefined,
createdAt:
typeof raw.createdAt === "string"
? String(raw.createdAt).trim().slice(0, 64)
: undefined,
};
})
.filter((annotation): annotation is NonNullable<typeof annotation> =>
Boolean(annotation)
);
if (normalized.length > 0) return normalized.slice(0, 20);
if (!fallbackText) return [];
return [
{
id: "",
sourceNodeId: "",
type: "text-note" as const,
text: fallbackText.slice(0, 1000),
bounds: undefined,
targetPoint: undefined,
color: undefined,
createdAt: undefined,
},
];
}
function applyAddAnnotationOperation(params: {
canvasData: CanvasDataLike;
nodes: any[];
edges: any[];
plan: CanvasOperationPlan;
}) {
const targetNodeIds = getOperationTargetNodeIds(params.plan);
if (targetNodeIds.length === 0) {
throw new CanvasAgentMutationError("缺少要添加批注的节点");
}
const fallbackText = String(params.plan.textContent || "").trim();
const annotations = normalizePlanAnnotations(params.plan, fallbackText);
if (annotations.length === 0) {
throw new CanvasAgentMutationError("缺少批注内容");
}
const now = new Date().toISOString();
const updatedNodeIds: string[] = [];
targetNodeIds.forEach((nodeId) => {
const node = findNode(params.nodes, nodeId);
if (!node) {
throw new CanvasAgentMutationError(`批注节点不存在:${nodeId}`);
}
if (node.type !== "image" && node.type !== "video") {
throw new CanvasAgentMutationError("add_annotation 只能给图片或视频节点添加批注");
}
const data = (node.data || {}) as Record<string, any>;
const metadata = normalizeCanvasMetadata(data.metadata);
const existingAnnotations = Array.isArray(metadata.annotations)
? metadata.annotations.filter((item) => item && typeof item === "object")
: [];
const nextAnnotations = annotations.map((annotation, index) => ({
...annotation,
id:
annotation.id ||
`${params.plan.operationId}:annotation-${node.id}-${index + 1}`,
sourceNodeId: annotation.sourceNodeId || node.id,
createdAt: annotation.createdAt || now,
createdByAgent: true,
canvasAgentOperationId: params.plan.operationId,
}));
node.data = {
...data,
hasAnnotations: true,
metadata: {
...metadata,
annotations: [...existingAnnotations, ...nextAnnotations].slice(-50),
annotationsUpdatedAt: now,
},
};
node.updatedAt = now;
updatedNodeIds.push(node.id);
});
return {
canvasData: {
...params.canvasData,
nodes: params.nodes,
edges: params.edges,
selectedNodeIds: updatedNodeIds,
},
createdNodeIds: [],
createdNodeIdsByMediaItemId: {},
updatedNodeIds,
createdEdgeIds: [],
};
}
function applyCreateAgentSlotOperation(params: {
canvasData: CanvasDataLike;
nodes: any[];
edges: any[];
plan: CanvasOperationPlan;
}) {
const now = new Date().toISOString();
const slotMetadata = (params.plan.metadata || {}) as Record<string, unknown>;
const slotSpec = (params.plan.slot || {}) as Record<string, unknown>;
const slotType = normalizeAgentSlotType(slotSpec.slotType || slotMetadata.slotType);
const defaults = getAgentSlotDefaults(slotType);
const label = String(slotSpec.label || defaults.label).trim().slice(0, 120);
const aspectRatio = String(slotSpec.aspectRatio || defaults.aspectRatio)
.trim()
.slice(0, 32);
const fillPolicy = normalizeSlotFillPolicy(slotSpec.fillPolicy);
const acceptedSources = normalizeAgentSlotAcceptedSources(slotSpec.acceptedSources);
const targetPosition =
params.plan.targetPosition &&
Number.isFinite(Number(params.plan.targetPosition.x)) &&
Number.isFinite(Number(params.plan.targetPosition.y))
? {
x: Number(params.plan.targetPosition.x),
y: Number(params.plan.targetPosition.y),
}
: getCanvasViewportCenterPosition({
canvasData: params.canvasData,
size: defaults.size,
});
const node = {
id: generateId(),
type: "agent-slot",
position: targetPosition,
size: defaults.size,
data: {
slotType,
label,
status: "empty",
aspectRatio,
fillPolicy,
acceptedSources,
filledNodeIds: [],
metadata: {
...(params.plan.metadata || {}),
createdByAgent: true,
canvasAgentOperationId: params.plan.operationId,
canvasAgentOperation: params.plan.operation,
agentCreatedAt: now,
},
},
connections: [],
createdAt: now,
updatedAt: now,
};
params.nodes.push(node);
return {
canvasData: {
...params.canvasData,
nodes: params.nodes,
edges: params.edges,
selectedNodeIds: [node.id],
},
createdNodeIds: [node.id],
createdNodeIdsByMediaItemId: {},
updatedNodeIds: [],
createdEdgeIds: [],
};
}
function applyOperationToCanvasData(params: {
canvasData: CanvasDataLike;
plan: CanvasOperationPlan;
@ -361,11 +876,15 @@ function applyOperationToCanvasData(params: {
throw new CanvasAgentMutationError("缺少 operationId");
}
if (
params.plan.operation !== "create_agent_slot" &&
params.plan.operation !== "insert_beside" &&
params.plan.operation !== "append_revision" &&
params.plan.operation !== "fill_slot" &&
params.plan.operation !== "link_nodes" &&
params.plan.operation !== "add_annotation" &&
params.plan.operation !== "move_nodes" &&
params.plan.operation !== "replace_node_media" &&
params.plan.operation !== "delete_nodes" &&
params.plan.operation !== "update_text"
) {
throw new CanvasAgentMutationError("当前仅支持非破坏式画布写回操作");
@ -373,6 +892,80 @@ function applyOperationToCanvasData(params: {
const nodes = [...(params.canvasData.nodes || [])];
const edges = [...(params.canvasData.edges || [])];
const existingOperationNodes = findExistingOperationNodes(
nodes,
params.plan.operationId
);
const existingAudit = findCanvasAgentOperationAudit(
params.canvasData,
params.plan.operationId
);
if (existingAudit) {
const auditResult = buildIdempotentCanvasAgentAuditResult({
canvasData: params.canvasData,
audit: existingAudit,
});
if (params.plan.operation !== "delete_nodes") {
return auditResult;
}
const deletedNodeIds = normalizeCanvasAgentAuditStringList(
existingAudit.deletedNodeIds
);
return {
canvasData: params.canvasData,
createdNodeIds: [],
createdNodeIdsByMediaItemId: {},
updatedNodeIds: deletedNodeIds.length > 0 ? deletedNodeIds : auditResult.updatedNodeIds,
createdEdgeIds: [],
};
}
assertFreshBaseForCanvasAgentHighRiskMutation(
params.plan,
params.baseCanvasWasStale
);
if (
existingOperationNodes.length > 0 &&
(params.plan.operation === "replace_node_media" ||
params.plan.operation === "move_nodes")
) {
return {
canvasData: params.canvasData,
createdNodeIds: [],
createdNodeIdsByMediaItemId: {},
updatedNodeIds: existingOperationNodes.map((node) => node.id),
createdEdgeIds: [],
};
}
if (params.plan.operation === "delete_nodes") {
return applyDeleteNodesOperation({
canvasData: params.canvasData,
nodes,
edges,
plan: params.plan,
});
}
if (params.plan.operation === "add_annotation") {
return applyAddAnnotationOperation({
canvasData: params.canvasData,
nodes,
edges,
plan: params.plan,
});
}
if (params.plan.operation === "replace_node_media") {
return applyReplaceNodeMediaOperation({
canvasData: params.canvasData,
nodes,
edges,
plan: params.plan,
});
}
if (params.plan.operation === "move_nodes") {
return applyMoveNodesOperation({
canvasData: params.canvasData,
@ -382,6 +975,25 @@ function applyOperationToCanvasData(params: {
});
}
if (params.plan.operation === "create_agent_slot") {
if (existingOperationNodes.length > 0) {
return {
canvasData: params.canvasData,
createdNodeIds: existingOperationNodes.map((node) => node.id),
createdNodeIdsByMediaItemId: {},
updatedNodeIds: [],
createdEdgeIds: [],
};
}
return applyCreateAgentSlotOperation({
canvasData: params.canvasData,
nodes,
edges,
plan: params.plan,
});
}
if (params.plan.operation === "update_text") {
const textContent = String(params.plan.textContent || "").trim();
if (!textContent) {
@ -477,10 +1089,6 @@ function applyOperationToCanvasData(params: {
};
}
const existingOperationNodes = findExistingOperationNodes(
nodes,
params.plan.operationId
);
if (existingOperationNodes.length > 0) {
const byMediaItemId: Record<string, string> = {};
existingOperationNodes.forEach((node) => {
@ -520,16 +1128,22 @@ function applyOperationToCanvasData(params: {
if (fillPolicy === "replace-existing" && filledNodeIds.length > 0) {
throw new CanvasAgentMutationError(
"当前 slot 已有结果,替换已有结果需要用户确认",
409
409,
"CANVAS_AGENT_CONFIRMATION_REQUIRED"
);
}
if (fillPolicy === "replace-empty" && filledNodeIds.length > 0) {
throw new CanvasAgentMutationError("当前 slot 已被填充,请新建 slot 或使用追加版本", 409);
throw new CanvasAgentMutationError(
"当前 slot 已被填充,请新建 slot 或使用追加版本",
409,
"CANVAS_AGENT_CONFLICT"
);
}
if (params.baseCanvasWasStale && fillPolicy === "replace-existing") {
throw new CanvasAgentMutationError(
"画布已更新,替换 slot 前需要刷新并确认",
409
409,
"STALE_CANVAS_REVISION"
);
}
}
@ -599,6 +1213,14 @@ function applyOperationToCanvasData(params: {
};
}
export function applyCanvasOperationPlanToCanvasData(params: {
canvasData: CanvasDataLike;
plan: CanvasOperationPlan;
baseCanvasWasStale?: boolean;
}) {
return applyOperationToCanvasData(params);
}
export async function applyCanvasOperationPlan(params: {
userId: string;
projectId: string;
@ -632,10 +1254,34 @@ export async function applyCanvasOperationPlan(params: {
},
baseCanvasWasStale,
});
const existingAudit = findCanvasAgentOperationAudit(
currentCanvasData,
params.plan.operationId
);
if (existingAudit) {
const currentRevision = project.updatedAt.toISOString();
return {
...applied,
canvasData: currentCanvasData,
canvasRevision: currentRevision,
canvasUpdatedAt: currentRevision,
baseCanvasWasStale,
};
}
const auditedCanvasData = appendCanvasAgentOperationAuditIfMissing({
canvasData: applied.canvasData,
plan: {
...params.plan,
projectId: params.projectId,
},
result: applied,
}) as CanvasDataLike;
const { syncCanvasNodesForProject } = await import("@/lib/db/queries");
const syncedCanvasData = await syncCanvasNodesForProject({
projectId: params.projectId,
userId: params.userId,
canvasData: applied.canvasData,
canvasData: auditedCanvasData,
});
const updatedAt = new Date();
const [updatedProject] = await db

View File

@ -0,0 +1,118 @@
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
export type CanvasAgentOperationAuditCanvasData = {
metadata?: unknown;
[key: string]: unknown;
};
function normalizeCanvasMetadata(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value)
? { ...(value as Record<string, unknown>) }
: {};
}
export function getCanvasAgentOperationAudit(
canvasData: CanvasAgentOperationAuditCanvasData
) {
const metadata = normalizeCanvasMetadata(canvasData.metadata);
const audit = Array.isArray(metadata.canvasAgentOperationAudit)
? metadata.canvasAgentOperationAudit
: [];
return audit.filter((item) => item && typeof item === "object").slice(-30);
}
export function findCanvasAgentOperationAudit(
canvasData: CanvasAgentOperationAuditCanvasData,
operationId: string
) {
return getCanvasAgentOperationAudit(canvasData).find(
(item) =>
String((item as Record<string, unknown>).operationId || "").trim() ===
operationId
) as Record<string, unknown> | undefined;
}
export function normalizeCanvasAgentAuditStringList(value: unknown) {
return Array.isArray(value)
? value.map((item) => String(item || "").trim()).filter(Boolean)
: [];
}
export function buildIdempotentCanvasAgentAuditResult(params: {
canvasData: CanvasAgentOperationAuditCanvasData;
audit: Record<string, unknown>;
}) {
return {
canvasData: params.canvasData,
createdNodeIds: normalizeCanvasAgentAuditStringList(params.audit.createdNodeIds),
createdNodeIdsByMediaItemId:
params.audit.createdNodeIdsByMediaItemId &&
typeof params.audit.createdNodeIdsByMediaItemId === "object" &&
!Array.isArray(params.audit.createdNodeIdsByMediaItemId)
? Object.fromEntries(
Object.entries(
params.audit.createdNodeIdsByMediaItemId as Record<string, unknown>
)
.map(([mediaItemId, nodeId]) => [
String(mediaItemId || "").trim(),
String(nodeId || "").trim(),
])
.filter(([mediaItemId, nodeId]) => mediaItemId && nodeId)
)
: {},
updatedNodeIds: normalizeCanvasAgentAuditStringList(params.audit.updatedNodeIds),
createdEdgeIds: normalizeCanvasAgentAuditStringList(params.audit.createdEdgeIds),
};
}
export function appendCanvasAgentOperationAudit<T extends CanvasAgentOperationAuditCanvasData>(
params: {
canvasData: T;
auditEntry: Record<string, unknown>;
}
): T {
const metadata = normalizeCanvasMetadata(params.canvasData.metadata);
const audit = getCanvasAgentOperationAudit(params.canvasData);
return {
...params.canvasData,
metadata: {
...metadata,
canvasAgentOperationAudit: [...audit, params.auditEntry].slice(-30),
},
};
}
export function appendCanvasAgentOperationAuditIfMissing<
T extends CanvasAgentOperationAuditCanvasData,
>(params: {
canvasData: T;
plan: CanvasOperationPlan;
result: {
createdNodeIds: string[];
createdNodeIdsByMediaItemId: Record<string, string>;
updatedNodeIds: string[];
createdEdgeIds: string[];
};
}) {
if (findCanvasAgentOperationAudit(params.canvasData, params.plan.operationId)) {
return params.canvasData;
}
return appendCanvasAgentOperationAudit({
canvasData: params.canvasData,
auditEntry: {
operationId: params.plan.operationId,
operation: params.plan.operation,
createdNodeIds: params.result.createdNodeIds,
createdNodeIdsByMediaItemId: params.result.createdNodeIdsByMediaItemId,
updatedNodeIds: params.result.updatedNodeIds,
createdEdgeIds: params.result.createdEdgeIds,
sourceNodeIds: params.plan.sourceNodeIds || [],
targetNodeIds: params.plan.targetNodeIds || [],
targetSlotId: params.plan.targetSlotId,
anchorNodeId: params.plan.anchorNodeId,
appliedAt: new Date().toISOString(),
metadata: params.plan.metadata || {},
},
});
}

View File

@ -0,0 +1,45 @@
export type CanvasAgentOperationErrorCode =
| "STALE_CANVAS_REVISION"
| "CANVAS_AGENT_CONFIRMATION_REQUIRED"
| "CANVAS_AGENT_CONFLICT"
| "CANVAS_AGENT_WRITEBACK_DISABLED"
| "CANVAS_AGENT_WRITEBACK_FAILED";
const CANVAS_AGENT_OPERATION_ERROR_CODES = new Set<string>([
"STALE_CANVAS_REVISION",
"CANVAS_AGENT_CONFIRMATION_REQUIRED",
"CANVAS_AGENT_CONFLICT",
"CANVAS_AGENT_WRITEBACK_DISABLED",
"CANVAS_AGENT_WRITEBACK_FAILED",
]);
export function normalizeCanvasAgentOperationErrorCode(
value: unknown
): CanvasAgentOperationErrorCode {
const code = String(value || "").trim();
return CANVAS_AGENT_OPERATION_ERROR_CODES.has(code)
? (code as CanvasAgentOperationErrorCode)
: "CANVAS_AGENT_WRITEBACK_FAILED";
}
export function getCanvasAgentOperationErrorMessage(params: {
code?: unknown;
fallbackMessage?: string;
}) {
const code = normalizeCanvasAgentOperationErrorCode(params.code);
if (code === "STALE_CANVAS_REVISION") {
return "画布已更新,生成结果已保留在对话中。请刷新画布后重新确认写回。";
}
if (code === "CANVAS_AGENT_CONFIRMATION_REQUIRED") {
return "这个画布操作需要确认后再执行,生成结果已保留在对话中。";
}
if (code === "CANVAS_AGENT_CONFLICT") {
return "画布目标状态有冲突,生成结果已保留在对话中。请检查槽位或刷新后重试。";
}
if (code === "CANVAS_AGENT_WRITEBACK_DISABLED") {
return "当前画布 Agent 写回处于灰度保护,未修改画布。";
}
const fallback = String(params.fallbackMessage || "").trim();
return fallback || "画布写回失败,生成结果已保留在对话中。";
}

22
lib/canvas/agent-risk.ts Normal file
View File

@ -0,0 +1,22 @@
export function isCanvasAgentHighRiskMutation(operation: unknown) {
return (
operation === "replace_node_media" ||
operation === "delete_nodes" ||
operation === "move_nodes"
);
}
export function shouldRejectStaleCanvasAgentHighRiskMutation(params: {
operation: unknown;
confirmed?: boolean;
metadata?: Record<string, unknown>;
baseCanvasWasStale?: boolean;
}) {
const metadata = params.metadata || {};
const confirmed = params.confirmed === true || metadata.confirmedByUser === true;
return (
params.baseCanvasWasStale === true &&
confirmed &&
isCanvasAgentHighRiskMutation(params.operation)
);
}

View File

@ -0,0 +1,122 @@
import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol";
type CanvasAgentViewNodeLike = {
id: string;
type?: string;
position?: { x?: number; y?: number };
size?: { width?: number; height?: number };
data?: unknown;
createdAt?: string | number | Date;
updatedAt?: string | number | Date;
};
type CanvasAgentViewportLike = {
x: number;
y: number;
zoom: number;
};
type CanvasAgentStageLike = {
width: number;
height: number;
};
const VIEWPORT_ANCHOR_X_RATIO = 0.52;
const VIEWPORT_ANCHOR_Y_RATIO = 0.46;
function toFiniteNumber(value: unknown, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function getNodeTimestamp(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (value instanceof Date) return value.getTime();
if (typeof value !== "string" || !value.trim()) return 0;
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : 0;
}
function getCanvasAgentResultScore(node: CanvasAgentViewNodeLike) {
const data =
node.data && typeof node.data === "object" && !Array.isArray(node.data)
? (node.data as Record<string, unknown>)
: {};
const metadata =
data.metadata && typeof data.metadata === "object" && !Array.isArray(data.metadata)
? (data.metadata as Record<string, unknown>)
: {};
const generatedByAgent =
metadata.generatedByAgent === true ||
metadata.latestGeneratedHighlight === true ||
typeof metadata.canvasAgentOperationId === "string";
if (!generatedByAgent) return 0;
return Math.max(
getNodeTimestamp(metadata.recentlyGeneratedAt),
getNodeTimestamp(metadata.agentGeneratedAt),
getNodeTimestamp(metadata.agentReplacedAt),
getNodeTimestamp(node.updatedAt),
getNodeTimestamp(node.createdAt),
1
);
}
export function resolveCanvasViewOperationTargetNodeIds(params: {
plan: Pick<CanvasViewOperationPlan, "operation" | "nodeIds" | "anchorNodeId">;
nodes: CanvasAgentViewNodeLike[];
selectedNodeIds?: string[];
maxLatestResults?: number;
}) {
const explicitNodeIds = Array.isArray(params.plan.nodeIds)
? params.plan.nodeIds.map((id) => String(id || "").trim()).filter(Boolean)
: [];
if (explicitNodeIds.length > 0) return Array.from(new Set(explicitNodeIds));
const anchorNodeId = String(params.plan.anchorNodeId || "").trim();
if (anchorNodeId) return [anchorNodeId];
if (params.plan.operation === "focus_selection") {
return Array.isArray(params.selectedNodeIds)
? params.selectedNodeIds.map((id) => String(id || "").trim()).filter(Boolean)
: [];
}
if (params.plan.operation === "focus_latest_result") {
const maxLatestResults = Math.max(1, Math.min(8, params.maxLatestResults || 4));
return params.nodes
.map((node) => ({
id: node.id,
score: getCanvasAgentResultScore(node),
}))
.filter((item) => item.id && item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, maxLatestResults)
.map((item) => item.id);
}
return [];
}
export function computeCanvasAgentPanViewport(params: {
currentViewport: CanvasAgentViewportLike;
center: { x?: number; y?: number };
stage: CanvasAgentStageLike;
zoom?: number;
}) {
const currentZoom = Math.max(0.1, toFiniteNumber(params.currentViewport.zoom, 1));
const zoom = Math.max(
0.1,
Math.min(4, toFiniteNumber(params.zoom, currentZoom))
);
const centerX = toFiniteNumber(params.center.x, 0);
const centerY = toFiniteNumber(params.center.y, 0);
const stageWidth = Math.max(1, toFiniteNumber(params.stage.width, 1200));
const stageHeight = Math.max(1, toFiniteNumber(params.stage.height, 800));
return {
x: stageWidth * VIEWPORT_ANCHOR_X_RATIO - centerX * zoom,
y: stageHeight * VIEWPORT_ANCHOR_Y_RATIO - centerY * zoom,
zoom,
};
}

View File

@ -123,6 +123,7 @@ interface CanvasStore {
// 🆕 新增节点(用于自动聚焦)
lastAddedNodeId: string | null;
agentOperationRefreshUntil: number;
// 项目操作
setProject: (id: string | null, title?: string) => void;
@ -197,6 +198,11 @@ interface CanvasStore {
// 🆕 自动聚焦控制
clearLastAddedNodeId: () => void;
// Agent mutation revalidate guard
markAgentOperationRefresh: (durationMs?: number) => void;
clearAgentOperationRefresh: () => void;
isAgentOperationRefreshActive: () => boolean;
}
export const useCanvasStore = create<CanvasStore>()(
@ -231,6 +237,7 @@ export const useCanvasStore = create<CanvasStore>()(
pendingPickedObjects: [],
imageNodeActionRequest: null,
lastAddedNodeId: null,
agentOperationRefreshUntil: 0,
// 项目操作
setProject: (id, title) => set((state) => {
@ -247,6 +254,22 @@ export const useCanvasStore = create<CanvasStore>()(
state.canvasUpdatedAt = String(updatedAt || revision || "").trim() || null;
}),
markAgentOperationRefresh: (durationMs = 5000) => set((state) => {
const until = Date.now() + Math.max(0, Number(durationMs) || 0);
state.agentOperationRefreshUntil = Math.max(
state.agentOperationRefreshUntil,
until
);
}),
clearAgentOperationRefresh: () => set((state) => {
state.agentOperationRefreshUntil = 0;
}),
isAgentOperationRefreshActive: () => (
Date.now() < get().agentOperationRefreshUntil
),
// 节点操作
addNode: (nodeData) => {
const imageMetadata =

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,17 @@ export type CanvasAgentFocusPolicy =
| "original"
| "slot";
export type CanvasAgentSlotType = "image" | "video" | "ui-screen";
export type CanvasAgentSlotFillPolicy =
| "replace-empty"
| "append-version"
| "replace-existing";
export type CanvasAgentSlotAcceptedSource =
| "text"
| "image"
| "selection"
| "annotation";
export interface CanvasViewportContext {
x: number;
y: number;
@ -44,7 +55,7 @@ export interface CanvasAgentNodeRef {
export interface CanvasAgentSlotRef {
nodeId: string;
slotType: "image" | "video" | "ui-screen";
slotType: CanvasAgentSlotType;
label?: string;
x: number;
y: number;
@ -52,8 +63,16 @@ export interface CanvasAgentSlotRef {
height: number;
aspectRatio: string;
isSelected?: boolean;
fillPolicy: "replace-empty" | "append-version" | "replace-existing";
acceptedSources?: Array<"text" | "image" | "selection" | "annotation">;
fillPolicy: CanvasAgentSlotFillPolicy;
acceptedSources?: CanvasAgentSlotAcceptedSource[];
}
export interface CanvasAgentSlotSpec {
slotType: CanvasAgentSlotType;
label?: string;
aspectRatio?: string;
fillPolicy?: CanvasAgentSlotFillPolicy;
acceptedSources?: CanvasAgentSlotAcceptedSource[];
}
export interface CanvasAgentAnnotationRef {
@ -91,6 +110,7 @@ export interface CanvasAgentContext {
}
export type CanvasOperationKind =
| "create_agent_slot"
| "fill_slot"
| "insert_beside"
| "append_revision"
@ -98,6 +118,7 @@ export type CanvasOperationKind =
| "add_annotation"
| "link_nodes"
| "move_nodes"
| "delete_nodes"
| "update_text";
export interface CanvasMediaItem {
@ -133,6 +154,12 @@ export interface CanvasOperationPlan {
x: number;
y: number;
};
slot?: CanvasAgentSlotSpec;
annotations?: Array<
Partial<CanvasAgentAnnotationRef> & {
type: CanvasAgentAnnotationRef["type"];
}
>;
placement?: "right" | "left" | "below" | "above" | "viewport-center";
placementStrategy?: "single" | "row" | "grid" | "overlay" | "beside_each_source";
preserveOriginal?: boolean;