import type { CanvasAgentAnnotationRef, CanvasAgentContext, CanvasAgentNodeRef, } from "@/types/canvas/agent-protocol"; export interface DirectAgentLocalEditRect { x: number; y: number; width: number; height: number; } export interface DirectAgentLocalEditRegion { id: string; color?: string; prompt: string; rect: DirectAgentLocalEditRect; targetRect?: DirectAgentLocalEditRect; referenceImages?: Array<{ id?: string; url: string; name?: string; source?: "upload" | "canvas"; }>; } export interface DirectAgentLocalEditRegionResolution { sourceNodeId?: string; regions: DirectAgentLocalEditRegion[]; } export class DirectAgentLocalEditServiceError extends Error { status: number; constructor(message: string, status = 400) { super(message); this.name = "DirectAgentLocalEditServiceError"; this.status = status; } } const MAX_DIRECT_AGENT_LOCAL_EDIT_REGIONS = 8; const DEFAULT_REGION_COLORS = [ "#ff3b30", "#1677ff", "#00c853", "#ff9500", "#af52de", "#00bcd4", "#ff2d55", "#8d6e63", ]; function trimText(value: unknown, maxLength: number) { return typeof value === "string" ? value.trim().slice(0, maxLength) : ""; } function normalizeCompactText(value: unknown) { return String(value || "").toLowerCase().replace(/\s+/g, "").trim(); } function toFiniteNumber(value: unknown): number | null { const numberValue = Number(value); return Number.isFinite(numberValue) ? numberValue : null; } function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function roundCoordinate(value: number) { return Math.round(value * 10_000) / 10_000; } function normalizeHttpUrl(value: unknown) { const url = String(value || "").trim(); return /^https?:\/\//i.test(url) ? url : ""; } function normalizeColor(value: unknown, fallback: string) { const raw = String(value || "").trim(); const expanded = raw.replace(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i, "#$1$1$2$2$3$3"); return /^#[0-9a-f]{6}$/i.test(expanded) ? expanded.toLowerCase() : fallback; } export function hasDirectAgentLocalEditIntent(text: string) { const normalizedText = normalizeCompactText(text); if (!normalizedText) return false; const annotationKeyword = "(?:批注|标注|箭头|框选|圈选|annotation|note)"; if ( new RegExp(`(?:添加|新增|创建|加|写|记录).{0,8}${annotationKeyword}`, "i").test( normalizedText ) ) { return false; } return ( /局部编辑|局部重绘|局部修改|局部修图|局部处理|区域编辑|区域重绘|局部换|局部去除|选区/.test( normalizedText ) || /框选区域.{0,24}(?:修改|编辑|修图|改图|处理|调整|去掉|移除|换|生成|出图|执行|重绘)/.test( normalizedText ) || new RegExp( `(?:按|按照|根据|依据|依照|参考|基于).{0,18}${annotationKeyword}.{0,24}(?:修改|编辑|修图|改图|处理|调整|去掉|移除|换|生成|出图|执行|重绘)`, "i" ).test(normalizedText) || new RegExp( `${annotationKeyword}.{0,24}(?:修改|编辑|修图|改图|处理|调整|去掉|移除|换|生成|出图|执行|重绘)`, "i" ).test(normalizedText) ); } export function normalizeDirectAgentLocalEditRect( value: unknown ): DirectAgentLocalEditRect | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const raw = value as Record; const x = toFiniteNumber(raw.x); const y = toFiniteNumber(raw.y); const width = toFiniteNumber(raw.width); const height = toFiniteNumber(raw.height); if (x === null || y === null || width === null || height === null) return null; const left = clamp(Math.min(x, x + width), 0, 1); const top = clamp(Math.min(y, y + height), 0, 1); const right = clamp(Math.max(x, x + width), 0, 1); const bottom = clamp(Math.max(y, y + height), 0, 1); const normalizedWidth = clamp(right - left, 0, 1 - left); const normalizedHeight = clamp(bottom - top, 0, 1 - top); if (normalizedWidth <= 0 || normalizedHeight <= 0) return null; return { x: roundCoordinate(left), y: roundCoordinate(top), width: roundCoordinate(normalizedWidth), height: roundCoordinate(normalizedHeight), }; } function normalizeReferenceImages(value: unknown): DirectAgentLocalEditRegion["referenceImages"] { if (!Array.isArray(value)) return undefined; const references = value .map((item, index) => { if (!item || typeof item !== "object") return null; const raw = item as Record; const url = normalizeHttpUrl(raw.url); if (!url) return null; return { id: trimText(raw.id, 80) || `reference-${index + 1}`, url, ...(trimText(raw.name, 80) ? { name: trimText(raw.name, 80) } : {}), source: raw.source === "canvas" ? ("canvas" as const) : ("upload" as const), }; }) .filter((item): item is NonNullable => Boolean(item)) .slice(0, 3); return references.length > 0 ? references : undefined; } function isGenericRegionPrompt(value: string) { const normalized = normalizeCompactText(value); return ( !normalized || /^局部编辑区域\d*$/.test(normalized) || /^区域\d+$/.test(normalized) || /^localeditregion\d*$/.test(normalized) ); } function normalizePlannedRegion( value: unknown, index: number, fallbackPrompt: string ): DirectAgentLocalEditRegion | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const raw = value as Record; const rect = normalizeDirectAgentLocalEditRect(raw.rect || raw.cropRect || raw.bounds); if (!rect) return null; const prompt = trimText(raw.prompt, 300) || trimText(raw.instruction, 300) || trimText(raw.text, 300) || fallbackPrompt; if (!prompt) return null; const targetRect = normalizeDirectAgentLocalEditRect(raw.targetRect); return { id: trimText(raw.id, 80) || `region-${index + 1}`, color: normalizeColor(raw.color, DEFAULT_REGION_COLORS[index % DEFAULT_REGION_COLORS.length]), prompt, rect, ...(targetRect ? { targetRect } : {}), ...(normalizeReferenceImages(raw.referenceImages) ? { referenceImages: normalizeReferenceImages(raw.referenceImages) } : {}), }; } function getAllContextNodes(context?: CanvasAgentContext | null): CanvasAgentNodeRef[] { if (!context) return []; const nodes = [...(context.selectedNodes || []), ...(context.candidateNodes || [])]; const seen = new Set(); return nodes.filter((node) => { if (!node?.nodeId || seen.has(node.nodeId)) return false; seen.add(node.nodeId); return true; }); } function resolveTargetNodeIds(params: { canvasAgentContext?: CanvasAgentContext | null; sourceImageUrl?: string; targetNodeIds?: string[] | null; }) { const context = params.canvasAgentContext; const sourceUrl = normalizeHttpUrl(params.sourceImageUrl); const targetIds: string[] = []; const addTargetId = (value: unknown) => { const nodeId = trimText(value, 128); if (nodeId && !targetIds.includes(nodeId)) { targetIds.push(nodeId); } }; const nodes = getAllContextNodes(context); if (sourceUrl) { for (const node of nodes) { if (normalizeHttpUrl(node.url) === sourceUrl) { addTargetId(node.nodeId); } } } for (const nodeId of params.targetNodeIds || []) addTargetId(nodeId); for (const nodeId of context?.selectedNodeIds || []) addTargetId(nodeId); for (const node of context?.selectedNodes || []) { if (node.isSelected) addTargetId(node.nodeId); } return targetIds; } export function resolveDirectAgentLocalEditSourceNodeId(params: { canvasAgentContext?: CanvasAgentContext | null; plannedArgs?: Record; sourceImageUrl?: string; targetNodeIds?: string[] | null; }) { const plannedSourceNodeId = trimText(params.plannedArgs?.sourceNodeId, 128); if (plannedSourceNodeId) return plannedSourceNodeId; const sourceUrl = normalizeHttpUrl(params.sourceImageUrl); if (sourceUrl) { const matchedNode = getAllContextNodes(params.canvasAgentContext).find( (node) => normalizeHttpUrl(node.url) === sourceUrl ); if (matchedNode?.nodeId) return matchedNode.nodeId; } const [firstTargetNodeId] = resolveTargetNodeIds(params); return firstTargetNodeId; } function resolvePlannedRegions(params: { plannedArgs?: Record; requestTextContent: string; }) { const plannedArgs = params.plannedArgs || {}; const fallbackPrompt = trimText(plannedArgs.prompt, 300) || trimText(plannedArgs.instruction, 300) || trimText(plannedArgs.text, 300) || trimText(params.requestTextContent, 300); const rawRegions = Array.isArray(plannedArgs.regions) ? plannedArgs.regions : Array.isArray(plannedArgs.localEditRegions) ? plannedArgs.localEditRegions : []; const regions = rawRegions .map((region, index) => normalizePlannedRegion(region, index, fallbackPrompt)) .filter((region): region is DirectAgentLocalEditRegion => Boolean(region)); if (regions.length > 0) return regions.slice(0, MAX_DIRECT_AGENT_LOCAL_EDIT_REGIONS); const singleRegion = normalizePlannedRegion( { id: plannedArgs.regionId || plannedArgs.id, color: plannedArgs.color, prompt: fallbackPrompt, rect: plannedArgs.rect || plannedArgs.cropRect || plannedArgs.bounds, targetRect: plannedArgs.targetRect, referenceImages: plannedArgs.referenceImages, }, 0, fallbackPrompt ); return singleRegion ? [singleRegion] : []; } function normalizeAnnotationRegion(params: { annotation: CanvasAgentAnnotationRef; index: number; requestTextContent: string; plannedPrompt: string; }): DirectAgentLocalEditRegion | null { const rect = normalizeDirectAgentLocalEditRect(params.annotation.bounds); if (!rect) return null; const annotationText = trimText(params.annotation.text, 300); const prompt = annotationText && !isGenericRegionPrompt(annotationText) ? annotationText : params.plannedPrompt || trimText(params.requestTextContent, 300) || annotationText; if (!prompt) return null; return { id: trimText(params.annotation.id, 80) || `annotation-region-${params.index + 1}`, color: normalizeColor( params.annotation.color, DEFAULT_REGION_COLORS[params.index % DEFAULT_REGION_COLORS.length] ), prompt, rect, }; } function resolveAnnotationRegions(params: { canvasAgentContext?: CanvasAgentContext | null; plannedArgs?: Record; requestTextContent: string; sourceImageUrl?: string; targetNodeIds?: string[] | null; }) { const context = params.canvasAgentContext; if (!context?.annotations?.length) return []; const targetNodeIds = resolveTargetNodeIds(params); const targetNodeIdSet = new Set(targetNodeIds); const plannedPrompt = trimText(params.plannedArgs?.prompt, 300) || trimText(params.plannedArgs?.instruction, 300) || trimText(params.plannedArgs?.text, 300); const annotations = context.annotations.filter((annotation) => { if (!annotation?.bounds) return false; if (targetNodeIdSet.size === 0) return true; return targetNodeIdSet.has(annotation.sourceNodeId); }); return annotations .map((annotation, index) => normalizeAnnotationRegion({ annotation, index, plannedPrompt, requestTextContent: params.requestTextContent, }) ) .filter((region): region is DirectAgentLocalEditRegion => Boolean(region)) .slice(0, MAX_DIRECT_AGENT_LOCAL_EDIT_REGIONS); } export function resolveDirectAgentLocalEditRegions(params: { canvasAgentContext?: CanvasAgentContext | null; plannedArgs?: Record; requestTextContent: string; sourceImageUrl?: string; targetNodeIds?: string[] | null; }): DirectAgentLocalEditRegionResolution { const plannedRegions = resolvePlannedRegions({ plannedArgs: params.plannedArgs, requestTextContent: params.requestTextContent, }); const regions = plannedRegions.length > 0 ? plannedRegions : resolveAnnotationRegions({ canvasAgentContext: params.canvasAgentContext, plannedArgs: params.plannedArgs, requestTextContent: params.requestTextContent, sourceImageUrl: params.sourceImageUrl, targetNodeIds: params.targetNodeIds, }); if (regions.length === 0) { throw new DirectAgentLocalEditServiceError( "请先在图片上框选局部编辑区域,或在批注里写明要修改的位置。" ); } return { sourceNodeId: resolveDirectAgentLocalEditSourceNodeId(params), regions, }; }