ai-chatbot/lib/ai/runtime/local-edit-options.ts
2026-06-24 00:17:03 +08:00

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