492 lines
16 KiB
TypeScript
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,
|
|
};
|
|
}
|