ai-chatbot/lib/canvas/agent-annotation-metadata.ts

492 lines
16 KiB
TypeScript

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,
};
}