feat: add canvas agent operation protocol
This commit is contained in:
parent
12b21d6f3b
commit
df0daf4d27
@ -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
@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 =>
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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 : "处理失败,请重试";
|
||||
|
||||
491
lib/canvas/agent-annotation-metadata.ts
Normal file
491
lib/canvas/agent-annotation-metadata.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
63
lib/canvas/agent-annotation-prompt.ts
Normal file
63
lib/canvas/agent-annotation-prompt.ts
Normal 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");
|
||||
}
|
||||
82
lib/canvas/agent-feature-flags.ts
Normal file
82
lib/canvas/agent-feature-flags.ts
Normal 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";
|
||||
}
|
||||
@ -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: "一句给用户看的中文短回复",
|
||||
|
||||
@ -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
|
||||
|
||||
118
lib/canvas/agent-operation-audit.ts
Normal file
118
lib/canvas/agent-operation-audit.ts
Normal 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 || {},
|
||||
},
|
||||
});
|
||||
}
|
||||
45
lib/canvas/agent-operation-errors.ts
Normal file
45
lib/canvas/agent-operation-errors.ts
Normal 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
22
lib/canvas/agent-risk.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
122
lib/canvas/agent-view-operations.ts
Normal file
122
lib/canvas/agent-view-operations.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user