From 6269aefee5d5698ed93e1aaff0735c0a143606ec Mon Sep 17 00:00:00 2001 From: 134 Date: Sat, 5 Sep 2026 18:45:48 +0800 Subject: [PATCH] fix(ai): preserve relational image references --- .../ordinal-image-references.ts | 71 ++++++++++++++++++ lib/ai/context-memory/target-resolution.ts | 75 +------------------ playwright.helpers.config.ts | 1 + tests/routes/ordinal-image-references.test.ts | 22 ++++++ 4 files changed, 96 insertions(+), 73 deletions(-) create mode 100644 lib/ai/context-memory/ordinal-image-references.ts create mode 100644 tests/routes/ordinal-image-references.test.ts diff --git a/lib/ai/context-memory/ordinal-image-references.ts b/lib/ai/context-memory/ordinal-image-references.ts new file mode 100644 index 000000000..e5352929d --- /dev/null +++ b/lib/ai/context-memory/ordinal-image-references.ts @@ -0,0 +1,71 @@ +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 = { + 一: 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; +} + +export function collectOrdinalImageIndexes(text: string): number[] { + const indexes = new Set(); + const normalizedText = String(text || ""); + const chineseOrdinalPattern = + /第\s*([一二三四五六七八九十两\d]+)\s*(?:张|个|幅)?\s*(?:图|图片|照片)?/gi; + const numberedImagePattern = /(?:图|图片|照片|image|photo|reference)\s*([1-9]\d*)\b/gi; + const compactChineseImagePattern = + /(?:图片|照片|图)\s*([一二两三四五六七八九十])(?![一二两三四五六七八九十\d维])/g; + const englishOrdinals: Array<{ index: number; pattern: RegExp }> = [ + { index: 0, pattern: /\b(?:first|1st)\s+(?:image|photo|reference)\b/i }, + { index: 1, pattern: /\b(?:second|2nd)\s+(?:image|photo|reference)\b/i }, + { index: 2, pattern: /\b(?:third|3rd)\s+(?:image|photo|reference)\b/i }, + { index: 3, pattern: /\b(?:fourth|4th)\s+(?:image|photo|reference)\b/i }, + { index: 4, pattern: /\b(?:fifth|5th)\s+(?:image|photo|reference)\b/i }, + { index: 5, pattern: /\b(?:sixth|6th)\s+(?:image|photo|reference)\b/i }, + { index: 6, pattern: /\b(?:seventh|7th)\s+(?:image|photo|reference)\b/i }, + { index: 7, pattern: /\b(?:eighth|8th)\s+(?:image|photo|reference)\b/i }, + ]; + + for (const match of normalizedText.matchAll(chineseOrdinalPattern)) { + const numeric = chineseNumberToInt(match[1] || ""); + if (numeric && numeric > 0) indexes.add(numeric - 1); + } + for (const match of normalizedText.matchAll(numberedImagePattern)) { + const numeric = Number.parseInt(match[1] || "", 10); + if (Number.isFinite(numeric) && numeric > 0) indexes.add(numeric - 1); + } + for (const match of normalizedText.matchAll(compactChineseImagePattern)) { + const numeric = chineseNumberToInt(match[1] || ""); + if (numeric && numeric > 0) indexes.add(numeric - 1); + } + for (const item of englishOrdinals) { + if (item.pattern.test(normalizedText)) indexes.add(item.index); + } + + return Array.from(indexes).sort((left, right) => left - right); +} diff --git a/lib/ai/context-memory/target-resolution.ts b/lib/ai/context-memory/target-resolution.ts index fb253758b..2a05949fc 100644 --- a/lib/ai/context-memory/target-resolution.ts +++ b/lib/ai/context-memory/target-resolution.ts @@ -10,6 +10,7 @@ import { hasCanvasAgentDeicticTarget, resolveCanvasAgentTargetPreference, } from "@/lib/ai/runtime/canvas-agent-kernel"; +import { collectOrdinalImageIndexes } from "@/lib/ai/context-memory/ordinal-image-references"; const ORIGINAL_IMAGE_REGEX = /(原图|上传图|参考图|最初那张|第一张图|素材图|源图|原始图片|原始那张|我发的那张|上传的那张|原来的图)/i; @@ -33,40 +34,6 @@ function normalizeStringArray(values?: string[] | null): string[] { ); } -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 = { - 一: 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); @@ -98,46 +65,8 @@ function getPositionLabel(ref: ResolvedImageRef, refs: ResolvedImageRef[]): stri return `${vertical}${horizontal}`; } -function collectOrdinalIndexes(text: string): number[] { - const indexes = new Set(); - const normalizedText = String(text || ""); - const chineseOrdinalPattern = - /第\s*([一二三四五六七八九十两\d]+)\s*(?:张|个|幅)?\s*(?:图|图片|照片)?/gi; - const numberedImagePattern = /(?:图|图片|照片|image|photo|reference)\s*([1-9]\d*)\b/gi; - const compactChineseImagePattern = - /(?:图片|照片|图)\s*([一二两三四五六七八九十])(?=\s*(?:的|上|中|里|内|和|与|及|、|,|,|。|;|;|$))/g; - const englishOrdinals: Array<{ index: number; pattern: RegExp }> = [ - { index: 0, pattern: /\b(?:first|1st)\s+(?:image|photo|reference)\b/i }, - { index: 1, pattern: /\b(?:second|2nd)\s+(?:image|photo|reference)\b/i }, - { index: 2, pattern: /\b(?:third|3rd)\s+(?:image|photo|reference)\b/i }, - { index: 3, pattern: /\b(?:fourth|4th)\s+(?:image|photo|reference)\b/i }, - { index: 4, pattern: /\b(?:fifth|5th)\s+(?:image|photo|reference)\b/i }, - { index: 5, pattern: /\b(?:sixth|6th)\s+(?:image|photo|reference)\b/i }, - { index: 6, pattern: /\b(?:seventh|7th)\s+(?:image|photo|reference)\b/i }, - { index: 7, pattern: /\b(?:eighth|8th)\s+(?:image|photo|reference)\b/i }, - ]; - - for (const match of normalizedText.matchAll(chineseOrdinalPattern)) { - const numeric = chineseNumberToInt(match[1] || ""); - if (numeric && numeric > 0) indexes.add(numeric - 1); - } - for (const match of normalizedText.matchAll(numberedImagePattern)) { - const numeric = Number.parseInt(match[1] || "", 10); - if (Number.isFinite(numeric) && numeric > 0) indexes.add(numeric - 1); - } - for (const match of normalizedText.matchAll(compactChineseImagePattern)) { - const numeric = chineseNumberToInt(match[1] || ""); - if (numeric && numeric > 0) indexes.add(numeric - 1); - } - for (const item of englishOrdinals) { - if (item.pattern.test(normalizedText)) indexes.add(item.index); - } - - return Array.from(indexes).sort((left, right) => left - right); -} - function pickByOrdinal(text: string, refs: ResolvedImageRef[]): ResolvedImageRef[] { - const ordinalIndexes = collectOrdinalIndexes(text); + const ordinalIndexes = collectOrdinalImageIndexes(text); if (ordinalIndexes.length === 0) return []; const ordered = sortRefsForCanvasOrdering(refs); return ordinalIndexes diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index db07b0e54..bfb112431 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ /routes\/hicad-manufacturing-extension\.test\.ts/, /routes\/openapi-access-key\.test\.ts/, /routes\/vip98k-reference-routing\.test\.ts/, + /routes\/ordinal-image-references\.test\.ts/, ], fullyParallel: true, forbidOnly: !!process.env.CI, diff --git a/tests/routes/ordinal-image-references.test.ts b/tests/routes/ordinal-image-references.test.ts new file mode 100644 index 000000000..695bae332 --- /dev/null +++ b/tests/routes/ordinal-image-references.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "@playwright/test"; +import { collectOrdinalImageIndexes } from "@/lib/ai/context-memory/ordinal-image-references"; + +test.describe("ordinal image references", () => { + test("keeps both sides of a Chinese replacement relationship", () => { + expect( + collectOrdinalImageIndexes("把图二替换到图一的场景中,靠着左边放"), + ).toEqual([0, 1]); + expect(collectOrdinalImageIndexes("用图三替换图一的产品")).toEqual([ + 0, 2, + ]); + }); + + test("still resolves a single compact ordinal", () => { + expect(collectOrdinalImageIndexes("只调整图二")).toEqual([1]); + expect(collectOrdinalImageIndexes("修改第三张图")).toEqual([2]); + }); + + test("does not treat dimensional wording as an image ordinal", () => { + expect(collectOrdinalImageIndexes("保持图二维布局")).toEqual([]); + }); +});