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 & { 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; annotations: CanvasAnnotationMetadataRecord[]; hasStructuredAnnotations: boolean; noteAnnotationId: string; } interface AppendCanvasVisualAnnotationsMetadataParams { metadata: unknown; nodeId: string; actions: CanvasVisualAnnotationActionInput[]; imageWidth?: number; imageHeight?: number; now?: string; } interface AppendCanvasVisualAnnotationsMetadataResult { metadata: Record; annotations: CanvasAnnotationMetadataRecord[]; appendedAnnotations: CanvasAnnotationMetadataRecord[]; hasStructuredAnnotations: boolean; } interface SyncCanvasLocalEditRegionAnnotationsMetadataParams { metadata: unknown; nodeId: string; regions: CanvasLocalEditRegionAnnotationInput[]; now?: string; } interface SyncCanvasLocalEditRegionAnnotationsMetadataResult { metadata: Record; annotations: CanvasAnnotationMetadataRecord[]; regionAnnotations: CanvasAnnotationMetadataRecord[]; hasStructuredAnnotations: boolean; } function normalizeCanvasMetadata(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as Record) } : {}; } function normalizeCanvasAnnotationRecord(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? ({ ...(value as Record) } 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; 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, }; }