254 lines
7.9 KiB
TypeScript
254 lines
7.9 KiB
TypeScript
import {
|
|
getBestAnchorNode,
|
|
getSelectedMediaNodes,
|
|
getSelectedSlots,
|
|
} from "@/lib/canvas/agent-context";
|
|
import { hasCanvasAgentAnnotationEditIntent } from "@/lib/canvas/agent-annotation-prompt";
|
|
import type {
|
|
CanvasAgentAnnotationRef,
|
|
CanvasAgentContext,
|
|
CanvasAgentNodeRef,
|
|
CanvasAgentSlotRef,
|
|
CanvasMediaItem,
|
|
CanvasOperationKind,
|
|
CanvasOperationPlan,
|
|
} from "@/types/canvas/agent-protocol";
|
|
|
|
export type CanvasAgentDirectWritebackMode = "generate" | "edit" | "video";
|
|
export type CanvasAgentDirectWritebackResultType = "image" | "video";
|
|
|
|
export interface CanvasAgentDirectWritebackTargetRef {
|
|
nodeId?: string | null;
|
|
}
|
|
|
|
export interface BuildCanvasAgentDirectWritebackPlanParams {
|
|
projectId: string;
|
|
chatId: string;
|
|
assistantMessageId: string;
|
|
operationLabel: string;
|
|
resultType: CanvasAgentDirectWritebackResultType;
|
|
resultUrls: string[];
|
|
prompt: string;
|
|
mode: CanvasAgentDirectWritebackMode;
|
|
context: CanvasAgentContext;
|
|
requestTextContent?: string | null;
|
|
targetRefs?: CanvasAgentDirectWritebackTargetRef[];
|
|
sourceImageUrls?: string[];
|
|
assetIdsByUrl?: Record<string, string | undefined>;
|
|
model?: string | null;
|
|
targetSlotId?: string | null;
|
|
}
|
|
|
|
export interface CanvasAgentDirectWritebackPlanResult {
|
|
plan: CanvasOperationPlan;
|
|
mediaItems: CanvasMediaItem[];
|
|
mediaItemIds: string[];
|
|
operationId: string;
|
|
operation: CanvasOperationKind;
|
|
targetSlotId: string;
|
|
shouldFillSlot: boolean;
|
|
selectedMediaNodes: ReturnType<typeof getSelectedMediaNodes>;
|
|
}
|
|
|
|
function buildKnownNodeMap(context: CanvasAgentContext) {
|
|
return new Map<string, CanvasAgentNodeRef>(
|
|
[...context.candidateNodes, ...context.selectedNodes].map((node) => [
|
|
node.nodeId,
|
|
node,
|
|
])
|
|
);
|
|
}
|
|
|
|
function isSlotCompatibleWithResultType(
|
|
slot: CanvasAgentSlotRef,
|
|
resultType: CanvasAgentDirectWritebackResultType
|
|
) {
|
|
if (slot.slotType === "video") return resultType === "video";
|
|
return resultType === "image";
|
|
}
|
|
|
|
function resolveTargetMediaNodes(params: {
|
|
context: CanvasAgentContext;
|
|
targetNodeIds: string[];
|
|
}) {
|
|
const knownNodes = buildKnownNodeMap(params.context);
|
|
return params.targetNodeIds
|
|
.map((nodeId) => knownNodes.get(nodeId))
|
|
.filter(
|
|
(node): node is CanvasAgentNodeRef =>
|
|
node !== undefined && (node.type === "image" || node.type === "video")
|
|
);
|
|
}
|
|
|
|
function formatAnnotationBrief(annotation: CanvasAgentAnnotationRef) {
|
|
const parts = [
|
|
annotation.id,
|
|
annotation.type,
|
|
annotation.text ? `text=${annotation.text}` : "",
|
|
annotation.bounds
|
|
? `bounds=${annotation.bounds.x},${annotation.bounds.y},${annotation.bounds.width},${annotation.bounds.height}`
|
|
: "",
|
|
annotation.targetPoint
|
|
? `target=${annotation.targetPoint.x},${annotation.targetPoint.y}`
|
|
: "",
|
|
].filter(Boolean);
|
|
return parts.join(";");
|
|
}
|
|
|
|
function buildAnnotationTraceMetadata(params: {
|
|
context: CanvasAgentContext;
|
|
sourceNodeIds: string[];
|
|
userText?: string | null;
|
|
}) {
|
|
if (!hasCanvasAgentAnnotationEditIntent(params.userText || "")) return {};
|
|
const sourceNodeIdSet = new Set(params.sourceNodeIds);
|
|
const annotations = params.context.annotations
|
|
.filter((annotation) => sourceNodeIdSet.has(annotation.sourceNodeId))
|
|
.slice(0, 8);
|
|
if (annotations.length === 0) return {};
|
|
|
|
return {
|
|
canvasAgentAnnotationIds: annotations.map((annotation) => annotation.id),
|
|
canvasAgentAnnotationCount: annotations.length,
|
|
canvasAgentAnnotationBrief: annotations
|
|
.map(formatAnnotationBrief)
|
|
.join("\n")
|
|
.slice(0, 2000),
|
|
};
|
|
}
|
|
|
|
export function buildCanvasAgentDirectWritebackPlan(
|
|
params: BuildCanvasAgentDirectWritebackPlanParams
|
|
): CanvasAgentDirectWritebackPlanResult | null {
|
|
const projectId = String(params.projectId || "").trim();
|
|
if (!projectId) return null;
|
|
|
|
const resultUrls = params.resultUrls
|
|
.map((url) => String(url || "").trim())
|
|
.filter((url) => /^https?:\/\//i.test(url));
|
|
if (resultUrls.length === 0) return null;
|
|
|
|
const preferredTargetSlotId = String(params.targetSlotId || "").trim();
|
|
const selectedSlots = getSelectedSlots(params.context);
|
|
const selectedSlot =
|
|
selectedSlots.find((slot) =>
|
|
isSlotCompatibleWithResultType(slot, params.resultType)
|
|
) || null;
|
|
const selectedSlotAnchor = selectedSlots[0] || null;
|
|
const targetSlotId = preferredTargetSlotId || selectedSlot?.nodeId || "";
|
|
const shouldFillSlot = Boolean(targetSlotId);
|
|
const selectedMediaNodes = getSelectedMediaNodes(params.context);
|
|
const targetRefs = params.targetRefs || [];
|
|
const targetNodeIds = Array.from(
|
|
new Set(
|
|
targetRefs
|
|
.map((ref) => String(ref.nodeId || "").trim())
|
|
.filter((value): value is string => Boolean(value))
|
|
)
|
|
);
|
|
const targetMediaNodes = resolveTargetMediaNodes({
|
|
context: params.context,
|
|
targetNodeIds,
|
|
});
|
|
const writebackMediaNodes =
|
|
targetMediaNodes.length > 0 ? targetMediaNodes : selectedMediaNodes;
|
|
const anchorNode =
|
|
selectedSlot ||
|
|
selectedSlotAnchor ||
|
|
targetMediaNodes[0] ||
|
|
selectedMediaNodes[0] ||
|
|
getBestAnchorNode(params.context);
|
|
const selectedMediaNodeIds = writebackMediaNodes
|
|
.map((node) => node.nodeId)
|
|
.filter((value): value is string => Boolean(value));
|
|
const perResultSourceNodeIds =
|
|
!shouldFillSlot && resultUrls.length > 1
|
|
? [targetNodeIds, selectedMediaNodeIds].find(
|
|
(nodeIds) => nodeIds.length === resultUrls.length
|
|
) || []
|
|
: [];
|
|
const sourceNodeIds = Array.from(
|
|
new Set(
|
|
[
|
|
...targetNodeIds,
|
|
...selectedMediaNodeIds,
|
|
anchorNode?.nodeId,
|
|
].filter((value): value is string => Boolean(value))
|
|
)
|
|
);
|
|
const operation: CanvasOperationKind = selectedSlot
|
|
? "fill_slot"
|
|
: shouldFillSlot
|
|
? "fill_slot"
|
|
: params.mode === "edit"
|
|
? "append_revision"
|
|
: "insert_beside";
|
|
const hasWritebackAnchor = Boolean(anchorNode?.nodeId);
|
|
const mediaItems: CanvasMediaItem[] = resultUrls.map((url, index) => {
|
|
const mediaItemId = `${params.assistantMessageId}:${params.operationLabel}:${index + 1}`;
|
|
const itemSourceNodeIds = perResultSourceNodeIds[index]
|
|
? [perResultSourceNodeIds[index]]
|
|
: sourceNodeIds;
|
|
return {
|
|
mediaItemId,
|
|
type: params.resultType,
|
|
url,
|
|
assetId: params.assetIdsByUrl?.[url],
|
|
title:
|
|
params.resultType === "video" ? "Agent 视频结果" : "Agent 图片结果",
|
|
prompt: params.prompt,
|
|
sourceNodeIds: itemSourceNodeIds,
|
|
metadata: {
|
|
messageId: params.assistantMessageId,
|
|
chatId: params.chatId,
|
|
model: params.model || undefined,
|
|
sourceImageUrls: params.sourceImageUrls || [],
|
|
...buildAnnotationTraceMetadata({
|
|
context: params.context,
|
|
sourceNodeIds: itemSourceNodeIds,
|
|
userText: params.requestTextContent || params.prompt,
|
|
}),
|
|
},
|
|
};
|
|
});
|
|
const operationId = `${params.assistantMessageId}:${params.operationLabel}`;
|
|
const plan: CanvasOperationPlan = {
|
|
version: 1,
|
|
operationId,
|
|
operation,
|
|
projectId,
|
|
baseCanvasRevision: params.context.canvasRevision,
|
|
baseCanvasUpdatedAt: params.context.canvasUpdatedAt,
|
|
sourceNodeIds,
|
|
targetSlotId: targetSlotId || undefined,
|
|
anchorNodeId: shouldFillSlot ? undefined : anchorNode?.nodeId,
|
|
placement: hasWritebackAnchor ? "right" : "viewport-center",
|
|
placementStrategy:
|
|
perResultSourceNodeIds.length === mediaItems.length
|
|
? "beside_each_source"
|
|
: mediaItems.length > 1
|
|
? "grid"
|
|
: "single",
|
|
preserveOriginal: true,
|
|
mediaItems,
|
|
metadata: {
|
|
messageId: params.assistantMessageId,
|
|
chatId: params.chatId,
|
|
mode: params.mode,
|
|
prompt: params.prompt || params.requestTextContent || "",
|
|
canvasAgentViewport: params.context.viewport,
|
|
},
|
|
};
|
|
|
|
return {
|
|
plan,
|
|
mediaItems,
|
|
mediaItemIds: mediaItems.map((item) => item.mediaItemId),
|
|
operationId,
|
|
operation,
|
|
targetSlotId,
|
|
shouldFillSlot,
|
|
selectedMediaNodes: writebackMediaNodes,
|
|
};
|
|
}
|