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(
|
export function writeProtocolDebugChunk(
|
||||||
writer: StreamWriter,
|
writer: StreamWriter,
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
@ -245,16 +251,10 @@ export function writeCanvasMediaChunk(
|
|||||||
agentCategoryName: String(params.agentCategoryName || "").trim() || undefined,
|
agentCategoryName: String(params.agentCategoryName || "").trim() || undefined,
|
||||||
agentExecutionSnapshotId:
|
agentExecutionSnapshotId:
|
||||||
String(params.agentExecutionSnapshotId || "").trim() || undefined,
|
String(params.agentExecutionSnapshotId || "").trim() || undefined,
|
||||||
mediaItemIds: Array.isArray(params.mediaItemIds)
|
mediaItemIds: normalizeStreamIdList(params.mediaItemIds),
|
||||||
? params.mediaItemIds
|
handledCanvasMediaItemIds: normalizeStreamIdList(
|
||||||
.map((id) => String(id || "").trim())
|
params.handledCanvasMediaItemIds
|
||||||
.filter(Boolean)
|
),
|
||||||
: [],
|
|
||||||
handledCanvasMediaItemIds: Array.isArray(params.handledCanvasMediaItemIds)
|
|
||||||
? params.handledCanvasMediaItemIds
|
|
||||||
.map((id) => String(id || "").trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
: [],
|
|
||||||
},
|
},
|
||||||
label
|
label
|
||||||
);
|
);
|
||||||
@ -266,6 +266,7 @@ export function writeCanvasOperationChunk(
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
operationId: string;
|
operationId: string;
|
||||||
operation: string;
|
operation: string;
|
||||||
|
status?: "applied" | "failed" | "shadowed";
|
||||||
createdNodeIds: string[];
|
createdNodeIds: string[];
|
||||||
createdNodeIdsByMediaItemId?: Record<string, string>;
|
createdNodeIdsByMediaItemId?: Record<string, string>;
|
||||||
updatedNodeIds?: string[];
|
updatedNodeIds?: string[];
|
||||||
@ -273,6 +274,10 @@ export function writeCanvasOperationChunk(
|
|||||||
canvasRevision?: string;
|
canvasRevision?: string;
|
||||||
anchorNodeId?: string | null;
|
anchorNodeId?: string | null;
|
||||||
handledCanvasMediaItemIds?: string[];
|
handledCanvasMediaItemIds?: string[];
|
||||||
|
mediaItemIds?: string[];
|
||||||
|
errorCode?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
fallbackToCanvasMedia?: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
},
|
},
|
||||||
label: string
|
label: string
|
||||||
@ -284,13 +289,21 @@ export function writeCanvasOperationChunk(
|
|||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
operationId: params.operationId,
|
operationId: params.operationId,
|
||||||
operation: params.operation,
|
operation: params.operation,
|
||||||
|
status: params.status || "applied",
|
||||||
createdNodeIds: params.createdNodeIds || [],
|
createdNodeIds: params.createdNodeIds || [],
|
||||||
createdNodeIdsByMediaItemId: params.createdNodeIdsByMediaItemId || {},
|
createdNodeIdsByMediaItemId: params.createdNodeIdsByMediaItemId || {},
|
||||||
updatedNodeIds: params.updatedNodeIds || [],
|
updatedNodeIds: params.updatedNodeIds || [],
|
||||||
createdEdgeIds: params.createdEdgeIds || [],
|
createdEdgeIds: params.createdEdgeIds || [],
|
||||||
canvasRevision: params.canvasRevision,
|
canvasRevision: params.canvasRevision,
|
||||||
anchorNodeId: params.anchorNodeId || undefined,
|
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,
|
message: params.message,
|
||||||
},
|
},
|
||||||
label
|
label
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -5,6 +5,10 @@ import {
|
|||||||
applyCanvasOperationPlan,
|
applyCanvasOperationPlan,
|
||||||
CanvasAgentMutationError,
|
CanvasAgentMutationError,
|
||||||
} from "@/lib/canvas/agent-mutations";
|
} from "@/lib/canvas/agent-mutations";
|
||||||
|
import {
|
||||||
|
canApplyCanvasAgentMutations,
|
||||||
|
getCanvasAgentFeatureFlags,
|
||||||
|
} from "@/lib/canvas/agent-feature-flags";
|
||||||
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
|
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
|
||||||
|
|
||||||
const canvasOperationPlanSchema = z
|
const canvasOperationPlanSchema = z
|
||||||
@ -12,11 +16,15 @@ const canvasOperationPlanSchema = z
|
|||||||
version: z.literal(1),
|
version: z.literal(1),
|
||||||
operationId: z.string().min(1).max(160),
|
operationId: z.string().min(1).max(160),
|
||||||
operation: z.enum([
|
operation: z.enum([
|
||||||
|
"create_agent_slot",
|
||||||
"fill_slot",
|
"fill_slot",
|
||||||
"insert_beside",
|
"insert_beside",
|
||||||
"append_revision",
|
"append_revision",
|
||||||
|
"replace_node_media",
|
||||||
|
"add_annotation",
|
||||||
"link_nodes",
|
"link_nodes",
|
||||||
"move_nodes",
|
"move_nodes",
|
||||||
|
"delete_nodes",
|
||||||
"update_text",
|
"update_text",
|
||||||
]),
|
]),
|
||||||
projectId: z.string().uuid().optional(),
|
projectId: z.string().uuid().optional(),
|
||||||
@ -37,6 +45,47 @@ const canvasOperationPlanSchema = z
|
|||||||
y: z.number(),
|
y: z.number(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.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
|
placement: z
|
||||||
.enum(["right", "left", "below", "above", "viewport-center"])
|
.enum(["right", "left", "below", "above", "viewport-center"])
|
||||||
.optional(),
|
.optional(),
|
||||||
@ -78,9 +127,12 @@ const canvasOperationPlanSchema = z
|
|||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(value) =>
|
(value) =>
|
||||||
|
value.operation === "create_agent_slot" ||
|
||||||
value.operation === "link_nodes" ||
|
value.operation === "link_nodes" ||
|
||||||
value.operation === "update_text" ||
|
value.operation === "update_text" ||
|
||||||
|
value.operation === "add_annotation" ||
|
||||||
value.operation === "move_nodes" ||
|
value.operation === "move_nodes" ||
|
||||||
|
value.operation === "delete_nodes" ||
|
||||||
value.media ||
|
value.media ||
|
||||||
(value.mediaItems?.length ?? 0) > 0,
|
(value.mediaItems?.length ?? 0) > 0,
|
||||||
{
|
{
|
||||||
@ -88,6 +140,16 @@ const canvasOperationPlanSchema = z
|
|||||||
path: ["mediaItems"],
|
path: ["mediaItems"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.refine(
|
||||||
|
(value) =>
|
||||||
|
value.operation !== "add_annotation" ||
|
||||||
|
Boolean(String(value.textContent || "").trim()) ||
|
||||||
|
(value.annotations?.length ?? 0) > 0,
|
||||||
|
{
|
||||||
|
message: "缺少批注内容",
|
||||||
|
path: ["annotations"],
|
||||||
|
}
|
||||||
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(value) =>
|
(value) =>
|
||||||
value.operation !== "update_text" ||
|
value.operation !== "update_text" ||
|
||||||
@ -116,6 +178,19 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
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 body = requestSchema.parse(await request.json());
|
||||||
const result = await applyCanvasOperationPlan({
|
const result = await applyCanvasOperationPlan({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
@ -130,7 +205,11 @@ export async function POST(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof CanvasAgentMutationError) {
|
if (error instanceof CanvasAgentMutationError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: error.message },
|
{
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
},
|
||||||
{ status: error.status }
|
{ status: error.status }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
MonitorSmartphone,
|
||||||
MousePointer,
|
MousePointer,
|
||||||
Music2,
|
Music2,
|
||||||
Palette,
|
Palette,
|
||||||
@ -234,6 +235,7 @@ import type {
|
|||||||
AgentWorkflowUiConfig,
|
AgentWorkflowUiConfig,
|
||||||
} from "@/types/agent";
|
} from "@/types/agent";
|
||||||
import type {
|
import type {
|
||||||
|
AgentSlotNodeData,
|
||||||
CanvasEdge,
|
CanvasEdge,
|
||||||
CanvasNode,
|
CanvasNode,
|
||||||
CanvasState,
|
CanvasState,
|
||||||
@ -1785,6 +1787,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
const updateNode = useCanvasStore((state) => state.updateNode);
|
const updateNode = useCanvasStore((state) => state.updateNode);
|
||||||
const pushHistory = useCanvasStore((state) => state.pushHistory);
|
const pushHistory = useCanvasStore((state) => state.pushHistory);
|
||||||
const setGenerating = useCanvasStore((state) => state.setGenerating);
|
const setGenerating = useCanvasStore((state) => state.setGenerating);
|
||||||
|
const isAgentOperationRefreshActive = useCanvasStore(
|
||||||
|
(state) => state.isAgentOperationRefreshActive
|
||||||
|
);
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@ -4477,7 +4482,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
|
|
||||||
// 自动保存逻辑
|
// 自动保存逻辑
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isAgentMode) return;
|
if (isLoading || isAgentMode || isAgentOperationRefreshActive()) return;
|
||||||
|
|
||||||
const saveTimer = setTimeout(async () => {
|
const saveTimer = setTimeout(async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
@ -4490,11 +4495,11 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
}, 3000); // 3秒自动保存
|
}, 3000); // 3秒自动保存
|
||||||
|
|
||||||
return () => clearTimeout(saveTimer);
|
return () => clearTimeout(saveTimer);
|
||||||
}, [isAgentMode, nodes, isLoading, persistCanvasState]);
|
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
||||||
|
|
||||||
// 删除节点时立即保存(按节点ID差集检测),避免删除后快速刷新被回滚
|
// 删除节点时立即保存(按节点ID差集检测),避免删除后快速刷新被回滚
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isAgentMode) {
|
if (isLoading || isAgentMode || isAgentOperationRefreshActive()) {
|
||||||
previousNodeIdsRef.current = new Set(nodes.map((node) => node.id));
|
previousNodeIdsRef.current = new Set(nodes.map((node) => node.id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -4521,7 +4526,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
};
|
};
|
||||||
void persistAfterDelete();
|
void persistAfterDelete();
|
||||||
}
|
}
|
||||||
}, [isAgentMode, nodes, isLoading, persistCanvasState]);
|
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
||||||
|
|
||||||
// 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率
|
// 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -4529,6 +4534,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
|
|
||||||
const handleBeforeUnload = () => {
|
const handleBeforeUnload = () => {
|
||||||
try {
|
try {
|
||||||
|
if (isAgentOperationRefreshActive()) return;
|
||||||
const canvasData = buildPersistedCanvasData();
|
const canvasData = buildPersistedCanvasData();
|
||||||
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
||||||
void fetch(`/api/canvas/${id}`, {
|
void fetch(`/api/canvas/${id}`, {
|
||||||
@ -4557,6 +4563,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
activeCanvasWorkspaceType,
|
activeCanvasWorkspaceType,
|
||||||
buildPersistedCanvasData,
|
buildPersistedCanvasData,
|
||||||
id,
|
id,
|
||||||
|
isAgentOperationRefreshActive,
|
||||||
isAgentMode,
|
isAgentMode,
|
||||||
isLoading,
|
isLoading,
|
||||||
]);
|
]);
|
||||||
@ -6773,9 +6780,34 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
});
|
});
|
||||||
}, [createCanvasNode, handleAddWorkflowNode, isVideoMode]);
|
}, [createCanvasNode, handleAddWorkflowNode, isVideoMode]);
|
||||||
|
|
||||||
const handleAddAgentImageSlotNode = useCallback(() => {
|
const handleAddAgentSlotNode = useCallback((slotType: AgentSlotNodeData["slotType"]) => {
|
||||||
if (isVideoMode) return;
|
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();
|
const origin = getVisibleCanvasOrigin();
|
||||||
createCanvasNode({
|
createCanvasNode({
|
||||||
type: "agent-slot",
|
type: "agent-slot",
|
||||||
@ -6783,17 +6815,18 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
x: origin.x + 40,
|
x: origin.x + 40,
|
||||||
y: origin.y + 40,
|
y: origin.y + 40,
|
||||||
},
|
},
|
||||||
size: { width: 320, height: 320 },
|
size: defaults.size,
|
||||||
data: {
|
data: {
|
||||||
slotType: "image",
|
slotType,
|
||||||
label: "图片槽位",
|
label: defaults.label,
|
||||||
status: "empty",
|
status: "empty",
|
||||||
aspectRatio: "1:1",
|
aspectRatio: defaults.aspectRatio,
|
||||||
fillPolicy: "replace-empty",
|
fillPolicy: "replace-empty",
|
||||||
acceptedSources: ["text", "image", "selection", "annotation"],
|
acceptedSources: ["text", "image", "selection", "annotation"],
|
||||||
filledNodeIds: [],
|
filledNodeIds: [],
|
||||||
metadata: {
|
metadata: {
|
||||||
createdBy: "toolbar",
|
createdBy: "toolbar",
|
||||||
|
createdSlotType: slotType,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -6801,6 +6834,10 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
});
|
});
|
||||||
}, [createCanvasNode, getVisibleCanvasOrigin, isVideoMode]);
|
}, [createCanvasNode, getVisibleCanvasOrigin, isVideoMode]);
|
||||||
|
|
||||||
|
const handleAddAgentImageSlotNode = useCallback(() => {
|
||||||
|
handleAddAgentSlotNode("image");
|
||||||
|
}, [handleAddAgentSlotNode]);
|
||||||
|
|
||||||
const handleAddVideoNode = useCallback(() => {
|
const handleAddVideoNode = useCallback(() => {
|
||||||
if (isVideoMode) {
|
if (isVideoMode) {
|
||||||
handleAddWorkflowNode("video-input");
|
handleAddWorkflowNode("video-input");
|
||||||
@ -10403,33 +10440,53 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"addAgentImageSlot"
|
"addAgentImageSlot"
|
||||||
) ? (
|
) ? (
|
||||||
<Tooltip>
|
<DropdownMenu>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
className="h-9 w-9 rounded-xl"
|
<DropdownMenuTrigger asChild>
|
||||||
onClick={handleAddAgentImageSlotNode}
|
<Button className="h-9 w-9 rounded-xl" size="icon" variant="ghost">
|
||||||
size="icon"
|
<CanvasToolbarIcon
|
||||||
variant="ghost"
|
className="h-4 w-4"
|
||||||
>
|
fallback={ImagePlus}
|
||||||
<CanvasToolbarIcon
|
item={getCanvasToolbarItem(
|
||||||
className="h-4 w-4"
|
canvasToolbarConfig,
|
||||||
fallback={ImagePlus}
|
"addAgentImageSlot"
|
||||||
item={getCanvasToolbarItem(
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{
|
||||||
|
getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"addAgentImageSlot"
|
"addAgentImageSlot"
|
||||||
)}
|
).label
|
||||||
/>
|
}
|
||||||
</Button>
|
</TooltipContent>
|
||||||
</TooltipTrigger>
|
</Tooltip>
|
||||||
<TooltipContent side="top">
|
<DropdownMenuContent align="start" side="bottom">
|
||||||
{
|
<DropdownMenuItem
|
||||||
getCanvasToolbarItem(
|
onSelect={() => handleAddAgentImageSlotNode()}
|
||||||
canvasToolbarConfig,
|
>
|
||||||
"addAgentImageSlot"
|
<ImagePlus className="h-4 w-4" />
|
||||||
).label
|
图片槽位
|
||||||
}
|
</DropdownMenuItem>
|
||||||
</TooltipContent>
|
<DropdownMenuItem
|
||||||
</Tooltip>
|
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}
|
) : null}
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -96,6 +96,11 @@ import {
|
|||||||
isCanvasImageReadyForAi,
|
isCanvasImageReadyForAi,
|
||||||
isCanvasImageUploadPending,
|
isCanvasImageUploadPending,
|
||||||
} from "@/lib/canvas/local-upload-state";
|
} 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 { getCanvasSkillIconComponent } from "@/components/canvas/canvas-skill-icons";
|
||||||
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
|
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
|
||||||
import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar";
|
import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar";
|
||||||
@ -122,7 +127,10 @@ import {
|
|||||||
getAgentProgressFallbackTitle,
|
getAgentProgressFallbackTitle,
|
||||||
getAgentTargetLockTitle,
|
getAgentTargetLockTitle,
|
||||||
getAgentTargetResolutionLabel,
|
getAgentTargetResolutionLabel,
|
||||||
|
getCanvasMediaPayloadHandledItemIds,
|
||||||
|
getCanvasOperationHandledMediaItemIds,
|
||||||
type ImageGenerationStatusStreamPayload,
|
type ImageGenerationStatusStreamPayload,
|
||||||
|
shouldSkipCanvasMediaPayloadForHandledItems,
|
||||||
} from "@/components/canvas/chat-panel/canvas-agent-stream";
|
} from "@/components/canvas/chat-panel/canvas-agent-stream";
|
||||||
import {
|
import {
|
||||||
type DetailArchive,
|
type DetailArchive,
|
||||||
@ -1825,9 +1833,6 @@ export function CanvasChatPanel({
|
|||||||
|
|
||||||
const store = useCanvasStore.getState();
|
const store = useCanvasStore.getState();
|
||||||
const currentNodes = store.nodes;
|
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 selectedIds = store.selectedNodeIds.filter(Boolean);
|
||||||
const zoomByFactor = (factor: number) => {
|
const zoomByFactor = (factor: number) => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@ -1859,15 +1864,31 @@ export function CanvasChatPanel({
|
|||||||
});
|
});
|
||||||
return;
|
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 =
|
const targetIds = resolveCanvasViewOperationTargetNodeIds({
|
||||||
payload.operation === "focus_selection" && nodeIds.length === 0
|
plan: payload,
|
||||||
? selectedIds
|
nodes: currentNodes,
|
||||||
: nodeIds.length > 0
|
selectedNodeIds: selectedIds,
|
||||||
? nodeIds
|
});
|
||||||
: payload.anchorNodeId
|
|
||||||
? [payload.anchorNodeId]
|
|
||||||
: [];
|
|
||||||
const targetNodes =
|
const targetNodes =
|
||||||
payload.operation === "fit_all"
|
payload.operation === "fit_all"
|
||||||
? currentNodes.filter((node) =>
|
? 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(
|
const applyVideoWorkspacePreset = useCallback(
|
||||||
(mode: VideoGenerationParams["mode"]) => {
|
(mode: VideoGenerationParams["mode"]) => {
|
||||||
if (!preferredVideoWorkspaceModel) {
|
if (!preferredVideoWorkspaceModel) {
|
||||||
@ -4002,19 +4065,19 @@ export function CanvasChatPanel({
|
|||||||
try {
|
try {
|
||||||
const result = await canvasService.getProject(targetProjectId);
|
const result = await canvasService.getProject(targetProjectId);
|
||||||
if (!result.success || !result.data?.canvasData) {
|
if (!result.success || !result.data?.canvasData) {
|
||||||
|
useCanvasStore.getState().markAgentOperationRefresh();
|
||||||
void mutate(`/api/canvas/${targetProjectId}`);
|
void mutate(`/api/canvas/${targetProjectId}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
useCanvasStore.getState().loadCanvas(result.data.canvasData);
|
const store = useCanvasStore.getState();
|
||||||
useCanvasStore
|
store.markAgentOperationRefresh();
|
||||||
.getState()
|
store.loadCanvas(result.data.canvasData);
|
||||||
.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
|
store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
|
||||||
if (createdNodeIds.length > 0) {
|
if (createdNodeIds.length > 0) {
|
||||||
useCanvasStore.getState().selectNodes(createdNodeIds);
|
const refreshedStore = useCanvasStore.getState();
|
||||||
const focusNode = useCanvasStore
|
refreshedStore.selectNodes(createdNodeIds);
|
||||||
.getState()
|
const focusNode = refreshedStore.nodes.find((node) => node.id === createdNodeIds[0]);
|
||||||
.nodes.find((node) => node.id === createdNodeIds[0]);
|
|
||||||
if (focusNode) {
|
if (focusNode) {
|
||||||
focusCanvasOnNode(focusNode);
|
focusCanvasOnNode(focusNode);
|
||||||
}
|
}
|
||||||
@ -4023,6 +4086,7 @@ export function CanvasChatPanel({
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[CanvasChat] Failed to refresh canvas after agent operation:", error);
|
console.error("[CanvasChat] Failed to refresh canvas after agent operation:", error);
|
||||||
|
useCanvasStore.getState().markAgentOperationRefresh();
|
||||||
void mutate(`/api/canvas/${targetProjectId}`);
|
void mutate(`/api/canvas/${targetProjectId}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -4156,16 +4220,29 @@ export function CanvasChatPanel({
|
|||||||
|
|
||||||
if (dataPart.type === "data-canvasOperation") {
|
if (dataPart.type === "data-canvasOperation") {
|
||||||
try {
|
try {
|
||||||
const payload: CanvasOperationStreamPayload =
|
const payload: CanvasOperationStreamPayload =
|
||||||
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
|
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
|
||||||
if (!payload || typeof payload !== "object") return;
|
if (!payload || typeof payload !== "object") return;
|
||||||
const handledIds = Array.isArray(payload.handledCanvasMediaItemIds)
|
if (payload.status === "failed") {
|
||||||
? payload.handledCanvasMediaItemIds
|
toast.warning(
|
||||||
.map((id) => String(id || "").trim())
|
getCanvasAgentOperationErrorMessage({
|
||||||
.filter(Boolean)
|
code: payload.errorCode,
|
||||||
: [];
|
fallbackMessage: payload.errorMessage || payload.message,
|
||||||
handledIds.forEach((id) => handledCanvasMediaItemIdsRef.current.add(id));
|
})
|
||||||
const targetProjectId = String(payload.projectId || "").trim();
|
);
|
||||||
|
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) {
|
if (targetProjectId) {
|
||||||
void refreshCanvasAfterAgentOperation({
|
void refreshCanvasAfterAgentOperation({
|
||||||
projectId: targetProjectId,
|
projectId: targetProjectId,
|
||||||
@ -4194,27 +4271,20 @@ export function CanvasChatPanel({
|
|||||||
|
|
||||||
if (dataPart.type === "data-canvasMedia") {
|
if (dataPart.type === "data-canvasMedia") {
|
||||||
try {
|
try {
|
||||||
const payload: CanvasMediaStreamPayload =
|
const payload: CanvasMediaStreamPayload =
|
||||||
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
|
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
|
||||||
if (!payload || typeof payload !== "object") return;
|
if (!payload || typeof payload !== "object") return;
|
||||||
const handledIds = Array.isArray(payload.handledCanvasMediaItemIds)
|
const handledIds = getCanvasMediaPayloadHandledItemIds(payload);
|
||||||
? payload.handledCanvasMediaItemIds
|
handledIds.forEach((id) => handledCanvasMediaItemIdsRef.current.add(id));
|
||||||
.map((id) => String(id || "").trim())
|
if (
|
||||||
.filter(Boolean)
|
shouldSkipCanvasMediaPayloadForHandledItems({
|
||||||
: [];
|
payload,
|
||||||
handledIds.forEach((id) => handledCanvasMediaItemIdsRef.current.add(id));
|
handledCanvasMediaItemIds: handledCanvasMediaItemIdsRef.current,
|
||||||
const mediaItemIds = Array.isArray(payload.mediaItemIds)
|
})
|
||||||
? payload.mediaItemIds
|
) {
|
||||||
.map((id) => String(id || "").trim())
|
clearStreamImageGenerationStateForChat(eventChatId);
|
||||||
.filter(Boolean)
|
return;
|
||||||
: [];
|
}
|
||||||
if (
|
|
||||||
mediaItemIds.length > 0 &&
|
|
||||||
mediaItemIds.every((id) => handledCanvasMediaItemIdsRef.current.has(id))
|
|
||||||
) {
|
|
||||||
clearStreamImageGenerationStateForChat(eventChatId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const imageUrls = Array.isArray(payload.imageUrls)
|
const imageUrls = Array.isArray(payload.imageUrls)
|
||||||
? payload.imageUrls.filter(
|
? payload.imageUrls.filter(
|
||||||
(url): url is string =>
|
(url): url is string =>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import React, { useRef, useState, useCallback, useMemo } from "react";
|
|||||||
import { Rnd } from "react-rnd";
|
import { Rnd } from "react-rnd";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
|
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 { ImageNode } from "./nodes/ImageNode";
|
||||||
import { AgentSlotNode } from "./nodes/AgentSlotNode";
|
import { AgentSlotNode } from "./nodes/AgentSlotNode";
|
||||||
import { Model3DNode } from "./nodes/Model3DNode";
|
import { Model3DNode } from "./nodes/Model3DNode";
|
||||||
@ -26,14 +26,18 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
Heart,
|
Heart,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
ImagePlus,
|
||||||
|
LayoutGrid,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
Minus,
|
Minus,
|
||||||
Pin,
|
Pin,
|
||||||
Plus,
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
Star,
|
Star,
|
||||||
Tag,
|
Tag,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
UserRound,
|
UserRound,
|
||||||
|
Video,
|
||||||
Wand2,
|
Wand2,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@ -299,6 +303,12 @@ export function CanvasWorkspace({
|
|||||||
vertical?: { x: number; top: number; bottom: number };
|
vertical?: { x: number; top: number; bottom: number };
|
||||||
horizontal?: { y: number; left: number; right: 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 containerRef = useRef<HTMLDivElement>(null);
|
||||||
const contentLayerRef = useRef<HTMLDivElement>(null);
|
const contentLayerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
@ -310,6 +320,8 @@ export function CanvasWorkspace({
|
|||||||
const [isDownloadSubmitting, setIsDownloadSubmitting] = useState(false);
|
const [isDownloadSubmitting, setIsDownloadSubmitting] = useState(false);
|
||||||
const [snapGuides, setSnapGuides] = useState<SnapGuideLines | null>(null);
|
const [snapGuides, setSnapGuides] = useState<SnapGuideLines | null>(null);
|
||||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||||
|
const [agentSlotContextMenu, setAgentSlotContextMenu] =
|
||||||
|
useState<AgentSlotContextMenuState>(null);
|
||||||
const [connectionDraft, setConnectionDraft] = useState<{
|
const [connectionDraft, setConnectionDraft] = useState<{
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
current: Position;
|
current: Position;
|
||||||
@ -578,6 +590,115 @@ export function CanvasWorkspace({
|
|||||||
});
|
});
|
||||||
}, [updateNode]);
|
}, [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(() => {
|
const isMacPlatform = React.useMemo(() => {
|
||||||
if (typeof navigator === "undefined") return false;
|
if (typeof navigator === "undefined") return false;
|
||||||
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
||||||
@ -947,6 +1068,7 @@ export function CanvasWorkspace({
|
|||||||
// 处理画布点击(取消选择)
|
// 处理画布点击(取消选择)
|
||||||
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
|
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
|
||||||
if (hasBlockingDialogOpen()) return;
|
if (hasBlockingDialogOpen()) return;
|
||||||
|
setAgentSlotContextMenu(null);
|
||||||
if (!isEventFromCanvasContainer(e.target)) return;
|
if (!isEventFromCanvasContainer(e.target)) return;
|
||||||
if (suppressCanvasClickRef.current) {
|
if (suppressCanvasClickRef.current) {
|
||||||
suppressCanvasClickRef.current = false;
|
suppressCanvasClickRef.current = false;
|
||||||
@ -1738,8 +1860,14 @@ export function CanvasWorkspace({
|
|||||||
)}
|
)}
|
||||||
data-canvas-node-id={node.id}
|
data-canvas-node-id={node.id}
|
||||||
style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部
|
style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部
|
||||||
|
onContextMenu={(event: React.MouseEvent) => {
|
||||||
|
if (node.type === "agent-slot") {
|
||||||
|
openAgentSlotContextMenu(event, node);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onClick={(e: React.MouseEvent) => {
|
onClick={(e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
setAgentSlotContextMenu(null);
|
||||||
clearLatestGeneratedImageHighlights();
|
clearLatestGeneratedImageHighlights();
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
selectNode(node.id, e.shiftKey);
|
selectNode(node.id, e.shiftKey);
|
||||||
@ -1822,6 +1950,7 @@ export function CanvasWorkspace({
|
|||||||
<AgentSlotNode
|
<AgentSlotNode
|
||||||
node={node}
|
node={node}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
|
onDataChange={(updates) => handleAgentSlotDataChange(node, updates)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{node.type === 'text' && (
|
{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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@ -2242,6 +2382,107 @@ export function CanvasWorkspace({
|
|||||||
</div>
|
</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 ? (
|
{showZoomControls ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export type CanvasOperationStreamPayload = {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
operationId?: string;
|
operationId?: string;
|
||||||
operation?: string;
|
operation?: string;
|
||||||
|
status?: "applied" | "failed" | "shadowed";
|
||||||
createdNodeIds?: string[];
|
createdNodeIds?: string[];
|
||||||
createdNodeIdsByMediaItemId?: Record<string, string>;
|
createdNodeIdsByMediaItemId?: Record<string, string>;
|
||||||
updatedNodeIds?: string[];
|
updatedNodeIds?: string[];
|
||||||
@ -26,11 +27,43 @@ export type CanvasOperationStreamPayload = {
|
|||||||
canvasRevision?: string;
|
canvasRevision?: string;
|
||||||
anchorNodeId?: string;
|
anchorNodeId?: string;
|
||||||
handledCanvasMediaItemIds?: string[];
|
handledCanvasMediaItemIds?: string[];
|
||||||
|
mediaItemIds?: string[];
|
||||||
|
errorCode?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
fallbackToCanvasMedia?: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CanvasViewOperationStreamPayload = CanvasViewOperationPlan;
|
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 = {
|
export type ImageGenerationStatusStreamPayload = {
|
||||||
phase?: "queued" | "processing" | "finalizing" | "failed" | "completed";
|
phase?: "queued" | "processing" | "finalizing" | "failed" | "completed";
|
||||||
providerType?: string;
|
providerType?: string;
|
||||||
|
|||||||
@ -1,14 +1,72 @@
|
|||||||
"use client";
|
"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 type { AgentSlotNodeData, CanvasNode } from "@/types/canvas";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface AgentSlotNodeProps {
|
interface AgentSlotNodeProps {
|
||||||
node: CanvasNode;
|
node: CanvasNode;
|
||||||
isSelected: boolean;
|
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"]) {
|
function getSlotIcon(slotType: AgentSlotNodeData["slotType"]) {
|
||||||
if (slotType === "video") return Video;
|
if (slotType === "video") return Video;
|
||||||
if (slotType === "ui-screen") return MonitorSmartphone;
|
if (slotType === "ui-screen") return MonitorSmartphone;
|
||||||
@ -28,18 +86,87 @@ function getStatusLabel(status: AgentSlotNodeData["status"]) {
|
|||||||
return "等待生成";
|
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 data = node.data as AgentSlotNodeData;
|
||||||
const slotType = data.slotType || "image";
|
const slotType = data.slotType || "image";
|
||||||
|
const acceptedSources = normalizeAcceptedSources(data.acceptedSources);
|
||||||
const SlotIcon = getSlotIcon(slotType);
|
const SlotIcon = getSlotIcon(slotType);
|
||||||
const filledCount = Array.isArray(data.filledNodeIds)
|
const filledCount = Array.isArray(data.filledNodeIds)
|
||||||
? data.filledNodeIds.length
|
? data.filledNodeIds.length
|
||||||
: 0;
|
: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
isSelected
|
||||||
? "border-[#5b76fe] ring-4 ring-[#5b76fe]/12"
|
? "border-[#5b76fe] ring-4 ring-[#5b76fe]/12"
|
||||||
: "border-[#b8c3d8] hover:border-[#8ea2d6] dark:border-zinc-700",
|
: "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-agent-slot-node="true"
|
||||||
data-slot-type={slotType}
|
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="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-5 py-4 text-center">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -74,14 +340,11 @@ export function AgentSlotNode({ node, isSelected }: AgentSlotNodeProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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">{aspectRatio}</span>
|
||||||
<span className="truncate">
|
<span className="min-w-0 flex-1 truncate px-2 text-center">
|
||||||
{data.fillPolicy === "append-version"
|
{getAcceptedSourcesLabel(acceptedSources)}
|
||||||
? "追加版本"
|
|
||||||
: data.fillPolicy === "replace-existing"
|
|
||||||
? "允许替换"
|
|
||||||
: "填充空槽"}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="truncate">{getFillPolicyLabel(fillPolicy)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -115,8 +115,16 @@ import {
|
|||||||
ThreeDGenerationDialog,
|
ThreeDGenerationDialog,
|
||||||
} from "@/components/canvas/nodes/image-node/image-node-dialogs";
|
} from "@/components/canvas/nodes/image-node/image-node-dialogs";
|
||||||
import { ImageNodeSelectedTitle } from "@/components/canvas/nodes/image-node/image-node-title";
|
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 { SceneFusionDialog } from "@/components/scene-fusion-dialog";
|
||||||
|
import {
|
||||||
|
appendCanvasVisualAnnotationsMetadata,
|
||||||
|
syncCanvasLocalEditRegionAnnotationsMetadata,
|
||||||
|
syncCanvasNodeNoteAnnotationMetadata,
|
||||||
|
} from "@/lib/canvas/agent-annotation-metadata";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -245,6 +253,25 @@ interface ImageOverlayRectStyle {
|
|||||||
height: number;
|
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 {
|
interface LayerSplitBounds {
|
||||||
x: number;
|
x: number;
|
||||||
y: 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 {
|
interface ImageNodeProps {
|
||||||
node: CanvasNode;
|
node: CanvasNode;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
@ -1184,6 +1325,14 @@ export function ImageNode({
|
|||||||
"/api/user/membership-summary",
|
"/api/user/membership-summary",
|
||||||
fetcher
|
fetcher
|
||||||
);
|
);
|
||||||
|
const canvasAgentAnnotations = useMemo(
|
||||||
|
() => normalizeCanvasNodeAnnotations(data.metadata),
|
||||||
|
[data.metadata]
|
||||||
|
);
|
||||||
|
const visibleCanvasAgentAnnotations = useMemo(
|
||||||
|
() => canvasAgentAnnotations.slice(-3),
|
||||||
|
[canvasAgentAnnotations]
|
||||||
|
);
|
||||||
const imageNodeMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNodeMore");
|
const imageNodeMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNodeMore");
|
||||||
const imageAddToChatItem = getCanvasToolbarItem(canvasToolbarConfig, "imageAddToChat");
|
const imageAddToChatItem = getCanvasToolbarItem(canvasToolbarConfig, "imageAddToChat");
|
||||||
const imagePickObjectItem = getCanvasToolbarItem(canvasToolbarConfig, "imagePickObject");
|
const imagePickObjectItem = getCanvasToolbarItem(canvasToolbarConfig, "imagePickObject");
|
||||||
@ -2579,10 +2728,20 @@ export function ImageNode({
|
|||||||
delete nextMetadata[LOCAL_EDIT_METADATA_KEY];
|
delete nextMetadata[LOCAL_EDIT_METADATA_KEY];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const syncedAnnotationMetadata =
|
||||||
|
syncCanvasLocalEditRegionAnnotationsMetadata({
|
||||||
|
metadata: nextMetadata,
|
||||||
|
nodeId: node.id,
|
||||||
|
regions: nextRegions,
|
||||||
|
});
|
||||||
|
|
||||||
updateNode(node.id, {
|
updateNode(node.id, {
|
||||||
data: {
|
data: {
|
||||||
...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 handleSaveNote = useCallback(() => {
|
||||||
const nextNote = draftNote.trim();
|
const nextNote = draftNote.trim();
|
||||||
|
const noteAnnotationMetadata = syncCanvasNodeNoteAnnotationMetadata({
|
||||||
|
metadata: data.metadata,
|
||||||
|
nodeId: node.id,
|
||||||
|
note: nextNote,
|
||||||
|
});
|
||||||
updateNode(node.id, {
|
updateNode(node.id, {
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
note: nextNote || undefined,
|
note: nextNote || undefined,
|
||||||
|
metadata:
|
||||||
|
Object.keys(noteAnnotationMetadata.metadata).length > 0
|
||||||
|
? noteAnnotationMetadata.metadata
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setDraftNote(nextNote);
|
setDraftNote(nextNote);
|
||||||
@ -6700,7 +6868,10 @@ export function ImageNode({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 图片标注完成回调
|
// 图片标注完成回调
|
||||||
const handleAnnotateComplete = async (annotatedImageUrl: string) => {
|
const handleAnnotateComplete = async (
|
||||||
|
annotatedImageUrl: string,
|
||||||
|
payload?: ImageAnnotatorConfirmPayload
|
||||||
|
) => {
|
||||||
setAnnotatorOpen(false);
|
setAnnotatorOpen(false);
|
||||||
|
|
||||||
// 如果仍然是 base64 格式,先上传到服务器(ImageAnnotator 应该已经处理了,这里作为保护)
|
// 如果仍然是 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 重点关注/修改的区域
|
// 这些标记指示了用户希望 AI 重点关注/修改的区域
|
||||||
@ -6738,6 +6917,10 @@ export function ImageNode({
|
|||||||
hasAnnotations: true, // 🆕 标记为已标注
|
hasAnnotations: true, // 🆕 标记为已标注
|
||||||
originalSrc: data.originalSrc || data.src, // 保留原始图片
|
originalSrc: data.originalSrc || data.src, // 保留原始图片
|
||||||
prompt: data.prompt ? `${data.prompt} (已标注)` : "已标注图片",
|
prompt: data.prompt ? `${data.prompt} (已标注)` : "已标注图片",
|
||||||
|
metadata:
|
||||||
|
Object.keys(visualAnnotationMetadata.metadata).length > 0
|
||||||
|
? visualAnnotationMetadata.metadata
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -8161,6 +8344,64 @@ export function ImageNode({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{typeof document !== "undefined" && localEditPromptPortalFrame
|
{(localEditOperationMode as CanvasLocalEditOperationMode) !== "scale_adjust" &&
|
||||||
|
typeof document !== "undefined" &&
|
||||||
|
localEditPromptPortalFrame
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none fixed z-[9998] overflow-visible"
|
className="pointer-events-none fixed z-[9998] overflow-visible"
|
||||||
@ -8523,7 +8766,7 @@ export function ImageNode({
|
|||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{isEditing && localEditOperationMode !== "scale_adjust" ? (
|
{isEditing ? (
|
||||||
<input
|
<input
|
||||||
autoFocus={editingLocalEditRegionId === region.id}
|
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"
|
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.preventDefault();
|
||||||
event.nativeEvent.stopImmediatePropagation?.();
|
event.nativeEvent.stopImmediatePropagation?.();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (localEditOperationMode !== "scale_adjust") {
|
setEditingLocalEditRegionId(region.id);
|
||||||
setEditingLocalEditRegionId(region.id);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -8655,36 +8896,12 @@ export function ImageNode({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.nativeEvent.stopImmediatePropagation?.();
|
event.nativeEvent.stopImmediatePropagation?.();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (localEditOperationMode !== "scale_adjust") {
|
setEditingLocalEditRegionId(region.id);
|
||||||
setEditingLocalEditRegionId(region.id);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
title={
|
title={region.prompt}
|
||||||
localEditOperationMode === "scale_adjust"
|
|
||||||
? region.targetRect
|
|
||||||
? getCanvasScaleAdjustLabel(
|
|
||||||
deriveCanvasScalePercentFromRects(
|
|
||||||
region.rect,
|
|
||||||
region.targetRect
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: "请框选青色目标框"
|
|
||||||
: region.prompt
|
|
||||||
}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{localEditOperationMode === "scale_adjust"
|
{`${region.index}. ${region.prompt}`}
|
||||||
? `${region.index}. ${
|
|
||||||
region.targetRect
|
|
||||||
? getCanvasScaleAdjustLabel(
|
|
||||||
deriveCanvasScalePercentFromRects(
|
|
||||||
region.rect,
|
|
||||||
region.targetRect
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: "请框选目标框"
|
|
||||||
}`
|
|
||||||
: `${region.index}. ${region.prompt}`}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
@ -8705,9 +8922,8 @@ export function ImageNode({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className={cn(
|
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",
|
"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"
|
)}
|
||||||
)}
|
|
||||||
data-canvas-no-drag="true"
|
data-canvas-no-drag="true"
|
||||||
data-local-edit-control="true"
|
data-local-edit-control="true"
|
||||||
disabled={
|
disabled={
|
||||||
|
|||||||
@ -67,7 +67,7 @@ type Tool = "pen" | "arrow" | "circle" | "text";
|
|||||||
|
|
||||||
const logger = createClientLogger("image-annotator");
|
const logger = createClientLogger("image-annotator");
|
||||||
|
|
||||||
type DrawingAction = {
|
export type ImageAnnotatorDrawingAction = {
|
||||||
type: Tool;
|
type: Tool;
|
||||||
color: string;
|
color: string;
|
||||||
lineWidth: number;
|
lineWidth: number;
|
||||||
@ -81,9 +81,18 @@ type DrawingAction = {
|
|||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ImageAnnotatorConfirmPayload {
|
||||||
|
actions: ImageAnnotatorDrawingAction[];
|
||||||
|
imageWidth?: number;
|
||||||
|
imageHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ImageAnnotatorProps {
|
interface ImageAnnotatorProps {
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
onConfirm: (annotatedImageUrl: string) => void | Promise<void>;
|
onConfirm: (
|
||||||
|
annotatedImageUrl: string,
|
||||||
|
payload?: ImageAnnotatorConfirmPayload
|
||||||
|
) => void | Promise<void>;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onHomeClick?: () => void;
|
onHomeClick?: () => void;
|
||||||
initialResizeMode?: boolean; // 是否直接进入尺寸调整模式
|
initialResizeMode?: boolean; // 是否直接进入尺寸调整模式
|
||||||
@ -115,10 +124,9 @@ export function ImageAnnotator({
|
|||||||
const [tool] = useState<Tool>("pen");
|
const [tool] = useState<Tool>("pen");
|
||||||
const color = "#FF0000";
|
const color = "#FF0000";
|
||||||
const lineWidth = 4;
|
const lineWidth = 4;
|
||||||
const [actions, setActions] = useState<DrawingAction[]>([]);
|
const [actions, setActions] = useState<ImageAnnotatorDrawingAction[]>([]);
|
||||||
const [currentAction, setCurrentAction] = useState<DrawingAction | null>(
|
const [currentAction, setCurrentAction] =
|
||||||
null
|
useState<ImageAnnotatorDrawingAction | null>(null);
|
||||||
);
|
|
||||||
const [textInput, setTextInput] = useState("");
|
const [textInput, setTextInput] = useState("");
|
||||||
const [textPosition, setTextPosition] = useState<{
|
const [textPosition, setTextPosition] = useState<{
|
||||||
x: number;
|
x: number;
|
||||||
@ -610,7 +618,7 @@ export function ImageAnnotator({
|
|||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
img: HTMLImageElement,
|
img: HTMLImageElement,
|
||||||
currentScale: number,
|
currentScale: number,
|
||||||
drawActions: DrawingAction[]
|
drawActions: ImageAnnotatorDrawingAction[]
|
||||||
) => {
|
) => {
|
||||||
const canvas = context.canvas;
|
const canvas = context.canvas;
|
||||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
@ -636,7 +644,7 @@ export function ImageAnnotator({
|
|||||||
// 绘制单个动作
|
// 绘制单个动作
|
||||||
const drawAction = (
|
const drawAction = (
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
action: DrawingAction,
|
action: ImageAnnotatorDrawingAction,
|
||||||
currentScale: number
|
currentScale: number
|
||||||
) => {
|
) => {
|
||||||
context.strokeStyle = action.color;
|
context.strokeStyle = action.color;
|
||||||
@ -770,7 +778,7 @@ export function ImageAnnotator({
|
|||||||
setIsDrawing(true);
|
setIsDrawing(true);
|
||||||
const pos = getPosition(e);
|
const pos = getPosition(e);
|
||||||
|
|
||||||
const newAction: DrawingAction = {
|
const newAction: ImageAnnotatorDrawingAction = {
|
||||||
type: tool,
|
type: tool,
|
||||||
color,
|
color,
|
||||||
lineWidth,
|
lineWidth,
|
||||||
@ -833,7 +841,7 @@ export function ImageAnnotator({
|
|||||||
const handleAddText = () => {
|
const handleAddText = () => {
|
||||||
if (!textInput.trim() || !textPosition) return;
|
if (!textInput.trim() || !textPosition) return;
|
||||||
|
|
||||||
const textAction: DrawingAction = {
|
const textAction: ImageAnnotatorDrawingAction = {
|
||||||
type: "text",
|
type: "text",
|
||||||
color,
|
color,
|
||||||
lineWidth,
|
lineWidth,
|
||||||
@ -1742,7 +1750,11 @@ export function ImageAnnotator({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await onConfirm(finalUrl);
|
await onConfirm(finalUrl, {
|
||||||
|
actions,
|
||||||
|
imageWidth: image?.width,
|
||||||
|
imageHeight: image?.height,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.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",
|
"zoom_out",
|
||||||
"link_nodes",
|
"link_nodes",
|
||||||
"update_text",
|
"update_text",
|
||||||
|
"add_annotation",
|
||||||
|
"create_agent_slot",
|
||||||
"confirmation_required",
|
"confirmation_required",
|
||||||
]),
|
]),
|
||||||
nodeIds: z.array(z.string().min(1).max(160)).max(20).optional(),
|
nodeIds: z.array(z.string().min(1).max(160)).max(20).optional(),
|
||||||
textContent: z.string().max(4000).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
|
riskOperation: z
|
||||||
.enum([
|
.enum([
|
||||||
"replace_node_media",
|
"replace_node_media",
|
||||||
@ -35,6 +40,14 @@ const plannerDecisionSchema = z.object({
|
|||||||
|
|
||||||
type PlannerDecision = z.infer<typeof plannerDecisionSchema>;
|
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 =
|
export type CanvasAgentModelPlannerOutcome =
|
||||||
| {
|
| {
|
||||||
kind: "view";
|
kind: "view";
|
||||||
@ -152,6 +165,145 @@ function inferMoveLayoutStrategy(userText: string): "row" | "column" | "grid" {
|
|||||||
return "row";
|
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: {
|
function buildViewPlan(params: {
|
||||||
action: PlannerDecision["action"];
|
action: PlannerDecision["action"];
|
||||||
context: CanvasAgentContext;
|
context: CanvasAgentContext;
|
||||||
@ -257,9 +409,11 @@ export function shouldConsiderCanvasAgentModelPlanner(params: {
|
|||||||
/文本|文字|文案|标题|标签|改写|润色|优化|改成|改为|设置为|设为|替换为/.test(
|
/文本|文字|文案|标题|标签|改写|润色|优化|改成|改为|设置为|设为|替换为/.test(
|
||||||
text
|
text
|
||||||
);
|
);
|
||||||
|
const hasAnnotationIntent = /批注|标注|备注|note|annotation/.test(text);
|
||||||
|
const hasSlotIntent = hasCanvasAgentSlotCreationIntent(text);
|
||||||
const hasHighRiskIntent = Boolean(inferHighRiskOperation(text));
|
const hasHighRiskIntent = Boolean(inferHighRiskOperation(text));
|
||||||
const hasMediaGenerationIntent =
|
const hasMediaGenerationIntent =
|
||||||
/生成|出图|做一版|再出|换背景|背景|抠图|去水印|高清|扩图|重绘|视频|变体|图层|多角度|洗图/.test(
|
/生成|出图|做一版|再出|换背景|背景|抠图|去水印|高清|扩图|重绘|变体|图层|多角度|洗图/.test(
|
||||||
text
|
text
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -268,12 +422,21 @@ export function shouldConsiderCanvasAgentModelPlanner(params: {
|
|||||||
!hasViewIntent &&
|
!hasViewIntent &&
|
||||||
!hasLinkIntent &&
|
!hasLinkIntent &&
|
||||||
!hasTextIntent &&
|
!hasTextIntent &&
|
||||||
|
!hasAnnotationIntent &&
|
||||||
|
!hasSlotIntent &&
|
||||||
!hasHighRiskIntent
|
!hasHighRiskIntent
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return hasViewIntent || hasLinkIntent || hasTextIntent || hasHighRiskIntent;
|
return (
|
||||||
|
hasViewIntent ||
|
||||||
|
hasLinkIntent ||
|
||||||
|
hasTextIntent ||
|
||||||
|
hasAnnotationIntent ||
|
||||||
|
hasSlotIntent ||
|
||||||
|
hasHighRiskIntent
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeCanvasAgentModelPlannerDecision(params: {
|
export function normalizeCanvasAgentModelPlannerDecision(params: {
|
||||||
@ -308,6 +471,7 @@ export function normalizeCanvasAgentModelPlannerDecision(params: {
|
|||||||
const canBuildMovePlan =
|
const canBuildMovePlan =
|
||||||
(riskOperation === "move_nodes" || riskOperation === "batch_move") &&
|
(riskOperation === "move_nodes" || riskOperation === "batch_move") &&
|
||||||
targetNodeIds.length > 0;
|
targetNodeIds.length > 0;
|
||||||
|
const canBuildDeletePlan = riskOperation === "delete_nodes" && targetNodeIds.length > 0;
|
||||||
const operationPlan: CanvasOperationPlan | undefined = canBuildMovePlan
|
const operationPlan: CanvasOperationPlan | undefined = canBuildMovePlan
|
||||||
? {
|
? {
|
||||||
version: 1,
|
version: 1,
|
||||||
@ -329,13 +493,35 @@ export function normalizeCanvasAgentModelPlannerDecision(params: {
|
|||||||
riskOperation,
|
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;
|
: undefined;
|
||||||
return {
|
return {
|
||||||
kind: "confirmation_required",
|
kind: "confirmation_required",
|
||||||
riskOperation,
|
riskOperation,
|
||||||
targetNodeIds,
|
targetNodeIds,
|
||||||
operationPlan,
|
operationPlan,
|
||||||
viewPlan: operationPlan
|
viewPlan: operationPlan && operationPlan.operation !== "delete_nodes"
|
||||||
? buildViewPlan({
|
? buildViewPlan({
|
||||||
action: "focus_nodes",
|
action: "focus_nodes",
|
||||||
context: params.context,
|
context: params.context,
|
||||||
@ -354,6 +540,35 @@ export function normalizeCanvasAgentModelPlannerDecision(params: {
|
|||||||
return { kind: "none", reason };
|
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 (
|
if (
|
||||||
decision.action === "focus_nodes" ||
|
decision.action === "focus_nodes" ||
|
||||||
decision.action === "fit_all" ||
|
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") {
|
if (decision.action === "update_text") {
|
||||||
const knownNodes = buildKnownNodeMap(params.context);
|
const knownNodes = buildKnownNodeMap(params.context);
|
||||||
const requestedIds = getDecisionNodeIds({
|
const requestedIds = getDecisionNodeIds({
|
||||||
@ -510,8 +781,9 @@ export async function planCanvasAgentOperationWithModel(params: {
|
|||||||
abortSignal: params.abortSignal || AbortSignal.timeout(18_000),
|
abortSignal: params.abortSignal || AbortSignal.timeout(18_000),
|
||||||
system: [
|
system: [
|
||||||
"你是无限画布 Agent 的受控操作规划器。",
|
"你是无限画布 Agent 的受控操作规划器。",
|
||||||
"你只能规划低风险画布操作:视角聚焦/缩放/看全局、连接节点、更新文本节点。",
|
"你只能规划低风险画布操作:视角聚焦/缩放/看全局、连接节点、更新文本节点、给图片或视频节点添加批注、创建空 Agent 槽位。",
|
||||||
"图片/视频生成、换背景、抠图、高清、扩图、重绘等媒体任务必须返回 action=none,交给其他执行器。",
|
"图片/视频生成、换背景、抠图、高清、扩图、重绘等媒体任务必须返回 action=none,交给其他执行器。",
|
||||||
|
"当用户明确要求新建/添加空图片槽位、视频槽位、UI槽位、空框或占位时,可以返回 action=create_agent_slot;如果同时要求生成或填充媒体,返回 action=none。",
|
||||||
"删除、覆盖、替换现有图片、移动/重排节点等高风险动作必须返回 action=confirmation_required。",
|
"删除、覆盖、替换现有图片、移动/重排节点等高风险动作必须返回 action=confirmation_required。",
|
||||||
"只能使用上下文中出现的 nodeId。不要编造节点 ID。",
|
"只能使用上下文中出现的 nodeId。不要编造节点 ID。",
|
||||||
"如果用户只是聊天或目标不明确,返回 action=none。",
|
"如果用户只是聊天或目标不明确,返回 action=none。",
|
||||||
@ -522,9 +794,12 @@ export async function planCanvasAgentOperationWithModel(params: {
|
|||||||
context: buildContextSummary(params.context),
|
context: buildContextSummary(params.context),
|
||||||
outputContract: {
|
outputContract: {
|
||||||
action:
|
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",
|
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:
|
riskOperation:
|
||||||
"confirmation_required 时填写 replace_node_media/delete_nodes/move_nodes/overwrite_slot/batch_move/other",
|
"confirmation_required 时填写 replace_node_media/delete_nodes/move_nodes/overwrite_slot/batch_move/other",
|
||||||
reply: "一句给用户看的中文短回复",
|
reply: "一句给用户看的中文短回复",
|
||||||
|
|||||||
@ -1,16 +1,21 @@
|
|||||||
import "server-only";
|
|
||||||
|
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { canvasProject } from "@/lib/db/schema";
|
import { canvasProject } from "@/lib/db/schema";
|
||||||
import { syncCanvasNodesForProject } from "@/lib/db/queries";
|
|
||||||
import { resolveCanvasAgentMediaPlacements } from "@/lib/canvas/agent-placement";
|
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 {
|
import type {
|
||||||
CanvasMediaItem,
|
CanvasMediaItem,
|
||||||
CanvasOperationPlan,
|
CanvasOperationPlan,
|
||||||
} from "@/types/canvas/agent-protocol";
|
} from "@/types/canvas/agent-protocol";
|
||||||
|
|
||||||
type CanvasDataLike = {
|
export type CanvasDataLike = {
|
||||||
nodes?: any[];
|
nodes?: any[];
|
||||||
edges?: any[];
|
edges?: any[];
|
||||||
selectedNodeIds?: string[];
|
selectedNodeIds?: string[];
|
||||||
@ -31,11 +36,13 @@ export type CanvasOperationResult = {
|
|||||||
|
|
||||||
export class CanvasAgentMutationError extends Error {
|
export class CanvasAgentMutationError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
|
code?: string;
|
||||||
|
|
||||||
constructor(message: string, status = 400) {
|
constructor(message: string, status = 400, code?: string) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "CanvasAgentMutationError";
|
this.name = "CanvasAgentMutationError";
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,10 +205,105 @@ function normalizeSlotFillPolicy(value: unknown) {
|
|||||||
: "replace-empty";
|
: "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" {
|
function normalizeEdgeType(value: unknown): "default" | "reference" | "conversation" {
|
||||||
return value === "default" || value === "conversation" ? value : "reference";
|
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) {
|
function edgeMatches(edge: any, source: string, target: string) {
|
||||||
return (
|
return (
|
||||||
(edge?.source === source && edge?.target === target) ||
|
(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) {
|
function getNodeSize(node: any) {
|
||||||
return {
|
return {
|
||||||
width: Math.max(1, Number(node?.size?.width || 220) || 220),
|
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;
|
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" {
|
function normalizeLayoutStrategy(value: unknown): "row" | "column" | "grid" {
|
||||||
return value === "column" || value === "grid" ? value : "row";
|
return value === "column" || value === "grid" ? value : "row";
|
||||||
}
|
}
|
||||||
@ -271,7 +399,11 @@ function applyMoveNodesOperation(params: {
|
|||||||
plan: CanvasOperationPlan;
|
plan: CanvasOperationPlan;
|
||||||
}) {
|
}) {
|
||||||
if (!isConfirmedHighRiskPlan(params.plan)) {
|
if (!isConfirmedHighRiskPlan(params.plan)) {
|
||||||
throw new CanvasAgentMutationError("移动画布节点需要用户确认", 409);
|
throw new CanvasAgentMutationError(
|
||||||
|
"移动画布节点需要用户确认",
|
||||||
|
409,
|
||||||
|
"CANVAS_AGENT_CONFIRMATION_REQUIRED"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetNodeIds = getOperationTargetNodeIds(params.plan);
|
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: {
|
function applyOperationToCanvasData(params: {
|
||||||
canvasData: CanvasDataLike;
|
canvasData: CanvasDataLike;
|
||||||
plan: CanvasOperationPlan;
|
plan: CanvasOperationPlan;
|
||||||
@ -361,11 +876,15 @@ function applyOperationToCanvasData(params: {
|
|||||||
throw new CanvasAgentMutationError("缺少 operationId");
|
throw new CanvasAgentMutationError("缺少 operationId");
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
params.plan.operation !== "create_agent_slot" &&
|
||||||
params.plan.operation !== "insert_beside" &&
|
params.plan.operation !== "insert_beside" &&
|
||||||
params.plan.operation !== "append_revision" &&
|
params.plan.operation !== "append_revision" &&
|
||||||
params.plan.operation !== "fill_slot" &&
|
params.plan.operation !== "fill_slot" &&
|
||||||
params.plan.operation !== "link_nodes" &&
|
params.plan.operation !== "link_nodes" &&
|
||||||
|
params.plan.operation !== "add_annotation" &&
|
||||||
params.plan.operation !== "move_nodes" &&
|
params.plan.operation !== "move_nodes" &&
|
||||||
|
params.plan.operation !== "replace_node_media" &&
|
||||||
|
params.plan.operation !== "delete_nodes" &&
|
||||||
params.plan.operation !== "update_text"
|
params.plan.operation !== "update_text"
|
||||||
) {
|
) {
|
||||||
throw new CanvasAgentMutationError("当前仅支持非破坏式画布写回操作");
|
throw new CanvasAgentMutationError("当前仅支持非破坏式画布写回操作");
|
||||||
@ -373,6 +892,80 @@ function applyOperationToCanvasData(params: {
|
|||||||
|
|
||||||
const nodes = [...(params.canvasData.nodes || [])];
|
const nodes = [...(params.canvasData.nodes || [])];
|
||||||
const edges = [...(params.canvasData.edges || [])];
|
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") {
|
if (params.plan.operation === "move_nodes") {
|
||||||
return applyMoveNodesOperation({
|
return applyMoveNodesOperation({
|
||||||
canvasData: params.canvasData,
|
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") {
|
if (params.plan.operation === "update_text") {
|
||||||
const textContent = String(params.plan.textContent || "").trim();
|
const textContent = String(params.plan.textContent || "").trim();
|
||||||
if (!textContent) {
|
if (!textContent) {
|
||||||
@ -477,10 +1089,6 @@ function applyOperationToCanvasData(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingOperationNodes = findExistingOperationNodes(
|
|
||||||
nodes,
|
|
||||||
params.plan.operationId
|
|
||||||
);
|
|
||||||
if (existingOperationNodes.length > 0) {
|
if (existingOperationNodes.length > 0) {
|
||||||
const byMediaItemId: Record<string, string> = {};
|
const byMediaItemId: Record<string, string> = {};
|
||||||
existingOperationNodes.forEach((node) => {
|
existingOperationNodes.forEach((node) => {
|
||||||
@ -520,16 +1128,22 @@ function applyOperationToCanvasData(params: {
|
|||||||
if (fillPolicy === "replace-existing" && filledNodeIds.length > 0) {
|
if (fillPolicy === "replace-existing" && filledNodeIds.length > 0) {
|
||||||
throw new CanvasAgentMutationError(
|
throw new CanvasAgentMutationError(
|
||||||
"当前 slot 已有结果,替换已有结果需要用户确认",
|
"当前 slot 已有结果,替换已有结果需要用户确认",
|
||||||
409
|
409,
|
||||||
|
"CANVAS_AGENT_CONFIRMATION_REQUIRED"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (fillPolicy === "replace-empty" && filledNodeIds.length > 0) {
|
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") {
|
if (params.baseCanvasWasStale && fillPolicy === "replace-existing") {
|
||||||
throw new CanvasAgentMutationError(
|
throw new CanvasAgentMutationError(
|
||||||
"画布已更新,替换 slot 前需要刷新并确认",
|
"画布已更新,替换 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: {
|
export async function applyCanvasOperationPlan(params: {
|
||||||
userId: string;
|
userId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -632,10 +1254,34 @@ export async function applyCanvasOperationPlan(params: {
|
|||||||
},
|
},
|
||||||
baseCanvasWasStale,
|
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({
|
const syncedCanvasData = await syncCanvasNodesForProject({
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
canvasData: applied.canvasData,
|
canvasData: auditedCanvasData,
|
||||||
});
|
});
|
||||||
const updatedAt = new Date();
|
const updatedAt = new Date();
|
||||||
const [updatedProject] = await db
|
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;
|
lastAddedNodeId: string | null;
|
||||||
|
agentOperationRefreshUntil: number;
|
||||||
|
|
||||||
// 项目操作
|
// 项目操作
|
||||||
setProject: (id: string | null, title?: string) => void;
|
setProject: (id: string | null, title?: string) => void;
|
||||||
@ -197,6 +198,11 @@ interface CanvasStore {
|
|||||||
|
|
||||||
// 🆕 自动聚焦控制
|
// 🆕 自动聚焦控制
|
||||||
clearLastAddedNodeId: () => void;
|
clearLastAddedNodeId: () => void;
|
||||||
|
|
||||||
|
// Agent mutation revalidate guard
|
||||||
|
markAgentOperationRefresh: (durationMs?: number) => void;
|
||||||
|
clearAgentOperationRefresh: () => void;
|
||||||
|
isAgentOperationRefreshActive: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCanvasStore = create<CanvasStore>()(
|
export const useCanvasStore = create<CanvasStore>()(
|
||||||
@ -231,6 +237,7 @@ export const useCanvasStore = create<CanvasStore>()(
|
|||||||
pendingPickedObjects: [],
|
pendingPickedObjects: [],
|
||||||
imageNodeActionRequest: null,
|
imageNodeActionRequest: null,
|
||||||
lastAddedNodeId: null,
|
lastAddedNodeId: null,
|
||||||
|
agentOperationRefreshUntil: 0,
|
||||||
|
|
||||||
// 项目操作
|
// 项目操作
|
||||||
setProject: (id, title) => set((state) => {
|
setProject: (id, title) => set((state) => {
|
||||||
@ -247,6 +254,22 @@ export const useCanvasStore = create<CanvasStore>()(
|
|||||||
state.canvasUpdatedAt = String(updatedAt || revision || "").trim() || null;
|
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) => {
|
addNode: (nodeData) => {
|
||||||
const imageMetadata =
|
const imageMetadata =
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -9,6 +9,17 @@ export type CanvasAgentFocusPolicy =
|
|||||||
| "original"
|
| "original"
|
||||||
| "slot";
|
| "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 {
|
export interface CanvasViewportContext {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@ -44,7 +55,7 @@ export interface CanvasAgentNodeRef {
|
|||||||
|
|
||||||
export interface CanvasAgentSlotRef {
|
export interface CanvasAgentSlotRef {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
slotType: "image" | "video" | "ui-screen";
|
slotType: CanvasAgentSlotType;
|
||||||
label?: string;
|
label?: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@ -52,8 +63,16 @@ export interface CanvasAgentSlotRef {
|
|||||||
height: number;
|
height: number;
|
||||||
aspectRatio: string;
|
aspectRatio: string;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
fillPolicy: "replace-empty" | "append-version" | "replace-existing";
|
fillPolicy: CanvasAgentSlotFillPolicy;
|
||||||
acceptedSources?: Array<"text" | "image" | "selection" | "annotation">;
|
acceptedSources?: CanvasAgentSlotAcceptedSource[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanvasAgentSlotSpec {
|
||||||
|
slotType: CanvasAgentSlotType;
|
||||||
|
label?: string;
|
||||||
|
aspectRatio?: string;
|
||||||
|
fillPolicy?: CanvasAgentSlotFillPolicy;
|
||||||
|
acceptedSources?: CanvasAgentSlotAcceptedSource[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CanvasAgentAnnotationRef {
|
export interface CanvasAgentAnnotationRef {
|
||||||
@ -91,6 +110,7 @@ export interface CanvasAgentContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CanvasOperationKind =
|
export type CanvasOperationKind =
|
||||||
|
| "create_agent_slot"
|
||||||
| "fill_slot"
|
| "fill_slot"
|
||||||
| "insert_beside"
|
| "insert_beside"
|
||||||
| "append_revision"
|
| "append_revision"
|
||||||
@ -98,6 +118,7 @@ export type CanvasOperationKind =
|
|||||||
| "add_annotation"
|
| "add_annotation"
|
||||||
| "link_nodes"
|
| "link_nodes"
|
||||||
| "move_nodes"
|
| "move_nodes"
|
||||||
|
| "delete_nodes"
|
||||||
| "update_text";
|
| "update_text";
|
||||||
|
|
||||||
export interface CanvasMediaItem {
|
export interface CanvasMediaItem {
|
||||||
@ -133,6 +154,12 @@ export interface CanvasOperationPlan {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
|
slot?: CanvasAgentSlotSpec;
|
||||||
|
annotations?: Array<
|
||||||
|
Partial<CanvasAgentAnnotationRef> & {
|
||||||
|
type: CanvasAgentAnnotationRef["type"];
|
||||||
|
}
|
||||||
|
>;
|
||||||
placement?: "right" | "left" | "below" | "above" | "viewport-center";
|
placement?: "right" | "left" | "below" | "above" | "viewport-center";
|
||||||
placementStrategy?: "single" | "row" | "grid" | "overlay" | "beside_each_source";
|
placementStrategy?: "single" | "row" | "grid" | "overlay" | "beside_each_source";
|
||||||
preserveOriginal?: boolean;
|
preserveOriginal?: boolean;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user