fix: improve detail replacement generation controls

This commit is contained in:
134 2026-06-22 17:56:58 +08:00
parent c528e22680
commit c2ef33753a
13 changed files with 1455 additions and 99 deletions

View File

@ -1286,6 +1286,28 @@ function applyDetailReplacementScenePlanGuards(
next.routeReason = appendUniqueInstruction(next.routeReason, params.visualProfile.reason, 220); next.routeReason = appendUniqueInstruction(next.routeReason, params.visualProfile.reason, 220);
} }
if (
params.hasModelImage &&
!isProductOnly &&
!next.useModelImage &&
(next.peoplePolicy !== "none" ||
next.generationRoute === "people_relation_locked" ||
next.pageKind === "people_scene" ||
next.pageKind === "action_poster")
) {
next = {
...next,
useModelImage: true,
modelImageReason:
next.modelImageReason ||
"本屏已判定为人物/动作详情页,必须使用用户提供的模特图锁定人物身份。",
generationPrompt: appendUniqueInstruction(
next.generationPrompt,
"本屏出现人物时必须使用用户提供的同一模特:脸型五官、发型发色、体型肤色和穿搭风格保持一致;不得换成其他人物。",
),
};
}
return next; return next;
} }
@ -1723,19 +1745,31 @@ export function normalizeAgentModeReferences(
): AgentReplacementModePayload["references"] { ): AgentReplacementModePayload["references"] {
if (!payload?.references?.length) return []; if (!payload?.references?.length) return [];
return payload.references return payload.references
.map((item) => ({ .map((item) => {
id: String(item.id || "").trim(), const rerunReferenceImages = Array.isArray(item.rerunReferenceImages)
label: String(item.label || "").trim() || "参考图", ? item.rerunReferenceImages
imageUrl: String(item.imageUrl || "").trim(), .map((image) => normalizeAgentModeSourceImage(image))
candidateId: String(item.candidateId || "").trim() || null, .filter(
sortOrder: Number.isFinite(Number(item.sortOrder)) (image): image is NonNullable<ReturnType<typeof normalizeAgentModeSourceImage>> =>
? Math.max(0, Math.min(999, Math.round(Number(item.sortOrder)))) Boolean(image)
: 0, )
passthrough: item.passthrough === true, .slice(0, 6)
assignedProductIndex: Number.isFinite(Number(item.assignedProductIndex)) : [];
? Math.max(0, Math.min(9, Math.round(Number(item.assignedProductIndex)))) return {
: null, id: String(item.id || "").trim(),
})) label: String(item.label || "").trim() || "参考图",
imageUrl: String(item.imageUrl || "").trim(),
candidateId: String(item.candidateId || "").trim() || null,
sortOrder: Number.isFinite(Number(item.sortOrder))
? Math.max(0, Math.min(999, Math.round(Number(item.sortOrder))))
: 0,
passthrough: item.passthrough === true,
assignedProductIndex: Number.isFinite(Number(item.assignedProductIndex))
? Math.max(0, Math.min(9, Math.round(Number(item.assignedProductIndex))))
: null,
...(rerunReferenceImages.length > 0 ? { rerunReferenceImages } : {}),
};
})
.filter((item) => item.id && /^https?:\/\//i.test(item.imageUrl)) .filter((item) => item.id && /^https?:\/\//i.test(item.imageUrl))
.sort((left, right) => left.sortOrder - right.sortOrder); .sort((left, right) => left.sortOrder - right.sortOrder);
} }
@ -2210,7 +2244,7 @@ export async function analyzeReplacementExecutionPlan(params: {
isDetailReplacement isDetailReplacement
? "用户上传图可能是产品、材质、板材、原材料或细节参考,不要因为它不是完整商品主体就排除;后续应根据上传图外观与参考图里的材质描述共同判断处理方向。" ? "用户上传图可能是产品、材质、板材、原材料或细节参考,不要因为它不是完整商品主体就排除;后续应根据上传图外观与参考图里的材质描述共同判断处理方向。"
: "", : "",
"这张图片是唯一的详情参考图底板,它决定当前这一屏的版式、文字排版、UI图标、按钮、机位、构图、留白和信息层级。", "这张图片是唯一的详情参考图底板,它决定当前这一屏的版式、文字排版、UI图标、按钮、机位、构图、留白和信息层级;如果画面里有产品主体/展示区,它也决定产品的拍摄角度、朝向、透视、可见面数量、占比和标注指向。",
isProductOnlyDetailReplacement isProductOnlyDetailReplacement
? "这张参考图要求最大程度原样保留。后续生图只允许替换其中的产品主体/产品展示区域,背景、文案、按钮、图标、分割线、品牌标识、留白、装饰和光影氛围都必须保留。" ? "这张参考图要求最大程度原样保留。后续生图只允许替换其中的产品主体/产品展示区域,背景、文案、按钮、图标、分割线、品牌标识、留白、装饰和光影氛围都必须保留。"
: isDetailReplacement : isDetailReplacement
@ -2248,12 +2282,12 @@ export async function analyzeReplacementExecutionPlan(params: {
? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/围坐/聚餐/模特互动/拿手机/用餐/试坐/承重动作时必须 preserve_relation;如果文字明确写了“多人围坐、聚餐、合照、用餐、单侧承重、横向受力测试、拿手机”等动作,generationPrompt 必须把这个动作写成硬约束;如果只提供 1 张模特图但参考图多人,仍要保留参考图人数和关系,额外人物按参考图生成相近人群。" ? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/围坐/聚餐/模特互动/拿手机/用餐/试坐/承重动作时必须 preserve_relation;如果文字明确写了“多人围坐、聚餐、合照、用餐、单侧承重、横向受力测试、拿手机”等动作,generationPrompt 必须把这个动作写成硬约束;如果只提供 1 张模特图但参考图多人,仍要保留参考图人数和关系,额外人物按参考图生成相近人群。"
: "", : "",
isDetailReplacement isDetailReplacement
? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary。" ? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary;cameraSummary 必须写出产品主体的视角特征,例如正侧视/三分之四/俯视/平视、倾斜方向、可见面数量、主体朝向和透视消失方向。"
: "", : "",
isProductOnlyDetailReplacement isProductOnlyDetailReplacement
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。" ? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。"
: isDetailReplacement : isDetailReplacement
? "8. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令。若 promptPackPolicy=forbid,必须明确“不使用前置词包,参考图语义优先”;若是动作海报,必须写清人物数量、围坐/试坐/承重等动作、产品形状和版式必须照参考图,同时写清“前置词包必须完整重建原照片内容区里的背景/空间/软装/灯光/产品场景,不能原图复刻”;若是产品尺寸页,必须写清白底抠图、尺寸线、单位和数字标注,不允许室内场景;若 promptPackPolicy=allow/soft 且 peoplePolicy=preserve_relation,明确“保留人物数量/动作/关系与文字层级,同时使用前置词包重建背景/产品场景”。" ? "8. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令。若 promptPackPolicy=forbid,必须明确“不使用前置词包,参考图语义优先”;若是动作海报,必须写清人物数量、围坐/试坐/承重等动作、产品形状和版式必须照参考图,同时写清“前置词包必须完整重建原照片内容区里的背景/空间/软装/灯光/产品场景,不能原图复刻”;若是产品尺寸页,必须写清白底抠图、尺寸线、单位和数字标注,不允许室内场景;若 promptPackPolicy=allow/soft 且 peoplePolicy=preserve_relation,明确“保留人物数量/动作/关系与文字层级,同时使用前置词包重建背景/产品场景”。只要参考图有产品主体,generationPrompt 必须明确产品拍摄角度、朝向、透视和标注指向以参考图为准。"
: "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,描述这一屏需要的场景、机位、主体位置、周边环境重建方向以及必须保留的内容。", : "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,描述这一屏需要的场景、机位、主体位置、周边环境重建方向以及必须保留的内容。",
isProductOnlyDetailReplacement isProductOnlyDetailReplacement
? "5. sceneSummary、styleSummary 和 generationPrompt 必须强调‘只替换产品,不动其他内容’,不要引导重建场景。" ? "5. sceneSummary、styleSummary 和 generationPrompt 必须强调‘只替换产品,不动其他内容’,不要引导重建场景。"
@ -2421,6 +2455,7 @@ export function buildAgentReplacementPrompt(params: {
hasModelImage: boolean; hasModelImage: boolean;
productImageCount: number; productImageCount: number;
originalProductImageCount: number; originalProductImageCount: number;
rerunReferenceCount?: number;
templateReferenceCount: number; templateReferenceCount: number;
pairedProductIndex?: number; pairedProductIndex?: number;
scenePlan?: ReplacementScenePlan | null; scenePlan?: ReplacementScenePlan | null;
@ -2455,14 +2490,14 @@ export function buildAgentReplacementPrompt(params: {
? `图片${nextImageIndex}` ? `图片${nextImageIndex}`
: `图片${nextImageIndex}-${nextImageIndex + params.productImageCount - 1}`; : `图片${nextImageIndex}-${nextImageIndex + params.productImageCount - 1}`;
imageOrderLines.push( imageOrderLines.push(
`${productRangeLabel}:用户上传的产品/材质/板材/原材料或细节参考图,必须综合理解外观、材质、颜色、纹理和结构,不可只看第一张,也不要因为它不是完整商品主体就忽略。` `${productRangeLabel}:用户上传的产品/材质/板材/原材料或细节参考图,必须综合理解外观、材质、颜色、纹理和结构,不可只看第一张,也不要因为它不是完整商品主体就忽略;如结果中需要生成商品主体,商品拍摄角度、朝向、透视和画面占比必须以最后一张参考图为准,不得直接沿用这些上传图的拍摄角度。`
); );
nextImageIndex += params.productImageCount; nextImageIndex += params.productImageCount;
} }
if (params.hasModelImage) { if (params.hasModelImage) {
imageOrderLines.push( imageOrderLines.push(
`图片${nextImageIndex}:模特图,仅在当前参考图本身包含人物展示时参考;如果参考图是多人,只把这张模特图作为主人物身份/气质参考,其余人物按参考图的人数、关系和风格补齐。` `图片${nextImageIndex}:用户提供的固定模特图,是人物身份锁定图;当前屏需要人物时,必须复用这张图里的同一人物(脸型五官、发型发色、体型肤色、穿搭风格保持一致)。如果参考图是多人,只把这张模特图作为主人物身份参考,其余人物按参考图的人数、关系和风格补齐。`
); );
nextImageIndex += 1; nextImageIndex += 1;
} }
@ -2478,8 +2513,20 @@ export function buildAgentReplacementPrompt(params: {
nextImageIndex += params.templateReferenceCount; nextImageIndex += params.templateReferenceCount;
} }
const rerunReferenceCount = Math.max(0, Math.round(Number(params.rerunReferenceCount || 0)));
if (rerunReferenceCount > 0) {
const rerunRangeLabel =
rerunReferenceCount === 1
? `图片${nextImageIndex}`
: `图片${nextImageIndex}-${nextImageIndex + rerunReferenceCount - 1}`;
imageOrderLines.push(
`${rerunRangeLabel}:本次重新生图的补充参考图,只用于理解用户新增的风格、构图、细节或局部调整方向;不得覆盖最终参考图底板的版式、文字层级和页面结构。`
);
nextImageIndex += rerunReferenceCount;
}
imageOrderLines.push( imageOrderLines.push(
`图片${nextImageIndex}:当前参考图底板(唯一版式基准图),必须保留它的文字排版、信息区、分割线、留白、机位和整体结构。` `图片${nextImageIndex}:当前参考图底板(唯一版式基准图),必须保留它的文字排版、信息区、分割线、留白、机位和整体结构;如果它包含产品主体/展示区,它同时决定产品姿态、朝向、透视、可见面数量、占比和标注指向。`
); );
const marketplaceBrandRemovalLines = const marketplaceBrandRemovalLines =
params.modeType === "detail_replacement" && params.marketplaceProductDetailReference params.modeType === "detail_replacement" && params.marketplaceProductDetailReference
@ -2489,6 +2536,12 @@ export function buildAgentReplacementPrompt(params: {
"如果参考图标题、标签或画面中出现可识别的外部品牌字样,不要保留该品牌名,不要换成相似字,不要生成伪 Logo;需要文字时只保留非品牌卖点语义或改成无品牌通用描述。", "如果参考图标题、标签或画面中出现可识别的外部品牌字样,不要保留该品牌名,不要换成相似字,不要生成伪 Logo;需要文字时只保留非品牌卖点语义或改成无品牌通用描述。",
] ]
: []; : [];
const productAngleLockLines = [
"【产品角度锁定规则】最终商品/主体的拍摄角度以最后一张参考图为准,而不是以前面的产品输入图为准;前面的产品图只提供款式、材质、颜色、结构和细节。",
"必须逐项对齐最后一张参考图中的产品姿态:朝向、正侧视/三分之四/俯视/平视关系、桌面或面板倾斜方向、可见侧面数量、支架/腿部/把手/转轴位置、主体外轮廓、占比、重心和透视消失方向。",
"如果最后一张参考图的产品是侧视或低视角,不允许改成俯视三分之四;如果是俯视三分之四,不允许改成正侧视;不允许镜像、旋转、翻转或把产品重画成另一个角度。",
"若参考图含功能标签、箭头、尺寸线、编号或引导线,它们指向的实际结构部位必须保持一致,不得因产品转角改变而让标签指向错误位置。",
];
const normalizedBasePrompt = String(params.basePrompt || "").trim(); const normalizedBasePrompt = String(params.basePrompt || "").trim();
const promptPackInstructionLines = const promptPackInstructionLines =
params.modeType === "detail_replacement" && params.modeType === "detail_replacement" &&
@ -2555,7 +2608,7 @@ export function buildAgentReplacementPrompt(params: {
params.scenePlan.useModelImage params.scenePlan.useModelImage
? params.scenePlan.modelImageReason ? params.scenePlan.modelImageReason
? `本屏允许使用模特图:${params.scenePlan.modelImageReason}` ? `本屏允许使用模特图:${params.scenePlan.modelImageReason}`
: "本屏允许使用模特图,但仅在参考图当前这一屏确实存在人物展示需求时使用。" : "本屏允许使用模特图;只要最终画面出现人物,就必须使用用户提供的同一模特身份。"
: params.hasModelImage : params.hasModelImage
? params.scenePlan.modelImageReason ? params.scenePlan.modelImageReason
? `本屏禁止使用模特图:${params.scenePlan.modelImageReason}` ? `本屏禁止使用模特图:${params.scenePlan.modelImageReason}`
@ -2588,6 +2641,7 @@ export function buildAgentReplacementPrompt(params: {
...promptPackInstructionLines, ...promptPackInstructionLines,
...scenePlanLines, ...scenePlanLines,
...marketplaceBrandRemovalLines, ...marketplaceBrandRemovalLines,
...productAngleLockLines,
"你现在做的是‘仅替换产品’,不是重建场景,也不是重新设计整张详情图。", "你现在做的是‘仅替换产品’,不是重建场景,也不是重新设计整张详情图。",
"忽略用户补充提示词里的场景风格、氛围、空间改造要求;这一次只服务于产品主体替换。", "忽略用户补充提示词里的场景风格、氛围、空间改造要求;这一次只服务于产品主体替换。",
"最后一张参考图是唯一底板,除产品主体/产品展示区外,其余所有内容都必须保持不变。", "最后一张参考图是唯一底板,除产品主体/产品展示区外,其余所有内容都必须保持不变。",
@ -2616,6 +2670,7 @@ export function buildAgentReplacementPrompt(params: {
...promptPackInstructionLines, ...promptPackInstructionLines,
...scenePlanLines, ...scenePlanLines,
...marketplaceBrandRemovalLines, ...marketplaceBrandRemovalLines,
...productAngleLockLines,
"你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。", "你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。",
"【版式锁层规则】把最后一张参考图当成 PSD 模板:文字、标题、编号、卖点标签、按钮、图标、白底/浅色底、留白、分割线、原有圆角照片区域和原有裁切范围都属于锁定版式层,不允许移动、缩放、重排或重新设计;不要把这些版式边界画成新的白色线框、描边或卡片。", "【版式锁层规则】把最后一张参考图当成 PSD 模板:文字、标题、编号、卖点标签、按钮、图标、白底/浅色底、留白、分割线、原有圆角照片区域和原有裁切范围都属于锁定版式层,不允许移动、缩放、重排或重新设计;不要把这些版式边界画成新的白色线框、描边或卡片。",
isReferencePosterActionPlan isReferencePosterActionPlan
@ -2730,6 +2785,7 @@ export function buildAgentReplacementPrompt(params: {
"【图片顺序规则】", "【图片顺序规则】",
...imageOrderLines, ...imageOrderLines,
...scenePlanLines, ...scenePlanLines,
...productAngleLockLines,
params.pairedProductIndex !== undefined params.pairedProductIndex !== undefined
? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。` ? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。`
: "请使用输入商品图作为本张参考图的一对一替换主体。", : "请使用输入商品图作为本张参考图的一对一替换主体。",
@ -2754,7 +2810,7 @@ export function buildAgentReplacementPrompt(params: {
if (params.hasModelImage) { if (params.hasModelImage) {
instructionLines.push( instructionLines.push(
"如有模特图,请在需要人物展示时保持模特体态、穿搭和出镜关系一致;多人参考图要保留参考图人数和互动关系,只把上传模特作为主人物参考,其余人物按参考图风格补齐。" "固定模特身份硬约束:如果本屏最终画面出现人物,必须使用上传模特图中的同一人物;脸型五官、发型发色、体型肤色和穿搭风格必须保持一致,不得换脸、换人、换发型或改成无关人物。多人参考图要保留参考图人数和互动关系,只把上传模特作为主人物参考,其余人物按参考图风格补齐。"
); );
} }

View File

@ -45,6 +45,7 @@ import {
import { buildAgentSceneImageRolePrompt } from "@/lib/agents/scene-image-role-prompt"; import { buildAgentSceneImageRolePrompt } from "@/lib/agents/scene-image-role-prompt";
import { optimizeAgentSceneWorkflowPrompt } from "@/lib/agents/scene-workflow-prompt-optimizer"; import { optimizeAgentSceneWorkflowPrompt } from "@/lib/agents/scene-workflow-prompt-optimizer";
import { getEnabledTools } from "@/lib/ai/tools-registry"; import { getEnabledTools } from "@/lib/ai/tools-registry";
import { normalizeCanvasAgentContext } from "@/lib/canvas/agent-context";
import { isProductionEnvironment } from "@/lib/constants"; import { isProductionEnvironment } from "@/lib/constants";
import { import {
createStreamId, createStreamId,
@ -343,6 +344,8 @@ const chatApiLogger = createLogger("chat.route");
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN = const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i; /生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
const DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN =
/自拍|合照|聚会|聚餐|围坐|用餐|试坐|承重|受力|拿手机|手持|人物场景|人物关系|真人演示|人物展示|人像展示|固定模特|用户模特|same\s+model|fixed\s+model|person|people|human/i;
function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) { function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) {
const normalizedMarkdown = String(markdown || "").trim(); const normalizedMarkdown = String(markdown || "").trim();
@ -365,6 +368,35 @@ function getPublicImageGenerationErrorText(error: unknown) {
return message; return message;
} }
function replacementScenePlanShouldIncludeModelImage(scenePlan: any) {
if (!scenePlan) return false;
if (scenePlan.useModelImage === true) return true;
if (
scenePlan.peoplePolicy &&
scenePlan.peoplePolicy !== "none"
) {
return true;
}
if (scenePlan.generationRoute === "people_relation_locked") return true;
if (scenePlan.pageKind === "people_scene" || scenePlan.pageKind === "action_poster") {
return true;
}
const text = [
scenePlan.modelImageReason,
scenePlan.routeReason,
scenePlan.sceneSummary,
scenePlan.cameraSummary,
scenePlan.placementSummary,
scenePlan.preserveSummary,
scenePlan.styleSummary,
scenePlan.avoidSummary,
scenePlan.generationPrompt,
]
.filter(Boolean)
.join(" ");
return DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN.test(text);
}
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max( const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
1, 1,
@ -721,6 +753,20 @@ export async function POST(request: Request) {
.join(" ") .join(" ")
.trim(); .trim();
const autoContextText = typeof autoContext === "string" ? autoContext.trim() : ""; const autoContextText = typeof autoContext === "string" ? autoContext.trim() : "";
const requestCanvasAgentContext = normalizeCanvasAgentContext(
contextPayload || undefined
);
if (requestCanvasAgentContext) {
chatApiLogger.info("[CanvasAgentContext] Normalized request context:", {
source: requestCanvasAgentContext.source,
projectId: requestCanvasAgentContext.projectId || null,
selectedNodeCount: requestCanvasAgentContext.selectedNodes.length,
candidateNodeCount: requestCanvasAgentContext.candidateNodes.length,
slotCount: requestCanvasAgentContext.slots.length,
annotationCount: requestCanvasAgentContext.annotations.length,
focusPolicy: requestCanvasAgentContext.focusPolicy,
});
}
const now = Date.now(); const now = Date.now();
for (const [key, startedAt] of inFlightChatRequests.entries()) { for (const [key, startedAt] of inFlightChatRequests.entries()) {
@ -3465,10 +3511,15 @@ export async function POST(request: Request) {
sourceProductImages: productInputImageUrls.length, sourceProductImages: productInputImageUrls.length,
sourceModelImage: Boolean(modelInputImageUrl), sourceModelImage: Boolean(modelInputImageUrl),
}); });
const rerunReferenceImageUrls = (reference.rerunReferenceImages || [])
.map((image) => String(image.url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
.slice(0, 6);
const referenceSourceImageUrls = Array.from( const referenceSourceImageUrls = Array.from(
new Set([ new Set([
...productInputImageUrls, ...productInputImageUrls,
...(modelInputImageUrl ? [modelInputImageUrl] : []), ...(modelInputImageUrl ? [modelInputImageUrl] : []),
...rerunReferenceImageUrls,
reference.imageUrl, reference.imageUrl,
]), ]),
); );
@ -3494,7 +3545,9 @@ export async function POST(request: Request) {
const shouldIncludeModelImage = const shouldIncludeModelImage =
detailReplacementStrategy !== "product_only" && detailReplacementStrategy !== "product_only" &&
Boolean(modelInputImageUrl) && Boolean(modelInputImageUrl) &&
(replacementExecutionPlan.scenePlan.useModelImage || (replacementScenePlanShouldIncludeModelImage(
replacementExecutionPlan.scenePlan,
) ||
selectedProductContextImageUrls.length === 0); selectedProductContextImageUrls.length === 0);
const sourceImageUrls = Array.from( const sourceImageUrls = Array.from(
new Set([ new Set([
@ -3502,6 +3555,7 @@ export async function POST(request: Request) {
...(shouldIncludeModelImage && modelInputImageUrl ...(shouldIncludeModelImage && modelInputImageUrl
? [modelInputImageUrl] ? [modelInputImageUrl]
: []), : []),
...rerunReferenceImageUrls,
reference.imageUrl, reference.imageUrl,
]), ]),
); );
@ -3515,6 +3569,7 @@ export async function POST(request: Request) {
selectedProductImages: selectedProductContextImageUrls.length, selectedProductImages: selectedProductContextImageUrls.length,
sourceProductImages: productInputImageUrls.length, sourceProductImages: productInputImageUrls.length,
includeModelImage: shouldIncludeModelImage, includeModelImage: shouldIncludeModelImage,
rerunReferenceImages: rerunReferenceImageUrls.length,
sourceImageCount: sourceImageUrls.length, sourceImageCount: sourceImageUrls.length,
whiteLayout: replacementExecutionPlan.scenePlan.whiteBackgroundLikely, whiteLayout: replacementExecutionPlan.scenePlan.whiteBackgroundLikely,
copyCoupled: replacementExecutionPlan.scenePlan.copyImageCoupled, copyCoupled: replacementExecutionPlan.scenePlan.copyImageCoupled,
@ -3532,6 +3587,7 @@ export async function POST(request: Request) {
hasModelImage: shouldIncludeModelImage, hasModelImage: shouldIncludeModelImage,
productImageCount: selectedProductContextImageUrls.length, productImageCount: selectedProductContextImageUrls.length,
originalProductImageCount: productInputImageUrls.length, originalProductImageCount: productInputImageUrls.length,
rerunReferenceCount: rerunReferenceImageUrls.length,
templateReferenceCount: 0, templateReferenceCount: 0,
scenePlan: replacementExecutionPlan.scenePlan, scenePlan: replacementExecutionPlan.scenePlan,
referenceGeometry, referenceGeometry,
@ -4246,10 +4302,15 @@ export async function POST(request: Request) {
promptPackPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy, promptPackPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
routeReason: replacementExecutionPlan.scenePlan.routeReason, routeReason: replacementExecutionPlan.scenePlan.routeReason,
}); });
const rerunReferenceImageUrls = (reference.rerunReferenceImages || [])
.map((image) => String(image.url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
.slice(0, 6);
const referenceSourceImageUrls = Array.from( const referenceSourceImageUrls = Array.from(
new Set([ new Set([
...sharedUploadedImageUrls, ...sharedUploadedImageUrls,
...replacementTemplateReferenceImageUrls, ...replacementTemplateReferenceImageUrls,
...rerunReferenceImageUrls,
reference.imageUrl, reference.imageUrl,
]), ]),
); );
@ -4309,7 +4370,9 @@ export async function POST(request: Request) {
!(isDetailReplacementPreset && !(isDetailReplacementPreset &&
detailReplacementStrategy === "product_only") && detailReplacementStrategy === "product_only") &&
Boolean(modelInputImageUrl) && Boolean(modelInputImageUrl) &&
(replacementExecutionPlan.scenePlan.useModelImage || (replacementScenePlanShouldIncludeModelImage(
replacementExecutionPlan.scenePlan,
) ||
(isDetailReplacementPreset && (isDetailReplacementPreset &&
selectedProductContextImageUrls.length === 0)); selectedProductContextImageUrls.length === 0));
const sourceImageUrls = Array.from( const sourceImageUrls = Array.from(
@ -4320,6 +4383,7 @@ export async function POST(request: Request) {
? [modelInputImageUrl] ? [modelInputImageUrl]
: []), : []),
...replacementTemplateReferenceImageUrls, ...replacementTemplateReferenceImageUrls,
...rerunReferenceImageUrls,
reference.imageUrl, reference.imageUrl,
].filter(Boolean), ].filter(Boolean),
), ),
@ -4343,6 +4407,7 @@ export async function POST(request: Request) {
hasModelImage: shouldIncludeModelImage, hasModelImage: shouldIncludeModelImage,
productImageCount: selectedProductContextImageUrls.length, productImageCount: selectedProductContextImageUrls.length,
originalProductImageCount: productInputImageUrls.length, originalProductImageCount: productInputImageUrls.length,
rerunReferenceCount: rerunReferenceImageUrls.length,
templateReferenceCount: replacementTemplateReferenceImageUrls.length, templateReferenceCount: replacementTemplateReferenceImageUrls.length,
pairedProductIndex, pairedProductIndex,
scenePlan: replacementExecutionPlan.scenePlan, scenePlan: replacementExecutionPlan.scenePlan,
@ -4368,6 +4433,7 @@ export async function POST(request: Request) {
originalProductImages: productInputImageUrls.length, originalProductImages: productInputImageUrls.length,
hasModelImage: shouldIncludeModelImage, hasModelImage: shouldIncludeModelImage,
templateReferenceImages: replacementTemplateReferenceImageUrls.length, templateReferenceImages: replacementTemplateReferenceImageUrls.length,
rerunReferenceImages: rerunReferenceImageUrls.length,
finalReferenceImageIndex: sourceImageUrls.length, finalReferenceImageIndex: sourceImageUrls.length,
}, },
}); });
@ -4475,6 +4541,9 @@ export async function POST(request: Request) {
new Set([ new Set([
...sharedUploadedImageUrls, ...sharedUploadedImageUrls,
...replacementTemplateReferenceImageUrls, ...replacementTemplateReferenceImageUrls,
...replacementReferences.flatMap((item) =>
(item.rerunReferenceImages || []).map((image) => image.url)
),
...replacementReferences.map((item) => item.imageUrl), ...replacementReferences.map((item) => item.imageUrl),
]), ]),
), ),

View File

@ -1,4 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
const textPartSchema = z.object({ const textPartSchema = z.object({
type: z.enum(["text"]), type: z.enum(["text"]),
@ -62,7 +63,8 @@ const contextPayloadSchema = z
projectId: z.string().uuid().optional(), projectId: z.string().uuid().optional(),
selectedNodeIds: z.array(z.string().min(1).max(128)).max(20).optional(), selectedNodeIds: z.array(z.string().min(1).max(128)).max(20).optional(),
selectedAssetIds: z.array(z.string().uuid()).max(20).optional(), selectedAssetIds: z.array(z.string().uuid()).max(20).optional(),
focusPolicy: z.enum(["auto", "selected", "latest_result", "original"]).optional(), focusPolicy: z.enum(["auto", "selected", "latest_result", "original", "slot"]).optional(),
canvasAgentContext: z.custom<CanvasAgentContext>().optional(),
canvasImageNodes: z canvasImageNodes: z
.array( .array(
z.object({ z.object({
@ -83,6 +85,13 @@ const contextPayloadSchema = z
}) })
.optional(); .optional();
const agentModeSourceImageSchema = z.object({
url: z.string().url(),
label: z.string().max(255).optional(),
name: z.string().max(255).optional(),
assetId: z.string().min(1).max(128).nullable().optional(),
});
const agentModeReferenceSchema = z.object({ const agentModeReferenceSchema = z.object({
id: z.string().min(1).max(128), id: z.string().min(1).max(128),
label: z.string().min(1).max(255), label: z.string().min(1).max(255),
@ -91,13 +100,7 @@ const agentModeReferenceSchema = z.object({
sortOrder: z.number().min(0).max(999).optional(), sortOrder: z.number().min(0).max(999).optional(),
assignedProductIndex: z.number().min(0).max(9).nullable().optional(), assignedProductIndex: z.number().min(0).max(9).nullable().optional(),
passthrough: z.boolean().nullable().optional(), passthrough: z.boolean().nullable().optional(),
}); rerunReferenceImages: z.array(agentModeSourceImageSchema).max(6).optional(),
const agentModeSourceImageSchema = z.object({
url: z.string().url(),
label: z.string().max(255).optional(),
name: z.string().max(255).optional(),
assetId: z.string().min(1).max(128).nullable().optional(),
}); });
const agentModePayloadSchema = z const agentModePayloadSchema = z

View File

@ -49,6 +49,10 @@ const DETAIL_PLAN_INLINE_RETRIES = 1;
const DETAIL_PLAN_MAIN_TIMEOUT_MS = 45000; const DETAIL_PLAN_MAIN_TIMEOUT_MS = 45000;
const DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS = 10000; const DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS = 10000;
const logger = createLogger("canvas-detail-plan"); const logger = createLogger("canvas-detail-plan");
const DETAIL_MODEL_USAGE_HINT_PATTERN =
/模特|人物|人像|真人|人体|露脸|人脸|面部|发型|体型|肤色|穿搭|服装|试坐|试用|手持|拿着|自拍|合照|真人演示|人物场景|场景演示|使用者|顾客.{0,8}使用|model|person|people|human|face|hair|body|outfit|wearing|holding|selfie|lifestyle\s+demo/i;
const DETAIL_NO_MODEL_HINT_PATTERN =
/不要出现人物|不出现人物|不要有人物|无人|无人物|只展示产品|产品信息展示|不使用人物|不使用模特|禁止出现人物|no\s+(human|person|people)|without\s+(human|person|people)|product[-\s]?only/i;
function isAbortError(error: unknown): boolean { function isAbortError(error: unknown): boolean {
const target = error as { const target = error as {
@ -271,6 +275,77 @@ type DetailPlanResponse = {
}; };
}; };
function buildFixedModelIdentityInstruction(language: "zh" | "en") {
return language === "en"
? "Fixed model identity lock: if this frame includes a person, use only the same person from the uploaded model image. Keep face, hairstyle, body type, skin tone, and outfit style consistent; do not replace with another person. Pose and camera angle may adapt to the composition."
: "固定模特身份硬约束:如果本屏出现人物,只允许使用上传模特图中的同一人物;脸型五官、发型发色、体型肤色和穿搭风格必须保持一致,不得换成其他人。姿势、视线和机位可按构图调整。";
}
function screenSuggestsModelUsage(screen: DetailPlanScreen) {
const text = [
screen.objective,
screen.title,
screen.subtitle,
screen.copy,
screen.layoutNote,
screen.modelReason,
screen.visualPrompt,
screen.fabe?.b,
screen.fabe?.f,
screen.fabe?.a,
screen.fabe?.e,
]
.filter(Boolean)
.join(" ");
return (
DETAIL_MODEL_USAGE_HINT_PATTERN.test(text) &&
!DETAIL_NO_MODEL_HINT_PATTERN.test(text)
);
}
function normalizeDetailModelUsage(params: {
screens: DetailPlanScreen[];
hasModelImage: boolean;
language: "zh" | "en";
}) {
const { screens, hasModelImage, language } = params;
const identityInstruction = buildFixedModelIdentityInstruction(language);
return screens.map((screen) => {
if (!hasModelImage) {
return {
...screen,
useModel: false,
modelReason:
language === "en"
? "No model image was provided, so this frame does not use a fixed model."
: "未提供模特图,当前分镜不使用人物。",
};
}
const shouldUseModel = screen.useModel || screenSuggestsModelUsage(screen);
if (!shouldUseModel) {
return screen;
}
const modelReason =
screen.modelReason && !DETAIL_NO_MODEL_HINT_PATTERN.test(screen.modelReason)
? screen.modelReason
: language === "en"
? "This frame contains a human/model scenario, so the uploaded fixed model must be used."
: "本屏包含人物/模特展示语义,必须使用上传的固定模特图。";
const visualPrompt = screen.visualPrompt.includes(identityInstruction)
? screen.visualPrompt
: `${screen.visualPrompt} ${identityInstruction}`;
return {
...screen,
useModel: true,
modelReason,
visualPrompt,
};
});
}
type DetailPlanPreflight = { type DetailPlanPreflight = {
passed: boolean; passed: boolean;
designDirectorScore: number; designDirectorScore: number;
@ -1230,9 +1305,13 @@ function buildFallbackPlan(params: {
...params, ...params,
globalStyle, globalStyle,
}); });
const screens = enforceCoreFocusOnScreens({ const screens = normalizeDetailModelUsage({
screens: fallbackScreens, screens: enforceCoreFocusOnScreens({
coreFocus: params.coreFocus, screens: fallbackScreens,
coreFocus: params.coreFocus,
language,
}),
hasModelImage: params.hasModelImage,
language, language,
}); });
const qualityChecks = evaluatePlanQuality({ const qualityChecks = evaluatePlanQuality({
@ -2181,27 +2260,28 @@ export async function POST(request: Request) {
dslV2Screens?: unknown; dslV2Screens?: unknown;
}>(text || ""); }>(text || "");
const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle); const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle);
const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language).map((item) => const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language);
hasModelImage const normalizedScreensWithoutBundles = rawScreens.map((item) =>
? item
: {
...item,
useModel: false,
modelReason: "未提供模特图,当前分镜不使用人物。",
}
);
const normalizedScreens = rawScreens.map((item) =>
hasBundleImages hasBundleImages
? item ? item
: { : {
...item, ...item,
bundleShowcase: false, bundleShowcase: false,
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。", bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
} }
); );
const coreFocusedScreens = enforceCoreFocusOnScreens({ const normalizedScreens = normalizeDetailModelUsage({
screens: normalizedScreens, screens: normalizedScreensWithoutBundles,
coreFocus, hasModelImage,
language,
});
const coreFocusedScreens = normalizeDetailModelUsage({
screens: enforceCoreFocusOnScreens({
screens: normalizedScreens,
coreFocus,
language,
}),
hasModelImage,
language, language,
}); });
const qualityChecksInitial = evaluatePlanQuality({ const qualityChecksInitial = evaluatePlanQuality({
@ -2225,7 +2305,7 @@ export async function POST(request: Request) {
hasBundleImages, hasBundleImages,
}); });
const preflightScreensRaw = preflight.candidateScreens.map((item) => const preflightScreensWithoutBundles = preflight.candidateScreens.map((item) =>
hasBundleImages hasBundleImages
? item ? item
: { : {
@ -2234,9 +2314,18 @@ export async function POST(request: Request) {
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。", bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
} }
); );
const preflightScreens = enforceCoreFocusOnScreens({ const preflightScreensRaw = normalizeDetailModelUsage({
screens: preflightScreensRaw, screens: preflightScreensWithoutBundles,
coreFocus, hasModelImage,
language,
});
const preflightScreens = normalizeDetailModelUsage({
screens: enforceCoreFocusOnScreens({
screens: preflightScreensRaw,
coreFocus,
language,
}),
hasModelImage,
language, language,
}); });
const qualityChecksAfterPreflight = evaluatePlanQuality({ const qualityChecksAfterPreflight = evaluatePlanQuality({

View File

@ -87,7 +87,7 @@ const SCALE_ADJUST_CROP_MAX_EDGE = 1536;
const MIN_REGION_EDGE_PX = 24; const MIN_REGION_EDGE_PX = 24;
const SCALE_ADJUST_OPERATION = "scale-adjust"; const SCALE_ADJUST_OPERATION = "scale-adjust";
type LocalEditOperation = "local-edit" | typeof SCALE_ADJUST_OPERATION; type LocalEditOperation = "local-edit" | typeof SCALE_ADJUST_OPERATION;
const SCALE_ADJUST_PERCENT_OPTIONS = [-30, -20, -10, 10, 20, 30] as const; const SCALE_ADJUST_PERCENT_OPTIONS = [-30, -20, -10, 10, 20, 30, 50, 100] as const;
type ScaleAdjustPercent = (typeof SCALE_ADJUST_PERCENT_OPTIONS)[number]; type ScaleAdjustPercent = (typeof SCALE_ADJUST_PERCENT_OPTIONS)[number];
type ScaleAdjustCropReference = { type ScaleAdjustCropReference = {
regionIndex: number; regionIndex: number;
@ -385,14 +385,59 @@ function scaleRectAroundCenter(
}; };
} }
function expandRectWithMargins(
rect: NormalizedLocalEditRegion["rect"],
marginX: number,
marginY: number
) {
const left = clamp(rect.x - marginX, 0, 1);
const top = clamp(rect.y - marginY, 0, 1);
const right = clamp(rect.x + rect.width + marginX, left + 0.001, 1);
const bottom = clamp(rect.y + rect.height + marginY, top + 0.001, 1);
return {
x: left,
y: top,
width: Math.max(0.001, right - left),
height: Math.max(0.001, bottom - top),
};
}
function getScaleAdjustWorkspaceRect(
sourceRect: NormalizedLocalEditRegion["rect"],
targetRect: NormalizedLocalEditRegion["rect"],
scalePercent: ScaleAdjustPercent
) {
if (scalePercent <= 0) return targetRect;
const scaleRatio = Math.min(1, Math.max(0, scalePercent / 100));
const marginX = Math.min(
0.12,
Math.max(0.018, sourceRect.width * scaleRatio * 0.22)
);
const marginY = Math.min(
0.12,
Math.max(0.018, sourceRect.height * scaleRatio * 0.22)
);
return expandRectWithMargins(targetRect, marginX, marginY);
}
function buildScaleAdjustCompositeRegions( function buildScaleAdjustCompositeRegions(
regions: NormalizedLocalEditRegion[], regions: NormalizedLocalEditRegion[],
scalePercent: ScaleAdjustPercent scalePercent: ScaleAdjustPercent
) { ) {
return regions.flatMap((region) => [ return regions.flatMap((region) => {
region.rect, const targetRect = scaleRectAroundCenter(region.rect, scalePercent);
scaleRectAroundCenter(region.rect, scalePercent), const workspaceRect = getScaleAdjustWorkspaceRect(
]); region.rect,
targetRect,
scalePercent
);
return scalePercent > 0
? [region.rect, targetRect, workspaceRect]
: [region.rect, targetRect];
});
} }
function expandPixelRectForCrop(params: { function expandPixelRectForCrop(params: {
@ -704,7 +749,7 @@ function buildScaleAdjustPrompt(
cropIndex >= 0 cropIndex >= 0
? `;输入图片${cropIndex + 3}是该区域的干净局部裁切参考图,只用于识别目标主体的身份、轮廓与原始比例` ? `;输入图片${cropIndex + 3}是该区域的干净局部裁切参考图,只用于识别目标主体的身份、轮廓与原始比例`
: ""; : "";
return `区域 ${region.index}${cropNote}:请在输入图片1中找到输入图片2红色实线框所标记范围内最主要、可独立缩放的商品/物体主体。红色实线框只是“寻找主体”的范围,不是要缩放或贴回的矩形补丁;目标虚线框只是表达目标尺寸范围,不是要求主体填满矩形或变成矩形比例。请以主体自身轮廓、原始宽高比和视觉中心为准,执行整体等比${actionLabel},宽度与高度必须使用同一个缩放倍率,目标主体视觉尺寸约为原主体的 ${targetLabel}。严禁宽度变窄但高度变高,严禁横向挤压、纵向拉伸或改变主体透视形体。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`; return `区域 ${region.index}${cropNote}:请在输入图片1中找到输入图片2红色实线框所标记范围内最主要、可独立缩放的商品/物体主体。红色实线框只是“寻找主体”的范围,不是放大后的硬边界,也不是要缩放或贴回的矩形补丁;放大时主体可以自然超出红色实线框,并由周边背景补全衔接。目标虚线框只是表达目标尺寸范围,不是要求主体填满矩形或变成矩形比例。请以主体自身轮廓、原始宽高比和视觉中心为准,执行整体等比${actionLabel},宽度与高度必须使用同一个缩放倍率,目标主体视觉尺寸约为原主体的 ${targetLabel}。严禁宽度变窄但高度变高,严禁横向挤压、纵向拉伸或改变主体透视形体。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`;
}) })
.join("\n"); .join("\n");
const cropLines = cropReferences const cropLines = cropReferences
@ -722,9 +767,9 @@ function buildScaleAdjustPrompt(
cropLines cropLines
? `输入图片3及之后是框选区域的干净局部裁切参考图,作用是帮助你确认“要缩放的是哪个主体”。这些裁切图不是输出底图,不得被整体放大、贴回或覆盖到原图上。\n${cropLines}` ? `输入图片3及之后是框选区域的干净局部裁切参考图,作用是帮助你确认“要缩放的是哪个主体”。这些裁切图不是输出底图,不得被整体放大、贴回或覆盖到原图上。\n${cropLines}`
: "", : "",
"输入图片2中的红色实线框表示输入图片1里目标主体当前大致位置;青绿色或绿色虚线框只表示比例调整后的安全尺寸范围。最终画面不得出现这些框线,主体也不需要填满虚线矩形;必须保持主体自身原始宽高比和透视形体。", "输入图片2中的红色实线框表示输入图片1里目标主体当前大致位置;它只用于定位主体,不限制放大后的空间。青绿色或绿色虚线框只表示比例调整后的安全尺寸范围。最终画面不得出现这些框线,主体也不需要填满虚线矩形;必须保持主体自身原始宽高比和透视形体。",
"如果系统或模型只能读取一张参考图,必须优先以输入图片1为输出基底,不得清理、改写或删除输入图片1中框选区域外的任何文字和图形。", "如果系统或模型只能读取一张参考图,必须优先以输入图片1为输出基底,不得清理、改写或删除输入图片1中框选区域外的任何文字和图形。",
"系统已根据用户框选区域计算出下方每个区域的原图坐标;框选区域只是定位目标主体的搜索范围,不是最终画面的裁切块、贴片、蒙版边界,也不是要整体缩放的矩形图层。", "系统已根据用户框选区域计算出下方每个区域的原图坐标;框选区域只是定位目标主体的搜索范围,不是最终画面的裁切块、贴片、蒙版边界,也不是放大后的空间上限,更不是要整体缩放的矩形图层。",
"最终结果必须保持原始干净图的画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度;严禁整张图整体缩放、裁切、平移、重新排版或重新生成。", "最终结果必须保持原始干净图的画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度;严禁整张图整体缩放、裁切、平移、重新排版或重新生成。",
"Highest priority rule: do NOT zoom, crop, resize, reframe, pan, or regenerate the full canvas. Treat image 1 as a locked background plate and only inpaint the selected subject footprint.", "Highest priority rule: do NOT zoom, crop, resize, reframe, pan, or regenerate the full canvas. Treat image 1 as a locked background plate and only inpaint the selected subject footprint.",
"请不要生成任何彩色框、矩形边框、编号圆点、提示词气泡、标注文字、UI 控件或半透明色块。", "请不要生成任何彩色框、矩形边框、编号圆点、提示词气泡、标注文字、UI 控件或半透明色块。",
@ -737,7 +782,7 @@ function buildScaleAdjustPrompt(
"2. 严禁把整个矩形框选区域、输入图片1整张图或任意裁切图当成图片块整体缩放、平移、复制、裁切或贴回;最终画面不得出现矩形拼接边缘、色差硬边、补丁感、裁切线或局部贴片痕迹。", "2. 严禁把整个矩形框选区域、输入图片1整张图或任意裁切图当成图片块整体缩放、平移、复制、裁切或贴回;最终画面不得出现矩形拼接边缘、色差硬边、补丁感、裁切线或局部贴片痕迹。",
"2.1 严禁把输入图片1整张图整体放大、缩小、裁切或重新构图;如果画面中的标题、Logo、页面文字、边框、背景家具或非目标主体发生整体位移/缩放/消失,必须视为失败并重新生成。", "2.1 严禁把输入图片1整张图整体放大、缩小、裁切或重新构图;如果画面中的标题、Logo、页面文字、边框、背景家具或非目标主体发生整体位移/缩放/消失,必须视为失败并重新生成。",
`3. 缩小时,目标主体应围绕原位置视觉中心自然变小到约 ${targetLabel};主体缩小后,原来被遮挡的背景、桌面、墙面、阴影、纹理和透视必须被重新生成并无缝补全,选区内应出现明显新增留白或背景。`, `3. 缩小时,目标主体应围绕原位置视觉中心自然变小到约 ${targetLabel};主体缩小后,原来被遮挡的背景、桌面、墙面、阴影、纹理和透视必须被重新生成并无缝补全,选区内应出现明显新增留白或背景。`,
`4. 放大时,目标主体应围绕原位置视觉中心自然变大到约 ${targetLabel};宽度和高度都要同步变大,不能出现“高度变高但宽度变窄”的非等比变形;主体边缘与遮挡关系要合理,不能产生突兀裁切。`, `4. 放大时,目标主体应围绕原位置视觉中心自然变大到约 ${targetLabel};允许主体自然超出用户手画的原始红框,使用周围真实背景完成边缘、阴影和遮挡衔接。宽度和高度都要同步变大,不能出现“高度变高但宽度变窄”的非等比变形;主体边缘与遮挡关系要合理,不能产生突兀裁切。`,
"5. 如果主体靠近画布边界导致目标尺寸空间不足,请用能完整放入画面的最大统一倍率整体缩放;绝不能为了塞进画面而只压缩宽度或只拉伸高度。", "5. 如果主体靠近画布边界导致目标尺寸空间不足,请用能完整放入画面的最大统一倍率整体缩放;绝不能为了塞进画面而只压缩宽度或只拉伸高度。",
"6. 框选区域内非目标主体的背景、墙面、桌面、植物、文字和其它物体应保持原场景逻辑,不要跟着主体一起缩放。", "6. 框选区域内非目标主体的背景、墙面、桌面、植物、文字和其它物体应保持原场景逻辑,不要跟着主体一起缩放。",
"6.1 比例调整后的主体边缘必须和周围环境自然融合,选区边界不能被肉眼看出;不得出现一整块矩形区域清晰度、颜色、光照、纹理或透视突然变化。", "6.1 比例调整后的主体边缘必须和周围环境自然融合,选区边界不能被肉眼看出;不得出现一整块矩形区域清晰度、颜色、光照、纹理或透视突然变化。",

View File

@ -247,7 +247,7 @@ type DetailResultLocalEditTarget = {
}; };
type DetailResultLocalEditMode = "local_edit" | "scale_adjust"; type DetailResultLocalEditMode = "local_edit" | "scale_adjust";
type DetailResultScalePercent = -30 | -20 | -10 | 10 | 20 | 30; type DetailResultScalePercent = -30 | -20 | -10 | 10 | 20 | 30 | 50 | 100;
type DetailResultLocalEditRect = { type DetailResultLocalEditRect = {
x: number; x: number;
@ -290,6 +290,7 @@ const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100;
const MAX_AGENT_MODE_QUERY_LENGTH = 64; const MAX_AGENT_MODE_QUERY_LENGTH = 64;
const MAX_AGENT_MODE_REFERENCE_COUNT = 40; const MAX_AGENT_MODE_REFERENCE_COUNT = 40;
const DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES = 30 * 1024 * 1024; const DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES = 30 * 1024 * 1024;
const REPLACEMENT_RERUN_MAX_REFERENCE_IMAGES = 4;
const DETAIL_SCALE_ADJUST_OPTIONS: DetailResultScalePercent[] = [ const DETAIL_SCALE_ADJUST_OPTIONS: DetailResultScalePercent[] = [
-30, -30,
-20, -20,
@ -297,6 +298,8 @@ const DETAIL_SCALE_ADJUST_OPTIONS: DetailResultScalePercent[] = [
10, 10,
20, 20,
30, 30,
50,
100,
]; ];
const DETAIL_LOCAL_EDIT_MIN_RECT_RATIO = 0.025; const DETAIL_LOCAL_EDIT_MIN_RECT_RATIO = 0.025;
const DIRECT_MODEL_UPLOAD_SLOT_COUNT = 4; const DIRECT_MODEL_UPLOAD_SLOT_COUNT = 4;
@ -860,6 +863,7 @@ function getDetailScaleAdjustRegionPrompt(scalePercent: DetailResultScalePercent
return [ return [
`对框选范围内的主物体执行等比${actionLabel},调整后主体视觉尺寸必须约为原来的 ${targetLabel},需要肉眼明显看出已经${scaleVerb}。`, `对框选范围内的主物体执行等比${actionLabel},调整后主体视觉尺寸必须约为原来的 ${targetLabel},需要肉眼明显看出已经${scaleVerb}。`,
"必须锁定主体自身原始宽高比和透视形体,宽度与高度用同一个倍率同步变化;严禁宽度变窄但高度变高、严禁横向挤压或纵向拉伸。", "必须锁定主体自身原始宽高比和透视形体,宽度与高度用同一个倍率同步变化;严禁宽度变窄但高度变高、严禁横向挤压或纵向拉伸。",
"框选区域只用于识别要调整的主体,不是放大后的边界;放大时主体可以自然超出原始框选范围,并由周边背景补全衔接。",
"只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。", "只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
scalePercent < 0 scalePercent < 0
? "主体缩小后露出的背景、桌面、墙面、阴影和遮挡关系必须自然补全。" ? "主体缩小后露出的背景、桌面、墙面、阴影和遮挡关系必须自然补全。"
@ -1475,7 +1479,7 @@ function DetailResultLocalEditDialog({
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38"> <div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38">
Scale Scale
</div> </div>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{DETAIL_SCALE_ADJUST_OPTIONS.map((option) => { {DETAIL_SCALE_ADJUST_OPTIONS.map((option) => {
const active = selectedScalePercent === option; const active = selectedScalePercent === option;
const Icon = option > 0 ? Maximize2 : Minimize2; const Icon = option > 0 ? Maximize2 : Minimize2;
@ -2035,6 +2039,23 @@ interface ReplacementRerunTarget {
title: string; title: string;
} }
type ReplacementRerunReferenceImage = UploadedDetailReferenceImage & {
id: string;
};
type ReplacementRerunRequestReferenceImage = {
assetId?: string | null;
label?: string;
name?: string;
url: string;
};
type ReplacementRerunRequestSnapshot = {
cardId: string;
images: ReplacementRerunRequestReferenceImage[];
modeType: ReplacementModeType;
} | null;
type ReplacementResultState = Record< type ReplacementResultState = Record<
ReplacementModeType, ReplacementModeType,
Record<string, ReplacementResultRecord> Record<string, ReplacementResultRecord>
@ -2729,9 +2750,11 @@ export function AgentGenerationWorkspaceDarkV2({
const generationRequestSnapshotRef = useRef<{ const generationRequestSnapshotRef = useRef<{
hiddenPrompt: string | null; hiddenPrompt: string | null;
replacementExecutionCardIds: string[] | null; replacementExecutionCardIds: string[] | null;
replacementRerunReferenceImages: ReplacementRerunRequestSnapshot;
}>({ }>({
hiddenPrompt: null, hiddenPrompt: null,
replacementExecutionCardIds: null, replacementExecutionCardIds: null,
replacementRerunReferenceImages: null,
}); });
const [localAgentModeState, setLocalAgentModeState] = useState<AgentModeWorkspaceState>( const [localAgentModeState, setLocalAgentModeState] = useState<AgentModeWorkspaceState>(
() => createEmptyAgentModeWorkspaceState() () => createEmptyAgentModeWorkspaceState()
@ -2858,6 +2881,10 @@ export function AgentGenerationWorkspaceDarkV2({
const [replacementRerunTarget, setReplacementRerunTarget] = const [replacementRerunTarget, setReplacementRerunTarget] =
useState<ReplacementRerunTarget | null>(null); useState<ReplacementRerunTarget | null>(null);
const [replacementRerunPrompt, setReplacementRerunPrompt] = useState(""); const [replacementRerunPrompt, setReplacementRerunPrompt] = useState("");
const [replacementRerunReferenceImages, setReplacementRerunReferenceImages] =
useState<ReplacementRerunReferenceImage[]>([]);
const [replacementRerunReferenceUploading, setReplacementRerunReferenceUploading] =
useState(false);
const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] = const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] =
useState<Set<string>>(() => new Set()); useState<Set<string>>(() => new Set());
const processedResultHintTimerRef = useRef<Record<string, number>>({}); const processedResultHintTimerRef = useRef<Record<string, number>>({});
@ -3684,6 +3711,8 @@ export function AgentGenerationWorkspaceDarkV2({
.map((item) => String(item || "").trim()) .map((item) => String(item || "").trim())
.filter(Boolean) .filter(Boolean)
); );
const replacementRerunReferenceSnapshot =
generationRequestSnapshotRef.current.replacementRerunReferenceImages;
const resolvedAgentPresetId = const resolvedAgentPresetId =
requiresPresetSelection requiresPresetSelection
? transportState.selectedAgentPresetId || selectedAgentPresetId || undefined ? transportState.selectedAgentPresetId || selectedAgentPresetId || undefined
@ -3726,23 +3755,42 @@ export function AgentGenerationWorkspaceDarkV2({
requestedReplacementCardIdSet.has(String(item.id || "").trim()) requestedReplacementCardIdSet.has(String(item.id || "").trim())
) )
.filter((item) => Boolean(String(item.imageUrl || "").trim())) .filter((item) => Boolean(String(item.imageUrl || "").trim()))
.map((item) => ({ .map((item) => {
id: String(item.id || "").slice(0, 128), const itemId = String(item.id || "").trim();
label: String(item.label || "").slice(0, 255), const rerunReferenceImages =
imageUrl: String(item.imageUrl || "").trim(), replacementRerunReferenceSnapshot &&
candidateId: item.candidateId replacementRerunReferenceSnapshot.modeType ===
? String(item.candidateId).slice(0, 128) (transportState.isDetailReplacementPreset
: null, ? "detail_replacement"
sortOrder: item.sortOrder ?? 0, : "sku_replacement") &&
passthrough: item.passthrough === true, replacementRerunReferenceSnapshot.cardId === itemId
assignedProductIndex: ? replacementRerunReferenceSnapshot.images
transportState.selectedProductAttachmentCount > 0 : [];
? Math.min( return {
item.assignedProductIndex ?? 0, id: itemId.slice(0, 128),
Math.max(transportState.selectedProductAttachmentCount - 1, 0) label: String(item.label || "").slice(0, 255),
) imageUrl: String(item.imageUrl || "").trim(),
candidateId: item.candidateId
? String(item.candidateId).slice(0, 128)
: null, : null,
})), sortOrder: item.sortOrder ?? 0,
passthrough: item.passthrough === true,
assignedProductIndex:
transportState.selectedProductAttachmentCount > 0
? Math.min(
item.assignedProductIndex ?? 0,
Math.max(transportState.selectedProductAttachmentCount - 1, 0)
)
: null,
rerunReferenceImages:
rerunReferenceImages.length > 0
? rerunReferenceImages.slice(
0,
REPLACEMENT_RERUN_MAX_REFERENCE_IMAGES
)
: undefined,
};
}),
sourceImages: transportState.isDetailReplacementPreset sourceImages: transportState.isDetailReplacementPreset
? { ? {
productImages: transportState.detailReplacementProductSourceImages, productImages: transportState.detailReplacementProductSourceImages,
@ -5472,6 +5520,7 @@ export function AgentGenerationWorkspaceDarkV2({
replacementModeType?: ReplacementModeType; replacementModeType?: ReplacementModeType;
replacementCardIds?: string[] | null; replacementCardIds?: string[] | null;
promptText?: string | null; promptText?: string | null;
replacementRerunReferenceImages?: ReplacementRerunRequestSnapshot;
}) => { }) => {
if (isSelectedBusinessModeUnavailable || activeBusinessModeKey === "buyer_show") { if (isSelectedBusinessModeUnavailable || activeBusinessModeKey === "buyer_show") {
toast.error("当前生图模式暂未开放"); toast.error("当前生图模式暂未开放");
@ -5634,6 +5683,8 @@ export function AgentGenerationWorkspaceDarkV2({
replacementModeType && replacementExecutionCards.length > 0 replacementModeType && replacementExecutionCards.length > 0
? replacementExecutionCards.map((card) => card.id) ? replacementExecutionCards.map((card) => card.id)
: null, : null,
replacementRerunReferenceImages:
options?.replacementRerunReferenceImages || null,
}; };
setReplacementExecutionContext( setReplacementExecutionContext(
replacementModeType && replacementExecutionCards.length > 0 replacementModeType && replacementExecutionCards.length > 0
@ -5675,6 +5726,7 @@ export function AgentGenerationWorkspaceDarkV2({
generationRequestSnapshotRef.current = { generationRequestSnapshotRef.current = {
hiddenPrompt: null, hiddenPrompt: null,
replacementExecutionCardIds: null, replacementExecutionCardIds: null,
replacementRerunReferenceImages: null,
}; };
setPendingGeneration(null); setPendingGeneration(null);
toast.error(error instanceof Error ? error.message : "发送生成请求失败"); toast.error(error instanceof Error ? error.message : "发送生成请求失败");
@ -5722,6 +5774,7 @@ export function AgentGenerationWorkspaceDarkV2({
generationRequestSnapshotRef.current = { generationRequestSnapshotRef.current = {
hiddenPrompt: hiddenPromptRef.current, hiddenPrompt: hiddenPromptRef.current,
replacementExecutionCardIds: null, replacementExecutionCardIds: null,
replacementRerunReferenceImages: null,
}; };
setPendingGeneration(null); setPendingGeneration(null);
setMessages((current) => current); setMessages((current) => current);
@ -5753,19 +5806,124 @@ export function AgentGenerationWorkspaceDarkV2({
: current : current
); );
}, [handleUpdateReplacementResultRecord, replacementTextEditTarget]); }, [handleUpdateReplacementResultRecord, replacementTextEditTarget]);
const handleReplacementRerunReferenceUpload = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
event.currentTarget.value = "";
if (files.length === 0) return;
const remainingSlots = Math.max(
0,
REPLACEMENT_RERUN_MAX_REFERENCE_IMAGES -
replacementRerunReferenceImages.length
);
if (remainingSlots <= 0) {
toast.error(
`重新生图最多添加 ${REPLACEMENT_RERUN_MAX_REFERENCE_IMAGES} 张参考图`
);
return;
}
const targetFiles = files.slice(0, remainingSlots);
if (files.length > targetFiles.length) {
toast.info(
`已自动保留前 ${REPLACEMENT_RERUN_MAX_REFERENCE_IMAGES} 张参考图`
);
}
setReplacementRerunReferenceUploading(true);
try {
const uploaded = await Promise.all(
targetFiles.map(async (file) => {
const upload = await uploadDetailReferenceImageFile(file);
return {
...upload,
id: `replacement-rerun-reference-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`,
};
})
);
setReplacementRerunReferenceImages((current) => [
...current,
...uploaded,
]);
toast.success(
uploaded.length > 1
? `已添加 ${uploaded.length} 张重生参考图`
: "已添加重生参考图"
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "参考图上传失败");
} finally {
setReplacementRerunReferenceUploading(false);
}
},
[replacementRerunReferenceImages.length]
);
const handleRemoveReplacementRerunReferenceImage = useCallback((id: string) => {
setReplacementRerunReferenceImages((current) =>
current.filter((image) => image.id !== id)
);
}, []);
const handleConfirmReplacementRerun = useCallback(() => { const handleConfirmReplacementRerun = useCallback(() => {
if (!replacementRerunTarget) { if (!replacementRerunTarget) {
return; return;
} }
const userPrompt = replacementRerunPrompt.trim();
if (!userPrompt) {
toast.error("请先填写本次重新生图的调整要求");
return;
}
if (replacementRerunReferenceUploading) {
toast.info("参考图上传中,请稍候");
return;
}
const rerunReferenceImages = replacementRerunReferenceImages
.map((image, index) => ({
assetId: image.assetId || null,
label: `重新生图补充参考图 ${index + 1}`,
name: image.name || `重新生图参考图 ${index + 1}`,
url: image.url,
}))
.filter((image) => /^https?:\/\//i.test(image.url))
.slice(0, REPLACEMENT_RERUN_MAX_REFERENCE_IMAGES);
const promptText = [
"【单张重新生图】请重新生成当前这张结果图,必须根据本次补充要求产生可见变化,避免返回与上一版几乎相同的图片。",
`本次补充要求:${userPrompt}`,
rerunReferenceImages.length > 0
? `本次额外提供 ${rerunReferenceImages.length} 张补充参考图;它们只用于理解风格、细节、构图或局部调整方向,不得覆盖当前目标参考图的版式边界和文字层级。`
: "",
]
.filter(Boolean)
.join("\n");
handleSend({ handleSend({
replacementModeType: replacementRerunTarget.modeType, replacementModeType: replacementRerunTarget.modeType,
replacementCardIds: [replacementRerunTarget.cardId], replacementCardIds: [replacementRerunTarget.cardId],
promptText: replacementRerunPrompt, promptText,
replacementRerunReferenceImages:
rerunReferenceImages.length > 0
? {
cardId: replacementRerunTarget.cardId,
images: rerunReferenceImages,
modeType: replacementRerunTarget.modeType,
}
: null,
}); });
setReplacementRerunTarget(null); setReplacementRerunTarget(null);
setReplacementRerunPrompt(""); setReplacementRerunPrompt("");
}, [handleSend, replacementRerunPrompt, replacementRerunTarget]); setReplacementRerunReferenceImages([]);
}, [
handleSend,
replacementRerunPrompt,
replacementRerunReferenceImages,
replacementRerunReferenceUploading,
replacementRerunTarget,
]);
const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => { const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => {
const raw = String(imageUrl || "").trim(); const raw = String(imageUrl || "").trim();
@ -6952,7 +7110,7 @@ export function AgentGenerationWorkspaceDarkV2({
? "首屏封面规则:必须做成海报主视觉,单一主体强焦点,产品主体占画面55%-70%,顶部标题区清晰留白,禁止拼贴式说明图。" ? "首屏封面规则:必须做成海报主视觉,单一主体强焦点,产品主体占画面55%-70%,顶部标题区清晰留白,禁止拼贴式说明图。"
: "", : "",
useModelForScene useModelForScene
? "人物规则:本屏涉及人物场景,固定使用提供的模特图同一人物(面部、发型、体型、服装风格保持一致)。" ? "人物规则:本屏涉及人物场景,必须固定使用提供的模特图同一人物;脸型五官、发型发色、体型肤色、穿搭风格保持一致,不得换脸、换人、换发型或改成无关人物。"
: "人物规则:本屏不要出现人物或人体部位,只展示产品与场景元素。", : "人物规则:本屏不要出现人物或人体部位,只展示产品与场景元素。",
screen?.modelReason ? `人物融入依据:${screen.modelReason}` : "", screen?.modelReason ? `人物融入依据:${screen.modelReason}` : "",
bundleShowcase bundleShowcase
@ -9426,6 +9584,7 @@ export function AgentGenerationWorkspaceDarkV2({
title: `${detailReferenceSourceLabel}结果图 ${card.index + 1}`, title: `${detailReferenceSourceLabel}结果图 ${card.index + 1}`,
}); });
setReplacementRerunPrompt(""); setReplacementRerunPrompt("");
setReplacementRerunReferenceImages([]);
}} }}
className={cn(IMAGE_OVERLAY_ACTION_CLASS, "disabled:cursor-not-allowed disabled:opacity-45")} className={cn(IMAGE_OVERLAY_ACTION_CLASS, "disabled:cursor-not-allowed disabled:opacity-45")}
> >
@ -9971,6 +10130,7 @@ export function AgentGenerationWorkspaceDarkV2({
title: `${skuReferenceSourceLabel}结果图 ${card.index + 1}`, title: `${skuReferenceSourceLabel}结果图 ${card.index + 1}`,
}); });
setReplacementRerunPrompt(""); setReplacementRerunPrompt("");
setReplacementRerunReferenceImages([]);
}} }}
className={cn(IMAGE_OVERLAY_ACTION_CLASS, "disabled:cursor-not-allowed disabled:opacity-45")} className={cn(IMAGE_OVERLAY_ACTION_CLASS, "disabled:cursor-not-allowed disabled:opacity-45")}
> >
@ -10544,6 +10704,7 @@ export function AgentGenerationWorkspaceDarkV2({
if (!open) { if (!open) {
setReplacementRerunTarget(null); setReplacementRerunTarget(null);
setReplacementRerunPrompt(""); setReplacementRerunPrompt("");
setReplacementRerunReferenceImages([]);
} }
}} }}
> >
@ -10551,7 +10712,7 @@ export function AgentGenerationWorkspaceDarkV2({
<DialogHeader> <DialogHeader>
<DialogTitle>重新生图</DialogTitle> <DialogTitle>重新生图</DialogTitle>
<DialogDescription> <DialogDescription>
当前会只重做这一张结果图,不会把已经完成的其它结果重新跑一遍。 当前会只重做这一张结果图。请填写本次调整要求,也可以添加补充参考图帮助模型拉开差异。
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
@ -10566,9 +10727,86 @@ export function AgentGenerationWorkspaceDarkV2({
value={replacementRerunPrompt} value={replacementRerunPrompt}
onChange={(event) => setReplacementRerunPrompt(event.target.value)} onChange={(event) => setReplacementRerunPrompt(event.target.value)}
className="min-h-[140px] resize-y" className="min-h-[140px] resize-y"
placeholder="例如:标题更有冲击力、背景更明亮、保留版式但优化卖点文案层级..." placeholder="例如:背景更明亮,产品占比更大,标题更有冲击力,保留版式但优化卖点文案层级..."
/> />
</div> </div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-slate-700 dark:text-zinc-200">
补充参考图
</div>
<span className="text-xs text-slate-400 dark:text-zinc-500">
{replacementRerunReferenceImages.length}/
{REPLACEMENT_RERUN_MAX_REFERENCE_IMAGES}
</span>
</div>
<input
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.jpg,.jpeg,.png,.webp,.heic,.heif"
className="hidden"
id="replacement-rerun-reference-upload"
multiple
onChange={(event) => {
void handleReplacementRerunReferenceUpload(event);
}}
type="file"
/>
<button
className="inline-flex h-9 items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-45 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
disabled={
replacementRerunReferenceUploading ||
replacementRerunReferenceImages.length >=
REPLACEMENT_RERUN_MAX_REFERENCE_IMAGES
}
onClick={() =>
document
.getElementById("replacement-rerun-reference-upload")
?.click()
}
type="button"
>
{replacementRerunReferenceUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImagePlus className="h-4 w-4" />
)}
添加参考图
</button>
{replacementRerunReferenceImages.length > 0 ? (
<div className="grid grid-cols-4 gap-2">
{replacementRerunReferenceImages.map((image) => (
<div
className="group relative aspect-square overflow-hidden rounded-xl border border-slate-200 bg-slate-50 dark:border-zinc-800 dark:bg-zinc-900"
key={image.id}
title={image.name || "补充参考图"}
>
<img
alt={image.name || "补充参考图"}
className="h-full w-full object-cover"
draggable={false}
src={buildImageProxyUrl(image.url, {
width: 220,
quality: 82,
})}
/>
<button
className="absolute inset-0 hidden items-center justify-center bg-black/55 text-white group-hover:flex"
onClick={() =>
handleRemoveReplacementRerunReferenceImage(image.id)
}
title="移除参考图"
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-slate-200 px-3 py-3 text-xs text-slate-400 dark:border-zinc-800 dark:text-zinc-500">
可选。参考图只作为本次重生的风格、细节或构图补充,不会加入长期参考图列表。
</div>
)}
</div>
</div> </div>
<div className="flex items-center justify-end gap-3"> <div className="flex items-center justify-end gap-3">
<Button <Button
@ -10577,11 +10815,19 @@ export function AgentGenerationWorkspaceDarkV2({
onClick={() => { onClick={() => {
setReplacementRerunTarget(null); setReplacementRerunTarget(null);
setReplacementRerunPrompt(""); setReplacementRerunPrompt("");
setReplacementRerunReferenceImages([]);
}} }}
> >
取消 取消
</Button> </Button>
<Button type="button" onClick={handleConfirmReplacementRerun}> <Button
type="button"
disabled={
replacementRerunReferenceUploading ||
!replacementRerunPrompt.trim()
}
onClick={handleConfirmReplacementRerun}
>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
开始重生 开始重生
</Button> </Button>

View File

@ -78,6 +78,13 @@ import type {
AgentWorkflowComposerContext, AgentWorkflowComposerContext,
} from "@/types/agent"; } from "@/types/agent";
import type { CanvasNode } from "@/types/canvas"; import type { CanvasNode } from "@/types/canvas";
import type {
CanvasAgentAnnotationRef,
CanvasAgentContext,
CanvasAgentNodeRef,
CanvasAgentSlotRef,
CanvasAgentNodeType,
} from "@/types/canvas/agent-protocol";
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch"; import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
import { import {
DEFAULT_CANVAS_SKILL_TAGS_TITLE, DEFAULT_CANVAS_SKILL_TAGS_TITLE,
@ -1619,6 +1626,7 @@ export function CanvasChatPanel({
const nodes = useCanvasStore((state) => state.nodes); const nodes = useCanvasStore((state) => state.nodes);
const projectTitle = useCanvasStore((state) => state.projectTitle); const projectTitle = useCanvasStore((state) => state.projectTitle);
const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds); const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds);
const viewport = useCanvasStore((state) => state.viewport);
const selectNodes = useCanvasStore((state) => state.selectNodes); const selectNodes = useCanvasStore((state) => state.selectNodes);
const setViewport = useCanvasStore((state) => state.setViewport); const setViewport = useCanvasStore((state) => state.setViewport);
const clearSelection = useCanvasStore((state) => state.clearSelection); const clearSelection = useCanvasStore((state) => state.clearSelection);
@ -5325,17 +5333,13 @@ export function CanvasChatPanel({
}, [nodes, selectedImageNodes]); }, [nodes, selectedImageNodes]);
const buildContextPayload = useCallback(() => { const buildContextPayload = useCallback(() => {
const selectedNodeIdSet = new Set(selectedNodeIds);
const imageNodes = nodes.filter( const imageNodes = nodes.filter(
(node) => (node) =>
node.type === "image" && node.type === "image" &&
isValidChatHttpUrl(String((node.data as any)?.src || "").trim()), isValidChatHttpUrl(String((node.data as any)?.src || "").trim()),
); );
if (imageNodes.length === 0) {
generationAnchorNodesRef.current = [];
return undefined;
}
const effectiveComposerMode = activeComposerModeRef.current || activeComposerMode; const effectiveComposerMode = activeComposerModeRef.current || activeComposerMode;
const shouldIsolateDirectModeContext = effectiveComposerMode !== "agent"; const shouldIsolateDirectModeContext = effectiveComposerMode !== "agent";
const selectedImageNodesForContext = selectedImageNodes.filter((node) => const selectedImageNodesForContext = selectedImageNodes.filter((node) =>
@ -5348,6 +5352,10 @@ export function CanvasChatPanel({
} }
const MAX_CONTEXT_IMAGES = 12; const MAX_CONTEXT_IMAGES = 12;
const MAX_AGENT_CANDIDATE_NODES = 30;
const selectedCanvasNodes = nodes
.filter((node) => selectedNodeIdSet.has(node.id))
.slice(0, 20);
const selectedContextNodes = selectedImageNodesForContext.slice(0, 20); const selectedContextNodes = selectedImageNodesForContext.slice(0, 20);
generationAnchorNodesRef.current = selectedContextNodes.slice(0, 4); generationAnchorNodesRef.current = selectedContextNodes.slice(0, 4);
const selectedIds = new Set(selectedContextNodes.map((node) => node.id)); const selectedIds = new Set(selectedContextNodes.map((node) => node.id));
@ -5361,6 +5369,13 @@ export function CanvasChatPanel({
] ]
: imageNodes; : imageNodes;
const contextNodes = prioritizedNodes.slice(0, MAX_CONTEXT_IMAGES); const contextNodes = prioritizedNodes.slice(0, MAX_CONTEXT_IMAGES);
const candidateSourceNodes = [
...selectedCanvasNodes,
...contextNodes.filter((node) => !selectedNodeIdSet.has(node.id)),
];
const uniqueCandidateSourceNodes = Array.from(
new Map(candidateSourceNodes.map((node) => [node.id, node])).values()
).slice(0, MAX_AGENT_CANDIDATE_NODES);
const selectedAssetIds = Array.from( const selectedAssetIds = Array.from(
new Set( new Set(
selectedContextNodes selectedContextNodes
@ -5372,12 +5387,210 @@ export function CanvasChatPanel({
), ),
); );
const projectId = isValidChatUuid(canvasId) ? canvasId : undefined;
const toAgentNodeRef = (node: CanvasNode): CanvasAgentNodeRef | null => {
const data = (node.data || {}) as Record<string, any>;
const type = String(node.type) as CanvasAgentNodeType;
const metadata =
data.metadata && typeof data.metadata === "object" && !Array.isArray(data.metadata)
? data.metadata
: undefined;
const srcCandidate =
typeof data.src === "string"
? data.src
: typeof data.previewImage === "string"
? data.previewImage
: typeof data.imageUrl === "string"
? data.imageUrl
: typeof data.worldUrl === "string"
? data.worldUrl
: typeof data.renderArtifacts?.screenshotUrl === "string"
? data.renderArtifacts.screenshotUrl
: "";
const url = isValidChatHttpUrl(String(srcCandidate || "").trim())
? String(srcCandidate).trim()
: undefined;
const titleCandidate =
typeof data.title === "string"
? data.title
: typeof data.content === "string"
? data.content
: typeof data.prompt === "string"
? data.prompt
: undefined;
return {
nodeId: node.id,
type,
assetId: isValidChatUuid(data.assetId) ? String(data.assetId).trim() : undefined,
url,
title:
typeof titleCandidate === "string"
? String(titleCandidate).trim().slice(0, 256)
: undefined,
prompt:
typeof data.prompt === "string"
? String(data.prompt).trim().slice(0, 2000)
: undefined,
x: Number(node.position.x || 0),
y: Number(node.position.y || 0),
width: Number(node.size?.width || 0) || undefined,
height: Number(node.size?.height || 0) || undefined,
isSelected: selectedNodeIdSet.has(node.id),
metadata,
};
};
const toAgentSlotRef = (node: CanvasNode): CanvasAgentSlotRef | null => {
if (String(node.type) !== "agent-slot") return null;
const data = (node.data || {}) as Record<string, any>;
const slotType =
data.slotType === "video" || data.slotType === "ui-screen" ? data.slotType : "image";
const fillPolicy =
data.fillPolicy === "append-version" || data.fillPolicy === "replace-existing"
? data.fillPolicy
: "replace-empty";
return {
nodeId: node.id,
slotType,
label:
typeof data.label === "string" ? String(data.label).slice(0, 128) : undefined,
x: Number(node.position.x || 0),
y: Number(node.position.y || 0),
width: Number(node.size?.width || 0) || 1,
height: Number(node.size?.height || 0) || 1,
aspectRatio:
typeof data.aspectRatio === "string" ? String(data.aspectRatio).slice(0, 32) : "1:1",
isSelected: selectedNodeIdSet.has(node.id),
fillPolicy,
acceptedSources: Array.isArray(data.acceptedSources)
? data.acceptedSources
.filter(
(value: unknown): value is "text" | "image" | "selection" | "annotation" =>
value === "text" ||
value === "image" ||
value === "selection" ||
value === "annotation"
)
.slice(0, 8)
: undefined,
};
};
const toAgentAnnotations = (node: CanvasNode): CanvasAgentAnnotationRef[] => {
const metadata = ((node.data as any)?.metadata || {}) as Record<string, any>;
const annotations = Array.isArray(metadata.annotations) ? metadata.annotations : [];
return annotations
.map((annotation, index): CanvasAgentAnnotationRef | null => {
if (!annotation || typeof annotation !== "object") return null;
const type = String(annotation.type || "text-note");
if (
type !== "text-note" &&
type !== "arrow-note" &&
type !== "box" &&
type !== "freehand" &&
type !== "crop-box"
) {
return null;
}
const bounds =
annotation.bounds && typeof annotation.bounds === "object"
? {
x: Number(annotation.bounds.x || 0),
y: Number(annotation.bounds.y || 0),
width: Number(annotation.bounds.width || 0),
height: Number(annotation.bounds.height || 0),
}
: undefined;
const targetPoint =
annotation.targetPoint && typeof annotation.targetPoint === "object"
? {
x: Number(annotation.targetPoint.x || 0),
y: Number(annotation.targetPoint.y || 0),
}
: undefined;
return {
id: String(annotation.id || `${node.id}-annotation-${index}`).slice(0, 128),
sourceNodeId:
typeof annotation.sourceNodeId === "string"
? String(annotation.sourceNodeId).slice(0, 128)
: node.id,
type: type as CanvasAgentAnnotationRef["type"],
text:
typeof annotation.text === "string"
? String(annotation.text).slice(0, 1000)
: undefined,
bounds,
targetPoint,
color:
typeof annotation.color === "string"
? String(annotation.color).slice(0, 64)
: undefined,
createdAt:
typeof annotation.createdAt === "string"
? String(annotation.createdAt).slice(0, 64)
: undefined,
};
})
.filter((annotation): annotation is CanvasAgentAnnotationRef => Boolean(annotation))
.slice(0, 20);
};
const selectedAgentNodes = selectedCanvasNodes
.map(toAgentNodeRef)
.filter((node): node is CanvasAgentNodeRef => Boolean(node));
const candidateAgentNodes = uniqueCandidateSourceNodes
.map(toAgentNodeRef)
.filter((node): node is CanvasAgentNodeRef => Boolean(node));
const agentSlots = nodes
.map(toAgentSlotRef)
.filter((slot): slot is CanvasAgentSlotRef => Boolean(slot))
.slice(0, 20);
const agentAnnotations = uniqueCandidateSourceNodes
.flatMap(toAgentAnnotations)
.slice(0, 20);
const selectedAgentSlotCount = agentSlots.filter(
(slot) => slot.isSelected || selectedNodeIdSet.has(slot.nodeId)
).length;
const agentFocusPolicy =
selectedAgentSlotCount > 0
? ("slot" as const)
: selectedCanvasNodes.length > 0
? ("selected" as const)
: ("auto" as const);
const canvasAgentContext: CanvasAgentContext = {
version: 1,
source: "canvas",
projectId,
viewport: {
x: Number(viewport.x || 0),
y: Number(viewport.y || 0),
zoom: Number(viewport.zoom || 1),
width: typeof window !== "undefined" ? window.innerWidth : undefined,
height: typeof window !== "undefined" ? window.innerHeight : undefined,
},
selectedNodeIds: selectedCanvasNodes.map((node) => node.id).slice(0, 20),
selectedNodes: selectedAgentNodes,
candidateNodes: candidateAgentNodes,
slots: agentSlots,
annotations: agentAnnotations,
focusPolicy: agentFocusPolicy,
};
const hasAgentContext =
canvasAgentContext.selectedNodes.length > 0 ||
canvasAgentContext.candidateNodes.length > 0 ||
canvasAgentContext.slots.length > 0 ||
canvasAgentContext.annotations.length > 0 ||
Boolean(canvasAgentContext.viewport);
if (contextNodes.length === 0 && !hasAgentContext) {
generationAnchorNodesRef.current = [];
return undefined;
}
return { return {
source: "canvas" as const, source: "canvas" as const,
projectId: isValidChatUuid(canvasId) ? canvasId : undefined, projectId,
selectedNodeIds: selectedContextNodes.map((node) => node.id).slice(0, 20), selectedNodeIds: selectedCanvasNodes.map((node) => node.id).slice(0, 20),
selectedAssetIds, selectedAssetIds,
focusPolicy: selectedContextNodes.length > 0 ? ("selected" as const) : ("auto" as const), focusPolicy: agentFocusPolicy,
canvasImageNodes: contextNodes.map((node) => ({ canvasImageNodes: contextNodes.map((node) => ({
nodeId: node.id, nodeId: node.id,
assetId: isValidChatUuid((node.data as any)?.assetId) assetId: isValidChatUuid((node.data as any)?.assetId)
@ -5398,8 +5611,9 @@ export function CanvasChatPanel({
height: Number(node.size?.height || 0) || undefined, height: Number(node.size?.height || 0) || undefined,
isSelected: selectedIds.has(node.id), isSelected: selectedIds.has(node.id),
})), })),
canvasAgentContext,
}; };
}, [activeComposerMode, canvasId, nodes, selectedImageNodes]); }, [activeComposerMode, canvasId, nodes, selectedImageNodes, selectedNodeIds, viewport]);
const updateAttachmentAtVideoSlot = useCallback( const updateAttachmentAtVideoSlot = useCallback(
(slotIndex: number, attachment: PickedObjectAttachment | null) => { (slotIndex: number, attachment: PickedObjectAttachment | null) => {
@ -6557,7 +6771,7 @@ export function CanvasChatPanel({
? "首屏封面规则:必须做成海报主视觉,单一主体强焦点,产品主体占画面55%-70%,顶部标题区清晰留白,禁止拼贴式说明图。" ? "首屏封面规则:必须做成海报主视觉,单一主体强焦点,产品主体占画面55%-70%,顶部标题区清晰留白,禁止拼贴式说明图。"
: "", : "",
useModelForScene useModelForScene
? `人物规则:本屏涉及人物场景,固定使用提供的模特图同一人物(面部、发型、体型、服装风格保持一致)。` ? `人物规则:本屏涉及人物场景,必须固定使用提供的模特图同一人物;脸型五官、发型发色、体型肤色、穿搭风格保持一致,不得换脸、换人、换发型或改成无关人物。`
: "人物规则:本屏不要出现人物或人体部位,只展示产品与场景元素。", : "人物规则:本屏不要出现人物或人体部位,只展示产品与场景元素。",
screen?.modelReason ? `人物融入依据:${screen.modelReason}` : "", screen?.modelReason ? `人物融入依据:${screen.modelReason}` : "",
bundleShowcase bundleShowcase

View File

@ -395,7 +395,7 @@ type LocalEditRegionInteractionState = {
}; };
type CanvasLocalEditOperationMode = "local_edit" | "scale_adjust"; type CanvasLocalEditOperationMode = "local_edit" | "scale_adjust";
type CanvasScaleAdjustPercent = -30 | -20 | -10 | 10 | 20 | 30; type CanvasScaleAdjustPercent = -30 | -20 | -10 | 10 | 20 | 30 | 50 | 100;
const CANVAS_SCALE_ADJUST_OPTIONS: CanvasScaleAdjustPercent[] = [ const CANVAS_SCALE_ADJUST_OPTIONS: CanvasScaleAdjustPercent[] = [
-30, -30,
@ -404,6 +404,8 @@ const CANVAS_SCALE_ADJUST_OPTIONS: CanvasScaleAdjustPercent[] = [
10, 10,
20, 20,
30, 30,
50,
100,
]; ];
function getCanvasScaleAdjustLabel(scalePercent: CanvasScaleAdjustPercent) { function getCanvasScaleAdjustLabel(scalePercent: CanvasScaleAdjustPercent) {
@ -421,6 +423,7 @@ function getCanvasScaleAdjustRegionPrompt(scalePercent: CanvasScaleAdjustPercent
const scaleVerb = scalePercent > 0 ? "放大" : "缩小"; const scaleVerb = scalePercent > 0 ? "放大" : "缩小";
return [ return [
`对框选范围内的主物体执行等比${actionLabel},调整后主体视觉尺寸必须约为原来的 ${targetLabel},需要肉眼明显看出已经${scaleVerb}。`, `对框选范围内的主物体执行等比${actionLabel},调整后主体视觉尺寸必须约为原来的 ${targetLabel},需要肉眼明显看出已经${scaleVerb}。`,
"框选区域只用于识别要调整的主体,不是放大后的边界;放大时主体可以自然超出原始框选范围,并由周边背景补全衔接。",
"只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。", "只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
scalePercent < 0 scalePercent < 0
? "主体缩小后露出的背景、桌面、墙面、阴影和遮挡关系必须自然补全。" ? "主体缩小后露出的背景、桌面、墙面、阴影和遮挡关系必须自然补全。"
@ -8671,7 +8674,7 @@ export function ImageNode({
)} )}
{/* 处理中遮罩 */} {/* 处理中遮罩 */}
{isProcessing && ( {isProcessing && !isSubmittingLocalEdit && (
<ImageNodeProcessingOverlay <ImageNodeProcessingOverlay
detailProgressLabel={detailProgressLabel || undefined} detailProgressLabel={detailProgressLabel || undefined}
generationProgress={generationProgress} generationProgress={generationProgress}

View File

@ -1,4 +1,5 @@
import type { DBMessage } from "@/lib/db/schema"; import type { DBMessage } from "@/lib/db/schema";
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
export interface CanvasImageNodeContext { export interface CanvasImageNodeContext {
nodeId: string; nodeId: string;
@ -18,8 +19,9 @@ export interface ContextPayload {
projectId?: string; projectId?: string;
selectedNodeIds?: string[]; selectedNodeIds?: string[];
selectedAssetIds?: string[]; selectedAssetIds?: string[];
focusPolicy?: "auto" | "selected" | "latest_result" | "original"; focusPolicy?: "auto" | "selected" | "latest_result" | "original" | "slot";
canvasImageNodes?: CanvasImageNodeContext[]; canvasImageNodes?: CanvasImageNodeContext[];
canvasAgentContext?: CanvasAgentContext;
} }
export interface ResolvedImageRef { export interface ResolvedImageRef {

392
lib/canvas/agent-context.ts Normal file
View File

@ -0,0 +1,392 @@
import type {
CanvasAgentAnnotationRef,
CanvasAgentContext,
CanvasAgentFocusPolicy,
CanvasAgentNodeRef,
CanvasAgentNodeType,
CanvasAgentSlotRef,
CanvasAgentSource,
CanvasViewportContext,
} from "@/types/canvas/agent-protocol";
type LegacyCanvasImageNode = {
nodeId?: unknown;
assetId?: unknown;
url?: unknown;
title?: unknown;
prompt?: unknown;
x?: unknown;
y?: unknown;
width?: unknown;
height?: unknown;
isSelected?: unknown;
};
export type CanvasAgentContextPayloadLike = {
source?: unknown;
projectId?: unknown;
selectedNodeIds?: unknown;
selectedAssetIds?: unknown;
focusPolicy?: unknown;
canvasImageNodes?: unknown;
canvasAgentContext?: unknown;
};
const CANVAS_AGENT_NODE_TYPES = new Set<string>([
"message",
"image",
"video",
"text",
"ui-screen",
"workflow",
"scene-fusion",
"model3d",
"world3d",
"agent-slot",
]);
const CANVAS_AGENT_FOCUS_POLICIES = new Set<string>([
"auto",
"selected",
"latest_result",
"original",
"slot",
]);
function normalizeString(value: unknown, maxLength = 512) {
if (typeof value !== "string") return undefined;
const normalized = value.trim();
return normalized ? normalized.slice(0, maxLength) : undefined;
}
function normalizeStringArray(value: unknown, maxItems: number) {
if (!Array.isArray(value)) return [];
return value
.map((item) => normalizeString(item, 128))
.filter((item): item is string => Boolean(item))
.slice(0, maxItems);
}
function normalizeNumber(value: unknown) {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function normalizeBoolean(value: unknown) {
return typeof value === "boolean" ? value : undefined;
}
function normalizeSource(value: unknown): CanvasAgentSource {
return value === "realtime-canvas" ? "realtime-canvas" : "canvas";
}
function normalizeFocusPolicy(value: unknown): CanvasAgentFocusPolicy {
return CANVAS_AGENT_FOCUS_POLICIES.has(String(value))
? (value as CanvasAgentFocusPolicy)
: "auto";
}
function normalizeNodeType(value: unknown): CanvasAgentNodeType | undefined {
const normalized = normalizeString(value, 64);
if (!normalized || !CANVAS_AGENT_NODE_TYPES.has(normalized)) return undefined;
return normalized as CanvasAgentNodeType;
}
function normalizeViewport(value: unknown): CanvasViewportContext | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const raw = value as Record<string, unknown>;
const x = normalizeNumber(raw.x);
const y = normalizeNumber(raw.y);
const zoom = normalizeNumber(raw.zoom);
if (x === undefined || y === undefined || zoom === undefined) return undefined;
return {
x,
y,
zoom,
width: normalizeNumber(raw.width),
height: normalizeNumber(raw.height),
};
}
function normalizeNodeRef(value: unknown): CanvasAgentNodeRef | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const raw = value as Record<string, unknown>;
const nodeId = normalizeString(raw.nodeId, 128);
const type = normalizeNodeType(raw.type);
const x = normalizeNumber(raw.x);
const y = normalizeNumber(raw.y);
if (!nodeId || !type || x === undefined || y === undefined) return null;
return {
nodeId,
type,
assetId: normalizeString(raw.assetId, 128),
url: normalizeString(raw.url, 2048),
title: normalizeString(raw.title, 256),
prompt: normalizeString(raw.prompt, 2000),
x,
y,
width: normalizeNumber(raw.width),
height: normalizeNumber(raw.height),
isSelected: normalizeBoolean(raw.isSelected),
metadata:
raw.metadata && typeof raw.metadata === "object" && !Array.isArray(raw.metadata)
? (raw.metadata as Record<string, unknown>)
: undefined,
};
}
function normalizeSlotRef(value: unknown): CanvasAgentSlotRef | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const raw = value as Record<string, unknown>;
const nodeId = normalizeString(raw.nodeId, 128);
const slotType = normalizeString(raw.slotType, 32);
const x = normalizeNumber(raw.x);
const y = normalizeNumber(raw.y);
const width = normalizeNumber(raw.width);
const height = normalizeNumber(raw.height);
if (
!nodeId ||
(slotType !== "image" && slotType !== "video" && slotType !== "ui-screen") ||
x === undefined ||
y === undefined ||
width === undefined ||
height === undefined
) {
return null;
}
const fillPolicy = normalizeString(raw.fillPolicy, 32);
return {
nodeId,
slotType,
label: normalizeString(raw.label, 128),
x,
y,
width,
height,
aspectRatio: normalizeString(raw.aspectRatio, 32) || "1:1",
isSelected: normalizeBoolean(raw.isSelected),
fillPolicy:
fillPolicy === "append-version" || fillPolicy === "replace-existing"
? fillPolicy
: "replace-empty",
acceptedSources: Array.isArray(raw.acceptedSources)
? raw.acceptedSources
.map((item) => normalizeString(item, 32))
.filter(
(item): item is "text" | "image" | "selection" | "annotation" =>
item === "text" ||
item === "image" ||
item === "selection" ||
item === "annotation"
)
.slice(0, 8)
: undefined,
};
}
function normalizeAnnotationRef(value: unknown): CanvasAgentAnnotationRef | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const raw = value as Record<string, unknown>;
const id = normalizeString(raw.id, 128);
const sourceNodeId = normalizeString(raw.sourceNodeId, 128);
const type = normalizeString(raw.type, 32);
if (
!id ||
!sourceNodeId ||
(type !== "text-note" &&
type !== "arrow-note" &&
type !== "box" &&
type !== "freehand" &&
type !== "crop-box")
) {
return null;
}
const rawBounds =
raw.bounds && typeof raw.bounds === "object" && !Array.isArray(raw.bounds)
? (raw.bounds as Record<string, unknown>)
: null;
const rawTargetPoint =
raw.targetPoint && typeof raw.targetPoint === "object" && !Array.isArray(raw.targetPoint)
? (raw.targetPoint as Record<string, unknown>)
: null;
return {
id,
sourceNodeId,
type,
text: normalizeString(raw.text, 1000),
bounds: rawBounds
? {
x: normalizeNumber(rawBounds.x) || 0,
y: normalizeNumber(rawBounds.y) || 0,
width: normalizeNumber(rawBounds.width) || 0,
height: normalizeNumber(rawBounds.height) || 0,
}
: undefined,
targetPoint: rawTargetPoint
? {
x: normalizeNumber(rawTargetPoint.x) || 0,
y: normalizeNumber(rawTargetPoint.y) || 0,
}
: undefined,
color: normalizeString(raw.color, 64),
createdAt: normalizeString(raw.createdAt, 64),
};
}
function normalizeNodeRefs(value: unknown, maxItems: number) {
if (!Array.isArray(value)) return [];
return value
.map(normalizeNodeRef)
.filter((node): node is CanvasAgentNodeRef => Boolean(node))
.slice(0, maxItems);
}
function normalizeSlotRefs(value: unknown, maxItems: number) {
if (!Array.isArray(value)) return [];
return value
.map(normalizeSlotRef)
.filter((slot): slot is CanvasAgentSlotRef => Boolean(slot))
.slice(0, maxItems);
}
function normalizeAnnotationRefs(value: unknown, maxItems: number) {
if (!Array.isArray(value)) return [];
return value
.map(normalizeAnnotationRef)
.filter((annotation): annotation is CanvasAgentAnnotationRef => Boolean(annotation))
.slice(0, maxItems);
}
function legacyImageToNodeRef(value: LegacyCanvasImageNode): CanvasAgentNodeRef | null {
const nodeId = normalizeString(value.nodeId, 128);
const url = normalizeString(value.url, 2048);
const x = normalizeNumber(value.x);
const y = normalizeNumber(value.y);
if (!nodeId || !url || x === undefined || y === undefined) return null;
return {
nodeId,
type: "image",
assetId: normalizeString(value.assetId, 128),
url,
title: normalizeString(value.title, 256),
prompt: normalizeString(value.prompt, 2000),
x,
y,
width: normalizeNumber(value.width),
height: normalizeNumber(value.height),
isSelected: normalizeBoolean(value.isSelected),
};
}
function normalizeLegacyImageNodes(value: unknown, maxItems: number) {
if (!Array.isArray(value)) return [];
return value
.map((item) => (item && typeof item === "object" ? legacyImageToNodeRef(item) : null))
.filter((node): node is CanvasAgentNodeRef => Boolean(node))
.slice(0, maxItems);
}
function hasAgentContextValue(context: CanvasAgentContext) {
return (
context.selectedNodes.length > 0 ||
context.candidateNodes.length > 0 ||
context.slots.length > 0 ||
context.annotations.length > 0 ||
Boolean(context.viewport)
);
}
function normalizeExplicitCanvasAgentContext(
value: unknown,
fallback: CanvasAgentContextPayloadLike
): CanvasAgentContext | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const raw = value as Record<string, unknown>;
const selectedNodeIds = normalizeStringArray(raw.selectedNodeIds, 20);
const selectedNodes = normalizeNodeRefs(raw.selectedNodes, 20);
const candidateNodes = normalizeNodeRefs(raw.candidateNodes, 30);
const slots = normalizeSlotRefs(raw.slots, 20);
const annotations = normalizeAnnotationRefs(raw.annotations, 20);
const context: CanvasAgentContext = {
version: 1,
source: normalizeSource(raw.source || fallback.source),
projectId: normalizeString(raw.projectId || fallback.projectId, 128),
canvasRevision: normalizeString(raw.canvasRevision, 128),
canvasUpdatedAt: normalizeString(raw.canvasUpdatedAt, 128),
viewport: normalizeViewport(raw.viewport),
selectedNodeIds:
selectedNodeIds.length > 0
? selectedNodeIds
: selectedNodes.map((node) => node.nodeId).slice(0, 20),
selectedNodes,
candidateNodes,
slots,
annotations,
focusPolicy: normalizeFocusPolicy(raw.focusPolicy || fallback.focusPolicy),
};
return hasAgentContextValue(context) ? context : null;
}
export function normalizeCanvasAgentContext(
payload?: CanvasAgentContextPayloadLike | null
): CanvasAgentContext | null {
if (!payload) return null;
const explicit = normalizeExplicitCanvasAgentContext(
payload.canvasAgentContext,
payload
);
if (explicit) return explicit;
const candidateNodes = normalizeLegacyImageNodes(payload.canvasImageNodes, 20);
const selectedNodeIdSet = new Set(normalizeStringArray(payload.selectedNodeIds, 20));
const selectedAssetIdSet = new Set(normalizeStringArray(payload.selectedAssetIds, 20));
const selectedNodes = candidateNodes.filter((node) => {
if (node.isSelected) return true;
if (selectedNodeIdSet.has(node.nodeId)) return true;
if (node.assetId && selectedAssetIdSet.has(node.assetId)) return true;
return false;
});
const context: CanvasAgentContext = {
version: 1,
source: normalizeSource(payload.source),
projectId: normalizeString(payload.projectId, 128),
selectedNodeIds:
selectedNodes.length > 0
? selectedNodes.map((node) => node.nodeId).slice(0, 20)
: Array.from(selectedNodeIdSet).slice(0, 20),
selectedNodes,
candidateNodes,
slots: [],
annotations: [],
focusPolicy: normalizeFocusPolicy(payload.focusPolicy),
};
return hasAgentContextValue(context) ? context : null;
}
export function getSelectedSlots(context?: CanvasAgentContext | null) {
return (context?.slots || []).filter(
(slot) => slot.isSelected || context?.selectedNodeIds.includes(slot.nodeId)
);
}
export function getSelectedMediaNodes(context?: CanvasAgentContext | null) {
return (context?.selectedNodes || []).filter(
(node) => node.type === "image" || node.type === "video"
);
}
export function getBestAnchorNode(context?: CanvasAgentContext | null) {
return (
getSelectedMediaNodes(context)[0] ||
context?.selectedNodes[0] ||
context?.candidateNodes[0] ||
null
);
}
export function getCanvasAgentFocusPolicy(context?: CanvasAgentContext | null) {
return context?.focusPolicy || "auto";
}

View File

@ -340,7 +340,7 @@ export const imageGenerationService = {
imageSize?: string; imageSize?: string;
aspectRatio?: string; aspectRatio?: string;
operation?: "scale-adjust"; operation?: "scale-adjust";
scalePercent?: -30 | -20 | -10 | 10 | 20 | 30; scalePercent?: -30 | -20 | -10 | 10 | 20 | 30 | 50 | 100;
}): Promise< }): Promise<
ApiResponse<{ ApiResponse<{
annotatedReferenceUrl: string; annotatedReferenceUrl: string;

View File

@ -6,6 +6,7 @@ import {
normalizeMediaUrl, normalizeMediaUrl,
stripMediaMarkup, stripMediaMarkup,
} from "@/components/canvas/chat-panel/canvas-chat-media"; } from "@/components/canvas/chat-panel/canvas-chat-media";
import { normalizeCanvasAgentContext } from "@/lib/canvas/agent-context";
import { import {
buildDetailResultMarkdown, buildDetailResultMarkdown,
buildDetailReviewAppendMarkdown, buildDetailReviewAppendMarkdown,
@ -49,6 +50,81 @@ import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canv
import type { ChatMessage } from "@/lib/types"; import type { ChatMessage } from "@/lib/types";
test.describe("canvas chat pure helpers", () => { test.describe("canvas chat pure helpers", () => {
test("normalizes slot-only canvas agent context without image nodes", () => {
const context = normalizeCanvasAgentContext({
source: "canvas",
projectId: "11111111-1111-4111-8111-111111111111",
selectedNodeIds: ["slot-1"],
canvasImageNodes: [],
canvasAgentContext: {
version: 1,
source: "canvas",
projectId: "11111111-1111-4111-8111-111111111111",
selectedNodeIds: ["slot-1"],
selectedNodes: [
{
nodeId: "slot-1",
type: "agent-slot",
x: 120,
y: 80,
width: 320,
height: 240,
isSelected: true,
},
],
candidateNodes: [],
slots: [
{
nodeId: "slot-1",
slotType: "image",
x: 120,
y: 80,
width: 320,
height: 240,
aspectRatio: "4:3",
isSelected: true,
fillPolicy: "replace-empty",
},
],
annotations: [],
viewport: { x: 0, y: 0, zoom: 1 },
focusPolicy: "slot",
},
});
expect(context?.selectedNodeIds).toEqual(["slot-1"]);
expect(context?.slots).toHaveLength(1);
expect(context?.candidateNodes).toEqual([]);
expect(context?.focusPolicy).toBe("slot");
});
test("builds canvas agent context from legacy canvas image nodes", () => {
const context = normalizeCanvasAgentContext({
source: "canvas",
selectedNodeIds: ["image-1"],
canvasImageNodes: [
{
nodeId: "image-1",
url: "https://cdn.example.com/image.png",
title: "主图",
x: 10,
y: 20,
width: 300,
height: 300,
isSelected: true,
},
],
});
expect(context?.selectedNodes).toHaveLength(1);
expect(context?.candidateNodes[0]).toMatchObject({
nodeId: "image-1",
type: "image",
url: "https://cdn.example.com/image.png",
isSelected: true,
});
});
test("normalizes media urls without query strings or trailing slashes", () => { test("normalizes media urls without query strings or trailing slashes", () => {
expect(normalizeMediaUrl(" HTTPS://EXAMPLE.COM/assets/image.png?sig=secret#preview ")).toBe( expect(normalizeMediaUrl(" HTTPS://EXAMPLE.COM/assets/image.png?sig=secret#preview ")).toBe(
"https://example.com/assets/image.png" "https://example.com/assets/image.png"

View File

@ -0,0 +1,161 @@
import type { NodeType } from "@/types/canvas";
export type CanvasAgentSource = "canvas" | "realtime-canvas";
export type CanvasAgentFocusPolicy =
| "auto"
| "selected"
| "latest_result"
| "original"
| "slot";
export interface CanvasViewportContext {
x: number;
y: number;
zoom: number;
width?: number;
height?: number;
}
export type CanvasAgentNodeType = NodeType | "agent-slot";
export interface CanvasAgentNodeRef {
nodeId: string;
type: CanvasAgentNodeType;
assetId?: string;
url?: string;
title?: string;
prompt?: string;
x: number;
y: number;
width?: number;
height?: number;
isSelected?: boolean;
metadata?: {
sourceNodeId?: string;
revisionOf?: string;
generatedByAgent?: boolean;
model?: string;
taskId?: string;
role?: string;
[key: string]: unknown;
};
}
export interface CanvasAgentSlotRef {
nodeId: string;
slotType: "image" | "video" | "ui-screen";
label?: string;
x: number;
y: number;
width: number;
height: number;
aspectRatio: string;
isSelected?: boolean;
fillPolicy: "replace-empty" | "append-version" | "replace-existing";
acceptedSources?: Array<"text" | "image" | "selection" | "annotation">;
}
export interface CanvasAgentAnnotationRef {
id: string;
sourceNodeId: string;
type: "text-note" | "arrow-note" | "box" | "freehand" | "crop-box";
text?: string;
bounds?: {
x: number;
y: number;
width: number;
height: number;
};
targetPoint?: {
x: number;
y: number;
};
color?: string;
createdAt?: string;
}
export interface CanvasAgentContext {
version: 1;
source: CanvasAgentSource;
projectId?: string;
canvasRevision?: string;
canvasUpdatedAt?: string;
viewport?: CanvasViewportContext;
selectedNodeIds: string[];
selectedNodes: CanvasAgentNodeRef[];
candidateNodes: CanvasAgentNodeRef[];
slots: CanvasAgentSlotRef[];
annotations: CanvasAgentAnnotationRef[];
focusPolicy: CanvasAgentFocusPolicy;
}
export type CanvasOperationKind =
| "fill_slot"
| "insert_beside"
| "append_revision"
| "replace_node_media"
| "add_annotation"
| "link_nodes"
| "move_nodes"
| "update_text";
export interface CanvasMediaItem {
mediaItemId: string;
type: "image" | "video";
url: string;
assetId?: string;
width?: number;
height?: number;
title?: string;
prompt?: string;
sourceNodeIds?: string[];
metadata?: Record<string, unknown>;
}
export interface CanvasOperationPlan {
version: 1;
operationId: string;
operation: CanvasOperationKind;
projectId: string;
baseCanvasRevision?: string;
baseCanvasUpdatedAt?: string;
sourceNodeIds?: string[];
targetSlotId?: string;
anchorNodeId?: string;
placement?: "right" | "left" | "below" | "above" | "viewport-center";
placementStrategy?: "single" | "row" | "grid" | "overlay" | "beside_each_source";
preserveOriginal?: boolean;
media?: Omit<CanvasMediaItem, "mediaItemId">;
mediaItems?: CanvasMediaItem[];
metadata?: Record<string, unknown>;
}
export type CanvasViewOperationKind =
| "focus_node"
| "focus_nodes"
| "focus_selection"
| "focus_latest_result"
| "fit_all"
| "zoom_in"
| "zoom_out"
| "zoom_to"
| "pan_to";
export interface CanvasViewOperationPlan {
version: 1;
viewOperationId: string;
operation: CanvasViewOperationKind;
projectId?: string;
nodeIds?: string[];
anchorNodeId?: string;
zoom?: number;
padding?: number;
durationMs?: number;
persist?: false;
center?: {
x: number;
y: number;
};
reason?: string;
}