393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
import type {
|
|
CanvasAgentAnnotationRef,
|
|
CanvasAgentContext,
|
|
CanvasAgentFocusPolicy,
|
|
CanvasAgentNodeRef,
|
|
CanvasAgentNodeType,
|
|
CanvasAgentSlotRef,
|
|
CanvasAgentSource,
|
|
CanvasViewportContext,
|
|
} from "@/types/canvas/agent-protocol";
|
|
|
|
type LegacyCanvasImageNode = {
|
|
nodeId?: unknown;
|
|
assetId?: unknown;
|
|
url?: unknown;
|
|
title?: unknown;
|
|
prompt?: unknown;
|
|
x?: unknown;
|
|
y?: unknown;
|
|
width?: unknown;
|
|
height?: unknown;
|
|
isSelected?: unknown;
|
|
};
|
|
|
|
export type CanvasAgentContextPayloadLike = {
|
|
source?: unknown;
|
|
projectId?: unknown;
|
|
selectedNodeIds?: unknown;
|
|
selectedAssetIds?: unknown;
|
|
focusPolicy?: unknown;
|
|
canvasImageNodes?: unknown;
|
|
canvasAgentContext?: unknown;
|
|
};
|
|
|
|
const CANVAS_AGENT_NODE_TYPES = new Set<string>([
|
|
"message",
|
|
"image",
|
|
"video",
|
|
"text",
|
|
"ui-screen",
|
|
"workflow",
|
|
"scene-fusion",
|
|
"model3d",
|
|
"world3d",
|
|
"agent-slot",
|
|
]);
|
|
|
|
const CANVAS_AGENT_FOCUS_POLICIES = new Set<string>([
|
|
"auto",
|
|
"selected",
|
|
"latest_result",
|
|
"original",
|
|
"slot",
|
|
]);
|
|
|
|
function normalizeString(value: unknown, maxLength = 512) {
|
|
if (typeof value !== "string") return undefined;
|
|
const normalized = value.trim();
|
|
return normalized ? normalized.slice(0, maxLength) : undefined;
|
|
}
|
|
|
|
function normalizeStringArray(value: unknown, maxItems: number) {
|
|
if (!Array.isArray(value)) return [];
|
|
return value
|
|
.map((item) => normalizeString(item, 128))
|
|
.filter((item): item is string => Boolean(item))
|
|
.slice(0, maxItems);
|
|
}
|
|
|
|
function normalizeNumber(value: unknown) {
|
|
const parsed = typeof value === "number" ? value : Number(value);
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
}
|
|
|
|
function normalizeBoolean(value: unknown) {
|
|
return typeof value === "boolean" ? value : undefined;
|
|
}
|
|
|
|
function normalizeSource(value: unknown): CanvasAgentSource {
|
|
return value === "realtime-canvas" ? "realtime-canvas" : "canvas";
|
|
}
|
|
|
|
function normalizeFocusPolicy(value: unknown): CanvasAgentFocusPolicy {
|
|
return CANVAS_AGENT_FOCUS_POLICIES.has(String(value))
|
|
? (value as CanvasAgentFocusPolicy)
|
|
: "auto";
|
|
}
|
|
|
|
function normalizeNodeType(value: unknown): CanvasAgentNodeType | undefined {
|
|
const normalized = normalizeString(value, 64);
|
|
if (!normalized || !CANVAS_AGENT_NODE_TYPES.has(normalized)) return undefined;
|
|
return normalized as CanvasAgentNodeType;
|
|
}
|
|
|
|
function normalizeViewport(value: unknown): CanvasViewportContext | undefined {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
const raw = value as Record<string, unknown>;
|
|
const x = normalizeNumber(raw.x);
|
|
const y = normalizeNumber(raw.y);
|
|
const zoom = normalizeNumber(raw.zoom);
|
|
if (x === undefined || y === undefined || zoom === undefined) return undefined;
|
|
return {
|
|
x,
|
|
y,
|
|
zoom,
|
|
width: normalizeNumber(raw.width),
|
|
height: normalizeNumber(raw.height),
|
|
};
|
|
}
|
|
|
|
function normalizeNodeRef(value: unknown): CanvasAgentNodeRef | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const raw = value as Record<string, unknown>;
|
|
const nodeId = normalizeString(raw.nodeId, 128);
|
|
const type = normalizeNodeType(raw.type);
|
|
const x = normalizeNumber(raw.x);
|
|
const y = normalizeNumber(raw.y);
|
|
if (!nodeId || !type || x === undefined || y === undefined) return null;
|
|
return {
|
|
nodeId,
|
|
type,
|
|
assetId: normalizeString(raw.assetId, 128),
|
|
url: normalizeString(raw.url, 2048),
|
|
title: normalizeString(raw.title, 256),
|
|
prompt: normalizeString(raw.prompt, 2000),
|
|
x,
|
|
y,
|
|
width: normalizeNumber(raw.width),
|
|
height: normalizeNumber(raw.height),
|
|
isSelected: normalizeBoolean(raw.isSelected),
|
|
metadata:
|
|
raw.metadata && typeof raw.metadata === "object" && !Array.isArray(raw.metadata)
|
|
? (raw.metadata as Record<string, unknown>)
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
function normalizeSlotRef(value: unknown): CanvasAgentSlotRef | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const raw = value as Record<string, unknown>;
|
|
const nodeId = normalizeString(raw.nodeId, 128);
|
|
const slotType = normalizeString(raw.slotType, 32);
|
|
const x = normalizeNumber(raw.x);
|
|
const y = normalizeNumber(raw.y);
|
|
const width = normalizeNumber(raw.width);
|
|
const height = normalizeNumber(raw.height);
|
|
if (
|
|
!nodeId ||
|
|
(slotType !== "image" && slotType !== "video" && slotType !== "ui-screen") ||
|
|
x === undefined ||
|
|
y === undefined ||
|
|
width === undefined ||
|
|
height === undefined
|
|
) {
|
|
return null;
|
|
}
|
|
const fillPolicy = normalizeString(raw.fillPolicy, 32);
|
|
return {
|
|
nodeId,
|
|
slotType,
|
|
label: normalizeString(raw.label, 128),
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
aspectRatio: normalizeString(raw.aspectRatio, 32) || "1:1",
|
|
isSelected: normalizeBoolean(raw.isSelected),
|
|
fillPolicy:
|
|
fillPolicy === "append-version" || fillPolicy === "replace-existing"
|
|
? fillPolicy
|
|
: "replace-empty",
|
|
acceptedSources: Array.isArray(raw.acceptedSources)
|
|
? raw.acceptedSources
|
|
.map((item) => normalizeString(item, 32))
|
|
.filter(
|
|
(item): item is "text" | "image" | "selection" | "annotation" =>
|
|
item === "text" ||
|
|
item === "image" ||
|
|
item === "selection" ||
|
|
item === "annotation"
|
|
)
|
|
.slice(0, 8)
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
function normalizeAnnotationRef(value: unknown): CanvasAgentAnnotationRef | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const raw = value as Record<string, unknown>;
|
|
const id = normalizeString(raw.id, 128);
|
|
const sourceNodeId = normalizeString(raw.sourceNodeId, 128);
|
|
const type = normalizeString(raw.type, 32);
|
|
if (
|
|
!id ||
|
|
!sourceNodeId ||
|
|
(type !== "text-note" &&
|
|
type !== "arrow-note" &&
|
|
type !== "box" &&
|
|
type !== "freehand" &&
|
|
type !== "crop-box")
|
|
) {
|
|
return null;
|
|
}
|
|
const rawBounds =
|
|
raw.bounds && typeof raw.bounds === "object" && !Array.isArray(raw.bounds)
|
|
? (raw.bounds as Record<string, unknown>)
|
|
: null;
|
|
const rawTargetPoint =
|
|
raw.targetPoint && typeof raw.targetPoint === "object" && !Array.isArray(raw.targetPoint)
|
|
? (raw.targetPoint as Record<string, unknown>)
|
|
: null;
|
|
return {
|
|
id,
|
|
sourceNodeId,
|
|
type,
|
|
text: normalizeString(raw.text, 1000),
|
|
bounds: rawBounds
|
|
? {
|
|
x: normalizeNumber(rawBounds.x) || 0,
|
|
y: normalizeNumber(rawBounds.y) || 0,
|
|
width: normalizeNumber(rawBounds.width) || 0,
|
|
height: normalizeNumber(rawBounds.height) || 0,
|
|
}
|
|
: undefined,
|
|
targetPoint: rawTargetPoint
|
|
? {
|
|
x: normalizeNumber(rawTargetPoint.x) || 0,
|
|
y: normalizeNumber(rawTargetPoint.y) || 0,
|
|
}
|
|
: undefined,
|
|
color: normalizeString(raw.color, 64),
|
|
createdAt: normalizeString(raw.createdAt, 64),
|
|
};
|
|
}
|
|
|
|
function normalizeNodeRefs(value: unknown, maxItems: number) {
|
|
if (!Array.isArray(value)) return [];
|
|
return value
|
|
.map(normalizeNodeRef)
|
|
.filter((node): node is CanvasAgentNodeRef => Boolean(node))
|
|
.slice(0, maxItems);
|
|
}
|
|
|
|
function normalizeSlotRefs(value: unknown, maxItems: number) {
|
|
if (!Array.isArray(value)) return [];
|
|
return value
|
|
.map(normalizeSlotRef)
|
|
.filter((slot): slot is CanvasAgentSlotRef => Boolean(slot))
|
|
.slice(0, maxItems);
|
|
}
|
|
|
|
function normalizeAnnotationRefs(value: unknown, maxItems: number) {
|
|
if (!Array.isArray(value)) return [];
|
|
return value
|
|
.map(normalizeAnnotationRef)
|
|
.filter((annotation): annotation is CanvasAgentAnnotationRef => Boolean(annotation))
|
|
.slice(0, maxItems);
|
|
}
|
|
|
|
function legacyImageToNodeRef(value: LegacyCanvasImageNode): CanvasAgentNodeRef | null {
|
|
const nodeId = normalizeString(value.nodeId, 128);
|
|
const url = normalizeString(value.url, 2048);
|
|
const x = normalizeNumber(value.x);
|
|
const y = normalizeNumber(value.y);
|
|
if (!nodeId || !url || x === undefined || y === undefined) return null;
|
|
return {
|
|
nodeId,
|
|
type: "image",
|
|
assetId: normalizeString(value.assetId, 128),
|
|
url,
|
|
title: normalizeString(value.title, 256),
|
|
prompt: normalizeString(value.prompt, 2000),
|
|
x,
|
|
y,
|
|
width: normalizeNumber(value.width),
|
|
height: normalizeNumber(value.height),
|
|
isSelected: normalizeBoolean(value.isSelected),
|
|
};
|
|
}
|
|
|
|
function normalizeLegacyImageNodes(value: unknown, maxItems: number) {
|
|
if (!Array.isArray(value)) return [];
|
|
return value
|
|
.map((item) => (item && typeof item === "object" ? legacyImageToNodeRef(item) : null))
|
|
.filter((node): node is CanvasAgentNodeRef => Boolean(node))
|
|
.slice(0, maxItems);
|
|
}
|
|
|
|
function hasAgentContextValue(context: CanvasAgentContext) {
|
|
return (
|
|
context.selectedNodes.length > 0 ||
|
|
context.candidateNodes.length > 0 ||
|
|
context.slots.length > 0 ||
|
|
context.annotations.length > 0 ||
|
|
Boolean(context.viewport)
|
|
);
|
|
}
|
|
|
|
function normalizeExplicitCanvasAgentContext(
|
|
value: unknown,
|
|
fallback: CanvasAgentContextPayloadLike
|
|
): CanvasAgentContext | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const raw = value as Record<string, unknown>;
|
|
const selectedNodeIds = normalizeStringArray(raw.selectedNodeIds, 20);
|
|
const selectedNodes = normalizeNodeRefs(raw.selectedNodes, 20);
|
|
const candidateNodes = normalizeNodeRefs(raw.candidateNodes, 30);
|
|
const slots = normalizeSlotRefs(raw.slots, 20);
|
|
const annotations = normalizeAnnotationRefs(raw.annotations, 20);
|
|
const context: CanvasAgentContext = {
|
|
version: 1,
|
|
source: normalizeSource(raw.source || fallback.source),
|
|
projectId: normalizeString(raw.projectId || fallback.projectId, 128),
|
|
canvasRevision: normalizeString(raw.canvasRevision, 128),
|
|
canvasUpdatedAt: normalizeString(raw.canvasUpdatedAt, 128),
|
|
viewport: normalizeViewport(raw.viewport),
|
|
selectedNodeIds:
|
|
selectedNodeIds.length > 0
|
|
? selectedNodeIds
|
|
: selectedNodes.map((node) => node.nodeId).slice(0, 20),
|
|
selectedNodes,
|
|
candidateNodes,
|
|
slots,
|
|
annotations,
|
|
focusPolicy: normalizeFocusPolicy(raw.focusPolicy || fallback.focusPolicy),
|
|
};
|
|
return hasAgentContextValue(context) ? context : null;
|
|
}
|
|
|
|
export function normalizeCanvasAgentContext(
|
|
payload?: CanvasAgentContextPayloadLike | null
|
|
): CanvasAgentContext | null {
|
|
if (!payload) return null;
|
|
|
|
const explicit = normalizeExplicitCanvasAgentContext(
|
|
payload.canvasAgentContext,
|
|
payload
|
|
);
|
|
if (explicit) return explicit;
|
|
|
|
const candidateNodes = normalizeLegacyImageNodes(payload.canvasImageNodes, 20);
|
|
const selectedNodeIdSet = new Set(normalizeStringArray(payload.selectedNodeIds, 20));
|
|
const selectedAssetIdSet = new Set(normalizeStringArray(payload.selectedAssetIds, 20));
|
|
const selectedNodes = candidateNodes.filter((node) => {
|
|
if (node.isSelected) return true;
|
|
if (selectedNodeIdSet.has(node.nodeId)) return true;
|
|
if (node.assetId && selectedAssetIdSet.has(node.assetId)) return true;
|
|
return false;
|
|
});
|
|
|
|
const context: CanvasAgentContext = {
|
|
version: 1,
|
|
source: normalizeSource(payload.source),
|
|
projectId: normalizeString(payload.projectId, 128),
|
|
selectedNodeIds:
|
|
selectedNodes.length > 0
|
|
? selectedNodes.map((node) => node.nodeId).slice(0, 20)
|
|
: Array.from(selectedNodeIdSet).slice(0, 20),
|
|
selectedNodes,
|
|
candidateNodes,
|
|
slots: [],
|
|
annotations: [],
|
|
focusPolicy: normalizeFocusPolicy(payload.focusPolicy),
|
|
};
|
|
|
|
return hasAgentContextValue(context) ? context : null;
|
|
}
|
|
|
|
export function getSelectedSlots(context?: CanvasAgentContext | null) {
|
|
return (context?.slots || []).filter(
|
|
(slot) => slot.isSelected || context?.selectedNodeIds.includes(slot.nodeId)
|
|
);
|
|
}
|
|
|
|
export function getSelectedMediaNodes(context?: CanvasAgentContext | null) {
|
|
return (context?.selectedNodes || []).filter(
|
|
(node) => node.type === "image" || node.type === "video"
|
|
);
|
|
}
|
|
|
|
export function getBestAnchorNode(context?: CanvasAgentContext | null) {
|
|
return (
|
|
getSelectedMediaNodes(context)[0] ||
|
|
context?.selectedNodes[0] ||
|
|
context?.candidateNodes[0] ||
|
|
null
|
|
);
|
|
}
|
|
|
|
export function getCanvasAgentFocusPolicy(context?: CanvasAgentContext | null) {
|
|
return context?.focusPolicy || "auto";
|
|
}
|