399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<typeof item> => 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<string, unknown>;
|
|
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<string>();
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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,
|
|
};
|
|
}
|