ai-chatbot/lib/ai/context-memory/target-resolution.ts
Your Name 5f849bf0b7
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: sync canvas resize flow and latest local updates
2026-03-06 17:00:35 +08:00

379 lines
12 KiB
TypeScript

import "server-only";
import type {
ResolveTargetAssetsParams,
ResolvedImageRef,
ResolvedTargetAssets,
} from "@/lib/ai/context-memory/types";
const ORIGINAL_IMAGE_REGEX =
/(原图|上传图|参考图|最初那张|第一张图|素材图|源图|原始图片|原始那张)/i;
const LATEST_RESULT_REGEX =
/(上一张|上一轮|刚生成|最新生成|这个结果|该结果|按这个结果|继续在这张|基于这张结果|延续上一次结果|上一版|刚才那张结果)/i;
const SELECTION_REGEX = /(选中的|当前这张|我选的|这几张|这些图|选中图)/i;
const ACTIONABLE_EDIT_REGEX =
/(改|调整|优化|换|替换|去掉|抠|重绘|增强|修|处理|按照|按|保留|统一|色差|背景|清晰|风格|亮一点|暗一点|白底|透明底)/i;
function normalizeStringArray(values?: string[] | null): string[] {
if (!Array.isArray(values)) return [];
return Array.from(
new Set(
values
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean),
),
);
}
function chineseNumberToInt(value: string): number | null {
const normalized = value.trim();
if (!normalized) return null;
if (/^\d+$/.test(normalized)) return Number.parseInt(normalized, 10);
const map: Record<string, number> = {
一: 1,
二: 2,
两: 2,
三: 3,
四: 4,
五: 5,
六: 6,
七: 7,
八: 8,
九: 9,
十: 10,
};
if (normalized === "十") return 10;
if (normalized.length === 1) return map[normalized] ?? null;
if (normalized.startsWith("十")) {
return 10 + (map[normalized.slice(1)] ?? 0);
}
if (normalized.endsWith("十")) {
return (map[normalized.slice(0, 1)] ?? 0) * 10;
}
if (normalized.includes("十")) {
const [head, tail] = normalized.split("十");
return (map[head] ?? 0) * 10 + (map[tail] ?? 0);
}
return null;
}
function sortRefsForCanvasOrdering(refs: ResolvedImageRef[]) {
return [...refs].sort((left, right) => {
const leftY = Number(left.y ?? 0);
const rightY = Number(right.y ?? 0);
if (Math.abs(leftY - rightY) > 12) {
return leftY - rightY;
}
return Number(left.x ?? 0) - Number(right.x ?? 0);
});
}
function getPositionLabel(ref: ResolvedImageRef, refs: ResolvedImageRef[]): string {
if (refs.length <= 1) return "中间";
const xs = refs.map((item) => Number(item.x ?? 0));
const ys = refs.map((item) => Number(item.y ?? 0));
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const spanX = Math.max(1, maxX - minX);
const spanY = Math.max(1, maxY - minY);
const relX = (Number(ref.x ?? 0) - minX) / spanX;
const relY = (Number(ref.y ?? 0) - minY) / spanY;
const horizontal = relX <= 0.33 ? "左" : relX >= 0.66 ? "右" : "中";
const vertical = relY <= 0.33 ? "上" : relY >= 0.66 ? "下" : "中";
if (horizontal === "中" && vertical === "中") return "中间";
return `${vertical}${horizontal}`;
}
function pickByOrdinal(text: string, refs: ResolvedImageRef[]): ResolvedImageRef[] {
const matched = text.match(/第\s*([一二三四五六七八九十两\d]+)\s*(?:张|个|幅|图)/i);
const numeric = matched ? chineseNumberToInt(matched[1]) : null;
if (!numeric || numeric <= 0) return [];
const ordered = sortRefsForCanvasOrdering(refs);
return ordered[numeric - 1] ? [ordered[numeric - 1]] : [];
}
function pickBySpatialHint(text: string, refs: ResolvedImageRef[]): ResolvedImageRef[] {
if (!refs.length) return [];
const normalizedText = text.replace(/\s+/g, "");
const hasLeft = normalizedText.includes("左");
const hasRight = normalizedText.includes("右");
const hasTop = normalizedText.includes("上");
const hasBottom = normalizedText.includes("下");
const hasCenter = normalizedText.includes("中间") || normalizedText.includes("中");
if (!(hasLeft || hasRight || hasTop || hasBottom || hasCenter)) return [];
const withLabels = refs.map((ref) => ({
ref,
label: getPositionLabel(ref, refs),
}));
return withLabels
.filter(({ label }) => {
if (hasLeft && !label.includes("左")) return false;
if (hasRight && !label.includes("右")) return false;
if (hasTop && !label.includes("上")) return false;
if (hasBottom && !label.includes("下")) return false;
if (hasCenter && !(label.includes("中") || label === "中间")) return false;
return true;
})
.map(({ ref }) => ref);
}
function buildResolvedTargetAssets(params: {
targetRefs: ResolvedImageRef[];
candidateRefs: ResolvedImageRef[];
resolutionMode: ResolvedTargetAssets["resolutionMode"];
confidence: number;
needsClarification: boolean;
reason: string;
fromHistory: boolean;
}): ResolvedTargetAssets {
const targetRefs = params.targetRefs;
const candidateRefs = params.candidateRefs;
return {
targetRefs,
candidateRefs,
targetImageUrls: targetRefs.map((ref) => ref.url),
targetAssetIds: normalizeStringArray(targetRefs.map((ref) => ref.assetId || "")),
candidateImageUrls: candidateRefs.map((ref) => ref.url),
candidateAssetIds: normalizeStringArray(candidateRefs.map((ref) => ref.assetId || "")),
resolutionMode: params.resolutionMode,
confidence: Math.max(0, Math.min(100, Math.round(params.confidence))),
needsClarification: params.needsClarification,
reason: params.reason,
fromHistory: params.fromHistory,
};
}
export function resolveTargetAssets(
params: ResolveTargetAssetsParams,
): ResolvedTargetAssets {
const text = String(params.text || "").trim();
const mediaInput = params.mediaInput;
const canvasRefs = mediaInput.canvasImageRefs;
const selectedRefs = mediaInput.selectedImageRefs;
const currentRefs = mediaInput.currentMessageImageRefs;
const assistantRefs = mediaInput.historyAssistantImageRefs;
const userRefs = mediaInput.historyUserImageRefs;
const candidateRefs = mediaInput.imageRefs;
const workingState = params.workingState;
const focusPolicy = params.contextPayload?.focusPolicy || "auto";
if (focusPolicy === "original" && userRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: userRefs,
candidateRefs,
resolutionMode: "text_reference",
confidence: 96,
needsClarification: false,
reason: "前端上下文指定优先使用原图",
fromHistory: true,
});
}
if (focusPolicy === "latest_result" && assistantRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: assistantRefs,
candidateRefs,
resolutionMode: "latest_result",
confidence: 96,
needsClarification: false,
reason: "前端上下文指定优先使用上一轮结果",
fromHistory: true,
});
}
if (focusPolicy === "selected" && selectedRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: selectedRefs,
candidateRefs,
resolutionMode: "explicit_selection",
confidence: 97,
needsClarification: false,
reason: "前端已明确选中目标图片",
fromHistory: true,
});
}
if (ORIGINAL_IMAGE_REGEX.test(text) && userRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: userRefs,
candidateRefs,
resolutionMode: "text_reference",
confidence: 94,
needsClarification: false,
reason: "用户文本明确指向原图/上传图",
fromHistory: true,
});
}
if (LATEST_RESULT_REGEX.test(text) && assistantRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: assistantRefs,
candidateRefs,
resolutionMode: "latest_result",
confidence: 94,
needsClarification: false,
reason: "用户文本明确指向上一轮结果",
fromHistory: true,
});
}
const ordinalRefs = pickByOrdinal(text, canvasRefs);
if (ordinalRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: ordinalRefs,
candidateRefs,
resolutionMode: "text_reference",
confidence: 88,
needsClarification: false,
reason: "根据用户文本中的序号定位到画布图片",
fromHistory: true,
});
}
const spatialRefs = pickBySpatialHint(text, canvasRefs);
if (spatialRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: spatialRefs,
candidateRefs,
resolutionMode: "text_reference",
confidence: spatialRefs.length === 1 ? 90 : 76,
needsClarification: false,
reason: "根据用户文本中的方位指向定位到画布图片",
fromHistory: true,
});
}
if (currentRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: currentRefs,
candidateRefs,
resolutionMode: "current_upload",
confidence: 93,
needsClarification: false,
reason: "本轮消息包含图片输入,默认优先处理当前上传图片",
fromHistory: false,
});
}
if ((SELECTION_REGEX.test(text) || focusPolicy === "selected") && selectedRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: selectedRefs,
candidateRefs,
resolutionMode: "explicit_selection",
confidence: 92,
needsClarification: false,
reason: "用户文本或上下文明确要求使用当前选中的图片",
fromHistory: true,
});
}
if (selectedRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: selectedRefs,
candidateRefs,
resolutionMode: "explicit_selection",
confidence: 86,
needsClarification: false,
reason: "当前存在画布选中图片,默认优先继续处理选中目标",
fromHistory: true,
});
}
const workingStateResolvedUrls = normalizeStringArray(
workingState?.lastResolvedTargetImageUrls,
);
if (workingStateResolvedUrls.length > 0) {
const refs = workingStateResolvedUrls.map((url) => ({
url,
normalizedUrl: url,
source: "working_memory" as const,
role: "working_memory" as const,
}));
return buildResolvedTargetAssets({
targetRefs: refs,
candidateRefs,
resolutionMode: "working_memory",
confidence: 82,
needsClarification: false,
reason: "使用会话工作记忆中的上一轮已解析目标图",
fromHistory: true,
});
}
const workingStateGeneratedUrls = normalizeStringArray(workingState?.lastGeneratedImageUrls);
if (workingStateGeneratedUrls.length > 0) {
const refs = workingStateGeneratedUrls.map((url) => ({
url,
normalizedUrl: url,
source: "working_memory" as const,
role: "working_memory" as const,
}));
return buildResolvedTargetAssets({
targetRefs: refs,
candidateRefs,
resolutionMode: "working_memory",
confidence: 78,
needsClarification: false,
reason: "使用会话工作记忆中的最近生成结果",
fromHistory: true,
});
}
if (assistantRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: assistantRefs,
candidateRefs,
resolutionMode: "latest_result",
confidence: 74,
needsClarification: false,
reason: "未显式指定时,默认续接最近一轮生成结果",
fromHistory: true,
});
}
if (userRefs.length > 0) {
return buildResolvedTargetAssets({
targetRefs: userRefs,
candidateRefs,
resolutionMode: "working_memory",
confidence: 68,
needsClarification: false,
reason: "回退使用最近一次用户上传图片",
fromHistory: true,
});
}
if (ACTIONABLE_EDIT_REGEX.test(text)) {
return buildResolvedTargetAssets({
targetRefs: [],
candidateRefs,
resolutionMode: "clarification",
confidence: 0,
needsClarification: true,
reason: "用户表达了编辑意图,但当前没有可用图片目标",
fromHistory: false,
});
}
return buildResolvedTargetAssets({
targetRefs: [],
candidateRefs,
resolutionMode: "none",
confidence: 0,
needsClarification: false,
reason: "当前请求没有可定位的图片目标",
fromHistory: false,
});
}