fix: improve detail replacement generation controls
This commit is contained in:
parent
c528e22680
commit
c2ef33753a
@ -1286,6 +1286,28 @@ function applyDetailReplacementScenePlanGuards(
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1723,19 +1745,31 @@ export function normalizeAgentModeReferences(
|
||||
): AgentReplacementModePayload["references"] {
|
||||
if (!payload?.references?.length) return [];
|
||||
return payload.references
|
||||
.map((item) => ({
|
||||
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,
|
||||
}))
|
||||
.map((item) => {
|
||||
const rerunReferenceImages = Array.isArray(item.rerunReferenceImages)
|
||||
? item.rerunReferenceImages
|
||||
.map((image) => normalizeAgentModeSourceImage(image))
|
||||
.filter(
|
||||
(image): image is NonNullable<ReturnType<typeof normalizeAgentModeSourceImage>> =>
|
||||
Boolean(image)
|
||||
)
|
||||
.slice(0, 6)
|
||||
: [];
|
||||
return {
|
||||
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))
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder);
|
||||
}
|
||||
@ -2210,7 +2244,7 @@ export async function analyzeReplacementExecutionPlan(params: {
|
||||
isDetailReplacement
|
||||
? "用户上传图可能是产品、材质、板材、原材料或细节参考,不要因为它不是完整商品主体就排除;后续应根据上传图外观与参考图里的材质描述共同判断处理方向。"
|
||||
: "",
|
||||
"这张图片是唯一的详情参考图底板,它决定当前这一屏的版式、文字排版、UI图标、按钮、机位、构图、留白和信息层级。",
|
||||
"这张图片是唯一的详情参考图底板,它决定当前这一屏的版式、文字排版、UI图标、按钮、机位、构图、留白和信息层级;如果画面里有产品主体/展示区,它也决定产品的拍摄角度、朝向、透视、可见面数量、占比和标注指向。",
|
||||
isProductOnlyDetailReplacement
|
||||
? "这张参考图要求最大程度原样保留。后续生图只允许替换其中的产品主体/产品展示区域,背景、文案、按钮、图标、分割线、品牌标识、留白、装饰和光影氛围都必须保留。"
|
||||
: isDetailReplacement
|
||||
@ -2248,12 +2282,12 @@ export async function analyzeReplacementExecutionPlan(params: {
|
||||
? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/围坐/聚餐/模特互动/拿手机/用餐/试坐/承重动作时必须 preserve_relation;如果文字明确写了“多人围坐、聚餐、合照、用餐、单侧承重、横向受力测试、拿手机”等动作,generationPrompt 必须把这个动作写成硬约束;如果只提供 1 张模特图但参考图多人,仍要保留参考图人数和关系,额外人物按参考图生成相近人群。"
|
||||
: "",
|
||||
isDetailReplacement
|
||||
? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary。"
|
||||
? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary;cameraSummary 必须写出产品主体的视角特征,例如正侧视/三分之四/俯视/平视、倾斜方向、可见面数量、主体朝向和透视消失方向。"
|
||||
: "",
|
||||
isProductOnlyDetailReplacement
|
||||
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。"
|
||||
: isDetailReplacement
|
||||
? "8. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令。若 promptPackPolicy=forbid,必须明确“不使用前置词包,参考图语义优先”;若是动作海报,必须写清人物数量、围坐/试坐/承重等动作、产品形状和版式必须照参考图,同时写清“前置词包必须完整重建原照片内容区里的背景/空间/软装/灯光/产品场景,不能原图复刻”;若是产品尺寸页,必须写清白底抠图、尺寸线、单位和数字标注,不允许室内场景;若 promptPackPolicy=allow/soft 且 peoplePolicy=preserve_relation,明确“保留人物数量/动作/关系与文字层级,同时使用前置词包重建背景/产品场景”。"
|
||||
? "8. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令。若 promptPackPolicy=forbid,必须明确“不使用前置词包,参考图语义优先”;若是动作海报,必须写清人物数量、围坐/试坐/承重等动作、产品形状和版式必须照参考图,同时写清“前置词包必须完整重建原照片内容区里的背景/空间/软装/灯光/产品场景,不能原图复刻”;若是产品尺寸页,必须写清白底抠图、尺寸线、单位和数字标注,不允许室内场景;若 promptPackPolicy=allow/soft 且 peoplePolicy=preserve_relation,明确“保留人物数量/动作/关系与文字层级,同时使用前置词包重建背景/产品场景”。只要参考图有产品主体,generationPrompt 必须明确产品拍摄角度、朝向、透视和标注指向以参考图为准。"
|
||||
: "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,描述这一屏需要的场景、机位、主体位置、周边环境重建方向以及必须保留的内容。",
|
||||
isProductOnlyDetailReplacement
|
||||
? "5. sceneSummary、styleSummary 和 generationPrompt 必须强调‘只替换产品,不动其他内容’,不要引导重建场景。"
|
||||
@ -2421,6 +2455,7 @@ export function buildAgentReplacementPrompt(params: {
|
||||
hasModelImage: boolean;
|
||||
productImageCount: number;
|
||||
originalProductImageCount: number;
|
||||
rerunReferenceCount?: number;
|
||||
templateReferenceCount: number;
|
||||
pairedProductIndex?: number;
|
||||
scenePlan?: ReplacementScenePlan | null;
|
||||
@ -2455,14 +2490,14 @@ export function buildAgentReplacementPrompt(params: {
|
||||
? `图片${nextImageIndex}`
|
||||
: `图片${nextImageIndex}-${nextImageIndex + params.productImageCount - 1}`;
|
||||
imageOrderLines.push(
|
||||
`${productRangeLabel}:用户上传的产品/材质/板材/原材料或细节参考图,必须综合理解外观、材质、颜色、纹理和结构,不可只看第一张,也不要因为它不是完整商品主体就忽略。`
|
||||
`${productRangeLabel}:用户上传的产品/材质/板材/原材料或细节参考图,必须综合理解外观、材质、颜色、纹理和结构,不可只看第一张,也不要因为它不是完整商品主体就忽略;如结果中需要生成商品主体,商品拍摄角度、朝向、透视和画面占比必须以最后一张参考图为准,不得直接沿用这些上传图的拍摄角度。`
|
||||
);
|
||||
nextImageIndex += params.productImageCount;
|
||||
}
|
||||
|
||||
if (params.hasModelImage) {
|
||||
imageOrderLines.push(
|
||||
`图片${nextImageIndex}:模特图,仅在当前参考图本身包含人物展示时参考;如果参考图是多人,只把这张模特图作为主人物身份/气质参考,其余人物按参考图的人数、关系和风格补齐。`
|
||||
`图片${nextImageIndex}:用户提供的固定模特图,是人物身份锁定图;当前屏需要人物时,必须复用这张图里的同一人物(脸型五官、发型发色、体型肤色、穿搭风格保持一致)。如果参考图是多人,只把这张模特图作为主人物身份参考,其余人物按参考图的人数、关系和风格补齐。`
|
||||
);
|
||||
nextImageIndex += 1;
|
||||
}
|
||||
@ -2478,8 +2513,20 @@ export function buildAgentReplacementPrompt(params: {
|
||||
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(
|
||||
`图片${nextImageIndex}:当前参考图底板(唯一版式基准图),必须保留它的文字排版、信息区、分割线、留白、机位和整体结构。`
|
||||
`图片${nextImageIndex}:当前参考图底板(唯一版式基准图),必须保留它的文字排版、信息区、分割线、留白、机位和整体结构;如果它包含产品主体/展示区,它同时决定产品姿态、朝向、透视、可见面数量、占比和标注指向。`
|
||||
);
|
||||
const marketplaceBrandRemovalLines =
|
||||
params.modeType === "detail_replacement" && params.marketplaceProductDetailReference
|
||||
@ -2489,6 +2536,12 @@ export function buildAgentReplacementPrompt(params: {
|
||||
"如果参考图标题、标签或画面中出现可识别的外部品牌字样,不要保留该品牌名,不要换成相似字,不要生成伪 Logo;需要文字时只保留非品牌卖点语义或改成无品牌通用描述。",
|
||||
]
|
||||
: [];
|
||||
const productAngleLockLines = [
|
||||
"【产品角度锁定规则】最终商品/主体的拍摄角度以最后一张参考图为准,而不是以前面的产品输入图为准;前面的产品图只提供款式、材质、颜色、结构和细节。",
|
||||
"必须逐项对齐最后一张参考图中的产品姿态:朝向、正侧视/三分之四/俯视/平视关系、桌面或面板倾斜方向、可见侧面数量、支架/腿部/把手/转轴位置、主体外轮廓、占比、重心和透视消失方向。",
|
||||
"如果最后一张参考图的产品是侧视或低视角,不允许改成俯视三分之四;如果是俯视三分之四,不允许改成正侧视;不允许镜像、旋转、翻转或把产品重画成另一个角度。",
|
||||
"若参考图含功能标签、箭头、尺寸线、编号或引导线,它们指向的实际结构部位必须保持一致,不得因产品转角改变而让标签指向错误位置。",
|
||||
];
|
||||
const normalizedBasePrompt = String(params.basePrompt || "").trim();
|
||||
const promptPackInstructionLines =
|
||||
params.modeType === "detail_replacement" &&
|
||||
@ -2555,7 +2608,7 @@ export function buildAgentReplacementPrompt(params: {
|
||||
params.scenePlan.useModelImage
|
||||
? params.scenePlan.modelImageReason
|
||||
? `本屏允许使用模特图:${params.scenePlan.modelImageReason}`
|
||||
: "本屏允许使用模特图,但仅在参考图当前这一屏确实存在人物展示需求时使用。"
|
||||
: "本屏允许使用模特图;只要最终画面出现人物,就必须使用用户提供的同一模特身份。"
|
||||
: params.hasModelImage
|
||||
? params.scenePlan.modelImageReason
|
||||
? `本屏禁止使用模特图:${params.scenePlan.modelImageReason}`
|
||||
@ -2588,6 +2641,7 @@ export function buildAgentReplacementPrompt(params: {
|
||||
...promptPackInstructionLines,
|
||||
...scenePlanLines,
|
||||
...marketplaceBrandRemovalLines,
|
||||
...productAngleLockLines,
|
||||
"你现在做的是‘仅替换产品’,不是重建场景,也不是重新设计整张详情图。",
|
||||
"忽略用户补充提示词里的场景风格、氛围、空间改造要求;这一次只服务于产品主体替换。",
|
||||
"最后一张参考图是唯一底板,除产品主体/产品展示区外,其余所有内容都必须保持不变。",
|
||||
@ -2616,6 +2670,7 @@ export function buildAgentReplacementPrompt(params: {
|
||||
...promptPackInstructionLines,
|
||||
...scenePlanLines,
|
||||
...marketplaceBrandRemovalLines,
|
||||
...productAngleLockLines,
|
||||
"你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。",
|
||||
"【版式锁层规则】把最后一张参考图当成 PSD 模板:文字、标题、编号、卖点标签、按钮、图标、白底/浅色底、留白、分割线、原有圆角照片区域和原有裁切范围都属于锁定版式层,不允许移动、缩放、重排或重新设计;不要把这些版式边界画成新的白色线框、描边或卡片。",
|
||||
isReferencePosterActionPlan
|
||||
@ -2730,6 +2785,7 @@ export function buildAgentReplacementPrompt(params: {
|
||||
"【图片顺序规则】",
|
||||
...imageOrderLines,
|
||||
...scenePlanLines,
|
||||
...productAngleLockLines,
|
||||
params.pairedProductIndex !== undefined
|
||||
? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。`
|
||||
: "请使用输入商品图作为本张参考图的一对一替换主体。",
|
||||
@ -2754,7 +2810,7 @@ export function buildAgentReplacementPrompt(params: {
|
||||
|
||||
if (params.hasModelImage) {
|
||||
instructionLines.push(
|
||||
"如有模特图,请在需要人物展示时保持模特体态、穿搭和出镜关系一致;多人参考图要保留参考图人数和互动关系,只把上传模特作为主人物参考,其余人物按参考图风格补齐。"
|
||||
"固定模特身份硬约束:如果本屏最终画面出现人物,必须使用上传模特图中的同一人物;脸型五官、发型发色、体型肤色和穿搭风格必须保持一致,不得换脸、换人、换发型或改成无关人物。多人参考图要保留参考图人数和互动关系,只把上传模特作为主人物参考,其余人物按参考图风格补齐。"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -45,6 +45,7 @@ import {
|
||||
import { buildAgentSceneImageRolePrompt } from "@/lib/agents/scene-image-role-prompt";
|
||||
import { optimizeAgentSceneWorkflowPrompt } from "@/lib/agents/scene-workflow-prompt-optimizer";
|
||||
import { getEnabledTools } from "@/lib/ai/tools-registry";
|
||||
import { normalizeCanvasAgentContext } from "@/lib/canvas/agent-context";
|
||||
import { isProductionEnvironment } from "@/lib/constants";
|
||||
import {
|
||||
createStreamId,
|
||||
@ -343,6 +344,8 @@ const chatApiLogger = createLogger("chat.route");
|
||||
|
||||
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
|
||||
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
|
||||
const DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN =
|
||||
/自拍|合照|聚会|聚餐|围坐|用餐|试坐|承重|受力|拿手机|手持|人物场景|人物关系|真人演示|人物展示|人像展示|固定模特|用户模特|same\s+model|fixed\s+model|person|people|human/i;
|
||||
|
||||
function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) {
|
||||
const normalizedMarkdown = String(markdown || "").trim();
|
||||
@ -365,6 +368,35 @@ function getPublicImageGenerationErrorText(error: unknown) {
|
||||
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(
|
||||
1,
|
||||
@ -721,6 +753,20 @@ export async function POST(request: Request) {
|
||||
.join(" ")
|
||||
.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();
|
||||
for (const [key, startedAt] of inFlightChatRequests.entries()) {
|
||||
@ -3465,10 +3511,15 @@ export async function POST(request: Request) {
|
||||
sourceProductImages: productInputImageUrls.length,
|
||||
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(
|
||||
new Set([
|
||||
...productInputImageUrls,
|
||||
...(modelInputImageUrl ? [modelInputImageUrl] : []),
|
||||
...rerunReferenceImageUrls,
|
||||
reference.imageUrl,
|
||||
]),
|
||||
);
|
||||
@ -3494,7 +3545,9 @@ export async function POST(request: Request) {
|
||||
const shouldIncludeModelImage =
|
||||
detailReplacementStrategy !== "product_only" &&
|
||||
Boolean(modelInputImageUrl) &&
|
||||
(replacementExecutionPlan.scenePlan.useModelImage ||
|
||||
(replacementScenePlanShouldIncludeModelImage(
|
||||
replacementExecutionPlan.scenePlan,
|
||||
) ||
|
||||
selectedProductContextImageUrls.length === 0);
|
||||
const sourceImageUrls = Array.from(
|
||||
new Set([
|
||||
@ -3502,6 +3555,7 @@ export async function POST(request: Request) {
|
||||
...(shouldIncludeModelImage && modelInputImageUrl
|
||||
? [modelInputImageUrl]
|
||||
: []),
|
||||
...rerunReferenceImageUrls,
|
||||
reference.imageUrl,
|
||||
]),
|
||||
);
|
||||
@ -3515,6 +3569,7 @@ export async function POST(request: Request) {
|
||||
selectedProductImages: selectedProductContextImageUrls.length,
|
||||
sourceProductImages: productInputImageUrls.length,
|
||||
includeModelImage: shouldIncludeModelImage,
|
||||
rerunReferenceImages: rerunReferenceImageUrls.length,
|
||||
sourceImageCount: sourceImageUrls.length,
|
||||
whiteLayout: replacementExecutionPlan.scenePlan.whiteBackgroundLikely,
|
||||
copyCoupled: replacementExecutionPlan.scenePlan.copyImageCoupled,
|
||||
@ -3532,6 +3587,7 @@ export async function POST(request: Request) {
|
||||
hasModelImage: shouldIncludeModelImage,
|
||||
productImageCount: selectedProductContextImageUrls.length,
|
||||
originalProductImageCount: productInputImageUrls.length,
|
||||
rerunReferenceCount: rerunReferenceImageUrls.length,
|
||||
templateReferenceCount: 0,
|
||||
scenePlan: replacementExecutionPlan.scenePlan,
|
||||
referenceGeometry,
|
||||
@ -4246,10 +4302,15 @@ export async function POST(request: Request) {
|
||||
promptPackPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
|
||||
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(
|
||||
new Set([
|
||||
...sharedUploadedImageUrls,
|
||||
...replacementTemplateReferenceImageUrls,
|
||||
...rerunReferenceImageUrls,
|
||||
reference.imageUrl,
|
||||
]),
|
||||
);
|
||||
@ -4309,7 +4370,9 @@ export async function POST(request: Request) {
|
||||
!(isDetailReplacementPreset &&
|
||||
detailReplacementStrategy === "product_only") &&
|
||||
Boolean(modelInputImageUrl) &&
|
||||
(replacementExecutionPlan.scenePlan.useModelImage ||
|
||||
(replacementScenePlanShouldIncludeModelImage(
|
||||
replacementExecutionPlan.scenePlan,
|
||||
) ||
|
||||
(isDetailReplacementPreset &&
|
||||
selectedProductContextImageUrls.length === 0));
|
||||
const sourceImageUrls = Array.from(
|
||||
@ -4320,6 +4383,7 @@ export async function POST(request: Request) {
|
||||
? [modelInputImageUrl]
|
||||
: []),
|
||||
...replacementTemplateReferenceImageUrls,
|
||||
...rerunReferenceImageUrls,
|
||||
reference.imageUrl,
|
||||
].filter(Boolean),
|
||||
),
|
||||
@ -4343,6 +4407,7 @@ export async function POST(request: Request) {
|
||||
hasModelImage: shouldIncludeModelImage,
|
||||
productImageCount: selectedProductContextImageUrls.length,
|
||||
originalProductImageCount: productInputImageUrls.length,
|
||||
rerunReferenceCount: rerunReferenceImageUrls.length,
|
||||
templateReferenceCount: replacementTemplateReferenceImageUrls.length,
|
||||
pairedProductIndex,
|
||||
scenePlan: replacementExecutionPlan.scenePlan,
|
||||
@ -4368,6 +4433,7 @@ export async function POST(request: Request) {
|
||||
originalProductImages: productInputImageUrls.length,
|
||||
hasModelImage: shouldIncludeModelImage,
|
||||
templateReferenceImages: replacementTemplateReferenceImageUrls.length,
|
||||
rerunReferenceImages: rerunReferenceImageUrls.length,
|
||||
finalReferenceImageIndex: sourceImageUrls.length,
|
||||
},
|
||||
});
|
||||
@ -4475,6 +4541,9 @@ export async function POST(request: Request) {
|
||||
new Set([
|
||||
...sharedUploadedImageUrls,
|
||||
...replacementTemplateReferenceImageUrls,
|
||||
...replacementReferences.flatMap((item) =>
|
||||
(item.rerunReferenceImages || []).map((image) => image.url)
|
||||
),
|
||||
...replacementReferences.map((item) => item.imageUrl),
|
||||
]),
|
||||
),
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
|
||||
|
||||
const textPartSchema = z.object({
|
||||
type: z.enum(["text"]),
|
||||
@ -62,7 +63,8 @@ const contextPayloadSchema = z
|
||||
projectId: z.string().uuid().optional(),
|
||||
selectedNodeIds: z.array(z.string().min(1).max(128)).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
|
||||
.array(
|
||||
z.object({
|
||||
@ -83,6 +85,13 @@ const contextPayloadSchema = z
|
||||
})
|
||||
.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({
|
||||
id: z.string().min(1).max(128),
|
||||
label: z.string().min(1).max(255),
|
||||
@ -91,13 +100,7 @@ const agentModeReferenceSchema = z.object({
|
||||
sortOrder: z.number().min(0).max(999).optional(),
|
||||
assignedProductIndex: z.number().min(0).max(9).nullable().optional(),
|
||||
passthrough: z.boolean().nullable().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(),
|
||||
rerunReferenceImages: z.array(agentModeSourceImageSchema).max(6).optional(),
|
||||
});
|
||||
|
||||
const agentModePayloadSchema = z
|
||||
|
||||
@ -49,6 +49,10 @@ const DETAIL_PLAN_INLINE_RETRIES = 1;
|
||||
const DETAIL_PLAN_MAIN_TIMEOUT_MS = 45000;
|
||||
const DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS = 10000;
|
||||
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 {
|
||||
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 = {
|
||||
passed: boolean;
|
||||
designDirectorScore: number;
|
||||
@ -1230,9 +1305,13 @@ function buildFallbackPlan(params: {
|
||||
...params,
|
||||
globalStyle,
|
||||
});
|
||||
const screens = enforceCoreFocusOnScreens({
|
||||
screens: fallbackScreens,
|
||||
coreFocus: params.coreFocus,
|
||||
const screens = normalizeDetailModelUsage({
|
||||
screens: enforceCoreFocusOnScreens({
|
||||
screens: fallbackScreens,
|
||||
coreFocus: params.coreFocus,
|
||||
language,
|
||||
}),
|
||||
hasModelImage: params.hasModelImage,
|
||||
language,
|
||||
});
|
||||
const qualityChecks = evaluatePlanQuality({
|
||||
@ -2181,27 +2260,28 @@ export async function POST(request: Request) {
|
||||
dslV2Screens?: unknown;
|
||||
}>(text || "");
|
||||
const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle);
|
||||
const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language).map((item) =>
|
||||
hasModelImage
|
||||
? item
|
||||
: {
|
||||
...item,
|
||||
useModel: false,
|
||||
modelReason: "未提供模特图,当前分镜不使用人物。",
|
||||
}
|
||||
);
|
||||
const normalizedScreens = rawScreens.map((item) =>
|
||||
const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language);
|
||||
const normalizedScreensWithoutBundles = rawScreens.map((item) =>
|
||||
hasBundleImages
|
||||
? item
|
||||
: {
|
||||
...item,
|
||||
bundleShowcase: false,
|
||||
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
|
||||
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
|
||||
}
|
||||
);
|
||||
const coreFocusedScreens = enforceCoreFocusOnScreens({
|
||||
screens: normalizedScreens,
|
||||
coreFocus,
|
||||
const normalizedScreens = normalizeDetailModelUsage({
|
||||
screens: normalizedScreensWithoutBundles,
|
||||
hasModelImage,
|
||||
language,
|
||||
});
|
||||
const coreFocusedScreens = normalizeDetailModelUsage({
|
||||
screens: enforceCoreFocusOnScreens({
|
||||
screens: normalizedScreens,
|
||||
coreFocus,
|
||||
language,
|
||||
}),
|
||||
hasModelImage,
|
||||
language,
|
||||
});
|
||||
const qualityChecksInitial = evaluatePlanQuality({
|
||||
@ -2225,7 +2305,7 @@ export async function POST(request: Request) {
|
||||
hasBundleImages,
|
||||
});
|
||||
|
||||
const preflightScreensRaw = preflight.candidateScreens.map((item) =>
|
||||
const preflightScreensWithoutBundles = preflight.candidateScreens.map((item) =>
|
||||
hasBundleImages
|
||||
? item
|
||||
: {
|
||||
@ -2234,9 +2314,18 @@ export async function POST(request: Request) {
|
||||
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
|
||||
}
|
||||
);
|
||||
const preflightScreens = enforceCoreFocusOnScreens({
|
||||
screens: preflightScreensRaw,
|
||||
coreFocus,
|
||||
const preflightScreensRaw = normalizeDetailModelUsage({
|
||||
screens: preflightScreensWithoutBundles,
|
||||
hasModelImage,
|
||||
language,
|
||||
});
|
||||
const preflightScreens = normalizeDetailModelUsage({
|
||||
screens: enforceCoreFocusOnScreens({
|
||||
screens: preflightScreensRaw,
|
||||
coreFocus,
|
||||
language,
|
||||
}),
|
||||
hasModelImage,
|
||||
language,
|
||||
});
|
||||
const qualityChecksAfterPreflight = evaluatePlanQuality({
|
||||
|
||||
@ -87,7 +87,7 @@ const SCALE_ADJUST_CROP_MAX_EDGE = 1536;
|
||||
const MIN_REGION_EDGE_PX = 24;
|
||||
const SCALE_ADJUST_OPERATION = "scale-adjust";
|
||||
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 ScaleAdjustCropReference = {
|
||||
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(
|
||||
regions: NormalizedLocalEditRegion[],
|
||||
scalePercent: ScaleAdjustPercent
|
||||
) {
|
||||
return regions.flatMap((region) => [
|
||||
region.rect,
|
||||
scaleRectAroundCenter(region.rect, scalePercent),
|
||||
]);
|
||||
return regions.flatMap((region) => {
|
||||
const targetRect = scaleRectAroundCenter(region.rect, scalePercent);
|
||||
const workspaceRect = getScaleAdjustWorkspaceRect(
|
||||
region.rect,
|
||||
targetRect,
|
||||
scalePercent
|
||||
);
|
||||
return scalePercent > 0
|
||||
? [region.rect, targetRect, workspaceRect]
|
||||
: [region.rect, targetRect];
|
||||
});
|
||||
}
|
||||
|
||||
function expandPixelRectForCrop(params: {
|
||||
@ -704,7 +749,7 @@ function buildScaleAdjustPrompt(
|
||||
cropIndex >= 0
|
||||
? `;输入图片${cropIndex + 3}是该区域的干净局部裁切参考图,只用于识别目标主体的身份、轮廓与原始比例`
|
||||
: "";
|
||||
return `区域 ${region.index}${cropNote}:请在输入图片1中找到输入图片2红色实线框所标记范围内最主要、可独立缩放的商品/物体主体。红色实线框只是“寻找主体”的范围,不是要缩放或贴回的矩形补丁;目标虚线框只是表达目标尺寸范围,不是要求主体填满矩形或变成矩形比例。请以主体自身轮廓、原始宽高比和视觉中心为准,执行整体等比${actionLabel},宽度与高度必须使用同一个缩放倍率,目标主体视觉尺寸约为原主体的 ${targetLabel}。严禁宽度变窄但高度变高,严禁横向挤压、纵向拉伸或改变主体透视形体。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`;
|
||||
return `区域 ${region.index}${cropNote}:请在输入图片1中找到输入图片2红色实线框所标记范围内最主要、可独立缩放的商品/物体主体。红色实线框只是“寻找主体”的范围,不是放大后的硬边界,也不是要缩放或贴回的矩形补丁;放大时主体可以自然超出红色实线框,并由周边背景补全衔接。目标虚线框只是表达目标尺寸范围,不是要求主体填满矩形或变成矩形比例。请以主体自身轮廓、原始宽高比和视觉中心为准,执行整体等比${actionLabel},宽度与高度必须使用同一个缩放倍率,目标主体视觉尺寸约为原主体的 ${targetLabel}。严禁宽度变窄但高度变高,严禁横向挤压、纵向拉伸或改变主体透视形体。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`;
|
||||
})
|
||||
.join("\n");
|
||||
const cropLines = cropReferences
|
||||
@ -722,9 +767,9 @@ function buildScaleAdjustPrompt(
|
||||
cropLines
|
||||
? `输入图片3及之后是框选区域的干净局部裁切参考图,作用是帮助你确认“要缩放的是哪个主体”。这些裁切图不是输出底图,不得被整体放大、贴回或覆盖到原图上。\n${cropLines}`
|
||||
: "",
|
||||
"输入图片2中的红色实线框表示输入图片1里目标主体当前大致位置;青绿色或绿色虚线框只表示比例调整后的安全尺寸范围。最终画面不得出现这些框线,主体也不需要填满虚线矩形;必须保持主体自身原始宽高比和透视形体。",
|
||||
"输入图片2中的红色实线框表示输入图片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.",
|
||||
"请不要生成任何彩色框、矩形边框、编号圆点、提示词气泡、标注文字、UI 控件或半透明色块。",
|
||||
@ -737,7 +782,7 @@ function buildScaleAdjustPrompt(
|
||||
"2. 严禁把整个矩形框选区域、输入图片1整张图或任意裁切图当成图片块整体缩放、平移、复制、裁切或贴回;最终画面不得出现矩形拼接边缘、色差硬边、补丁感、裁切线或局部贴片痕迹。",
|
||||
"2.1 严禁把输入图片1整张图整体放大、缩小、裁切或重新构图;如果画面中的标题、Logo、页面文字、边框、背景家具或非目标主体发生整体位移/缩放/消失,必须视为失败并重新生成。",
|
||||
`3. 缩小时,目标主体应围绕原位置视觉中心自然变小到约 ${targetLabel};主体缩小后,原来被遮挡的背景、桌面、墙面、阴影、纹理和透视必须被重新生成并无缝补全,选区内应出现明显新增留白或背景。`,
|
||||
`4. 放大时,目标主体应围绕原位置视觉中心自然变大到约 ${targetLabel};宽度和高度都要同步变大,不能出现“高度变高但宽度变窄”的非等比变形;主体边缘与遮挡关系要合理,不能产生突兀裁切。`,
|
||||
`4. 放大时,目标主体应围绕原位置视觉中心自然变大到约 ${targetLabel};允许主体自然超出用户手画的原始红框,使用周围真实背景完成边缘、阴影和遮挡衔接。宽度和高度都要同步变大,不能出现“高度变高但宽度变窄”的非等比变形;主体边缘与遮挡关系要合理,不能产生突兀裁切。`,
|
||||
"5. 如果主体靠近画布边界导致目标尺寸空间不足,请用能完整放入画面的最大统一倍率整体缩放;绝不能为了塞进画面而只压缩宽度或只拉伸高度。",
|
||||
"6. 框选区域内非目标主体的背景、墙面、桌面、植物、文字和其它物体应保持原场景逻辑,不要跟着主体一起缩放。",
|
||||
"6.1 比例调整后的主体边缘必须和周围环境自然融合,选区边界不能被肉眼看出;不得出现一整块矩形区域清晰度、颜色、光照、纹理或透视突然变化。",
|
||||
|
||||
@ -247,7 +247,7 @@ type DetailResultLocalEditTarget = {
|
||||
};
|
||||
|
||||
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 = {
|
||||
x: number;
|
||||
@ -290,6 +290,7 @@ const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100;
|
||||
const MAX_AGENT_MODE_QUERY_LENGTH = 64;
|
||||
const MAX_AGENT_MODE_REFERENCE_COUNT = 40;
|
||||
const DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES = 30 * 1024 * 1024;
|
||||
const REPLACEMENT_RERUN_MAX_REFERENCE_IMAGES = 4;
|
||||
const DETAIL_SCALE_ADJUST_OPTIONS: DetailResultScalePercent[] = [
|
||||
-30,
|
||||
-20,
|
||||
@ -297,6 +298,8 @@ const DETAIL_SCALE_ADJUST_OPTIONS: DetailResultScalePercent[] = [
|
||||
10,
|
||||
20,
|
||||
30,
|
||||
50,
|
||||
100,
|
||||
];
|
||||
const DETAIL_LOCAL_EDIT_MIN_RECT_RATIO = 0.025;
|
||||
const DIRECT_MODEL_UPLOAD_SLOT_COUNT = 4;
|
||||
@ -860,6 +863,7 @@ function getDetailScaleAdjustRegionPrompt(scalePercent: DetailResultScalePercent
|
||||
return [
|
||||
`对框选范围内的主物体执行等比${actionLabel},调整后主体视觉尺寸必须约为原来的 ${targetLabel},需要肉眼明显看出已经${scaleVerb}。`,
|
||||
"必须锁定主体自身原始宽高比和透视形体,宽度与高度用同一个倍率同步变化;严禁宽度变窄但高度变高、严禁横向挤压或纵向拉伸。",
|
||||
"框选区域只用于识别要调整的主体,不是放大后的边界;放大时主体可以自然超出原始框选范围,并由周边背景补全衔接。",
|
||||
"只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
|
||||
scalePercent < 0
|
||||
? "主体缩小后露出的背景、桌面、墙面、阴影和遮挡关系必须自然补全。"
|
||||
@ -1475,7 +1479,7 @@ function DetailResultLocalEditDialog({
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38">
|
||||
Scale
|
||||
</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) => {
|
||||
const active = selectedScalePercent === option;
|
||||
const Icon = option > 0 ? Maximize2 : Minimize2;
|
||||
@ -2035,6 +2039,23 @@ interface ReplacementRerunTarget {
|
||||
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<
|
||||
ReplacementModeType,
|
||||
Record<string, ReplacementResultRecord>
|
||||
@ -2729,9 +2750,11 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const generationRequestSnapshotRef = useRef<{
|
||||
hiddenPrompt: string | null;
|
||||
replacementExecutionCardIds: string[] | null;
|
||||
replacementRerunReferenceImages: ReplacementRerunRequestSnapshot;
|
||||
}>({
|
||||
hiddenPrompt: null,
|
||||
replacementExecutionCardIds: null,
|
||||
replacementRerunReferenceImages: null,
|
||||
});
|
||||
const [localAgentModeState, setLocalAgentModeState] = useState<AgentModeWorkspaceState>(
|
||||
() => createEmptyAgentModeWorkspaceState()
|
||||
@ -2858,6 +2881,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const [replacementRerunTarget, setReplacementRerunTarget] =
|
||||
useState<ReplacementRerunTarget | null>(null);
|
||||
const [replacementRerunPrompt, setReplacementRerunPrompt] = useState("");
|
||||
const [replacementRerunReferenceImages, setReplacementRerunReferenceImages] =
|
||||
useState<ReplacementRerunReferenceImage[]>([]);
|
||||
const [replacementRerunReferenceUploading, setReplacementRerunReferenceUploading] =
|
||||
useState(false);
|
||||
const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] =
|
||||
useState<Set<string>>(() => new Set());
|
||||
const processedResultHintTimerRef = useRef<Record<string, number>>({});
|
||||
@ -3684,6 +3711,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
const replacementRerunReferenceSnapshot =
|
||||
generationRequestSnapshotRef.current.replacementRerunReferenceImages;
|
||||
const resolvedAgentPresetId =
|
||||
requiresPresetSelection
|
||||
? transportState.selectedAgentPresetId || selectedAgentPresetId || undefined
|
||||
@ -3726,23 +3755,42 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
requestedReplacementCardIdSet.has(String(item.id || "").trim())
|
||||
)
|
||||
.filter((item) => Boolean(String(item.imageUrl || "").trim()))
|
||||
.map((item) => ({
|
||||
id: String(item.id || "").slice(0, 128),
|
||||
label: String(item.label || "").slice(0, 255),
|
||||
imageUrl: String(item.imageUrl || "").trim(),
|
||||
candidateId: item.candidateId
|
||||
? String(item.candidateId).slice(0, 128)
|
||||
: null,
|
||||
sortOrder: item.sortOrder ?? 0,
|
||||
passthrough: item.passthrough === true,
|
||||
assignedProductIndex:
|
||||
transportState.selectedProductAttachmentCount > 0
|
||||
? Math.min(
|
||||
item.assignedProductIndex ?? 0,
|
||||
Math.max(transportState.selectedProductAttachmentCount - 1, 0)
|
||||
)
|
||||
.map((item) => {
|
||||
const itemId = String(item.id || "").trim();
|
||||
const rerunReferenceImages =
|
||||
replacementRerunReferenceSnapshot &&
|
||||
replacementRerunReferenceSnapshot.modeType ===
|
||||
(transportState.isDetailReplacementPreset
|
||||
? "detail_replacement"
|
||||
: "sku_replacement") &&
|
||||
replacementRerunReferenceSnapshot.cardId === itemId
|
||||
? replacementRerunReferenceSnapshot.images
|
||||
: [];
|
||||
return {
|
||||
id: itemId.slice(0, 128),
|
||||
label: String(item.label || "").slice(0, 255),
|
||||
imageUrl: String(item.imageUrl || "").trim(),
|
||||
candidateId: item.candidateId
|
||||
? String(item.candidateId).slice(0, 128)
|
||||
: 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
|
||||
? {
|
||||
productImages: transportState.detailReplacementProductSourceImages,
|
||||
@ -5472,6 +5520,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
replacementModeType?: ReplacementModeType;
|
||||
replacementCardIds?: string[] | null;
|
||||
promptText?: string | null;
|
||||
replacementRerunReferenceImages?: ReplacementRerunRequestSnapshot;
|
||||
}) => {
|
||||
if (isSelectedBusinessModeUnavailable || activeBusinessModeKey === "buyer_show") {
|
||||
toast.error("当前生图模式暂未开放");
|
||||
@ -5634,6 +5683,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
replacementModeType && replacementExecutionCards.length > 0
|
||||
? replacementExecutionCards.map((card) => card.id)
|
||||
: null,
|
||||
replacementRerunReferenceImages:
|
||||
options?.replacementRerunReferenceImages || null,
|
||||
};
|
||||
setReplacementExecutionContext(
|
||||
replacementModeType && replacementExecutionCards.length > 0
|
||||
@ -5675,6 +5726,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
generationRequestSnapshotRef.current = {
|
||||
hiddenPrompt: null,
|
||||
replacementExecutionCardIds: null,
|
||||
replacementRerunReferenceImages: null,
|
||||
};
|
||||
setPendingGeneration(null);
|
||||
toast.error(error instanceof Error ? error.message : "发送生成请求失败");
|
||||
@ -5722,6 +5774,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
generationRequestSnapshotRef.current = {
|
||||
hiddenPrompt: hiddenPromptRef.current,
|
||||
replacementExecutionCardIds: null,
|
||||
replacementRerunReferenceImages: null,
|
||||
};
|
||||
setPendingGeneration(null);
|
||||
setMessages((current) => current);
|
||||
@ -5753,19 +5806,124 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
: current
|
||||
);
|
||||
}, [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(() => {
|
||||
if (!replacementRerunTarget) {
|
||||
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({
|
||||
replacementModeType: replacementRerunTarget.modeType,
|
||||
replacementCardIds: [replacementRerunTarget.cardId],
|
||||
promptText: replacementRerunPrompt,
|
||||
promptText,
|
||||
replacementRerunReferenceImages:
|
||||
rerunReferenceImages.length > 0
|
||||
? {
|
||||
cardId: replacementRerunTarget.cardId,
|
||||
images: rerunReferenceImages,
|
||||
modeType: replacementRerunTarget.modeType,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
setReplacementRerunTarget(null);
|
||||
setReplacementRerunPrompt("");
|
||||
}, [handleSend, replacementRerunPrompt, replacementRerunTarget]);
|
||||
setReplacementRerunReferenceImages([]);
|
||||
}, [
|
||||
handleSend,
|
||||
replacementRerunPrompt,
|
||||
replacementRerunReferenceImages,
|
||||
replacementRerunReferenceUploading,
|
||||
replacementRerunTarget,
|
||||
]);
|
||||
|
||||
const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => {
|
||||
const raw = String(imageUrl || "").trim();
|
||||
@ -6952,7 +7110,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
? "首屏封面规则:必须做成海报主视觉,单一主体强焦点,产品主体占画面55%-70%,顶部标题区清晰留白,禁止拼贴式说明图。"
|
||||
: "",
|
||||
useModelForScene
|
||||
? "人物规则:本屏涉及人物场景,固定使用提供的模特图同一人物(面部、发型、体型、服装风格保持一致)。"
|
||||
? "人物规则:本屏涉及人物场景,必须固定使用提供的模特图同一人物;脸型五官、发型发色、体型肤色、穿搭风格保持一致,不得换脸、换人、换发型或改成无关人物。"
|
||||
: "人物规则:本屏不要出现人物或人体部位,只展示产品与场景元素。",
|
||||
screen?.modelReason ? `人物融入依据:${screen.modelReason}` : "",
|
||||
bundleShowcase
|
||||
@ -9426,6 +9584,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
title: `${detailReferenceSourceLabel}结果图 ${card.index + 1}`,
|
||||
});
|
||||
setReplacementRerunPrompt("");
|
||||
setReplacementRerunReferenceImages([]);
|
||||
}}
|
||||
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}`,
|
||||
});
|
||||
setReplacementRerunPrompt("");
|
||||
setReplacementRerunReferenceImages([]);
|
||||
}}
|
||||
className={cn(IMAGE_OVERLAY_ACTION_CLASS, "disabled:cursor-not-allowed disabled:opacity-45")}
|
||||
>
|
||||
@ -10544,6 +10704,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
if (!open) {
|
||||
setReplacementRerunTarget(null);
|
||||
setReplacementRerunPrompt("");
|
||||
setReplacementRerunReferenceImages([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@ -10551,7 +10712,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
<DialogHeader>
|
||||
<DialogTitle>重新生图</DialogTitle>
|
||||
<DialogDescription>
|
||||
当前会只重做这一张结果图,不会把已经完成的其它结果重新跑一遍。
|
||||
当前会只重做这一张结果图。请填写本次调整要求,也可以添加补充参考图帮助模型拉开差异。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
@ -10566,9 +10727,86 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
value={replacementRerunPrompt}
|
||||
onChange={(event) => setReplacementRerunPrompt(event.target.value)}
|
||||
className="min-h-[140px] resize-y"
|
||||
placeholder="例如:标题更有冲击力、背景更明亮、保留版式但优化卖点文案层级..."
|
||||
placeholder="例如:背景更明亮,产品占比更大,标题更有冲击力,保留版式但优化卖点文案层级..."
|
||||
/>
|
||||
</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 className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
@ -10577,11 +10815,19 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
onClick={() => {
|
||||
setReplacementRerunTarget(null);
|
||||
setReplacementRerunPrompt("");
|
||||
setReplacementRerunReferenceImages([]);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={handleConfirmReplacementRerun}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
replacementRerunReferenceUploading ||
|
||||
!replacementRerunPrompt.trim()
|
||||
}
|
||||
onClick={handleConfirmReplacementRerun}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
开始重生
|
||||
</Button>
|
||||
|
||||
@ -78,6 +78,13 @@ import type {
|
||||
AgentWorkflowComposerContext,
|
||||
} from "@/types/agent";
|
||||
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 {
|
||||
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
|
||||
@ -1619,6 +1626,7 @@ export function CanvasChatPanel({
|
||||
const nodes = useCanvasStore((state) => state.nodes);
|
||||
const projectTitle = useCanvasStore((state) => state.projectTitle);
|
||||
const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds);
|
||||
const viewport = useCanvasStore((state) => state.viewport);
|
||||
const selectNodes = useCanvasStore((state) => state.selectNodes);
|
||||
const setViewport = useCanvasStore((state) => state.setViewport);
|
||||
const clearSelection = useCanvasStore((state) => state.clearSelection);
|
||||
@ -5325,17 +5333,13 @@ export function CanvasChatPanel({
|
||||
}, [nodes, selectedImageNodes]);
|
||||
|
||||
const buildContextPayload = useCallback(() => {
|
||||
const selectedNodeIdSet = new Set(selectedNodeIds);
|
||||
const imageNodes = nodes.filter(
|
||||
(node) =>
|
||||
node.type === "image" &&
|
||||
isValidChatHttpUrl(String((node.data as any)?.src || "").trim()),
|
||||
);
|
||||
|
||||
if (imageNodes.length === 0) {
|
||||
generationAnchorNodesRef.current = [];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const effectiveComposerMode = activeComposerModeRef.current || activeComposerMode;
|
||||
const shouldIsolateDirectModeContext = effectiveComposerMode !== "agent";
|
||||
const selectedImageNodesForContext = selectedImageNodes.filter((node) =>
|
||||
@ -5348,6 +5352,10 @@ export function CanvasChatPanel({
|
||||
}
|
||||
|
||||
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);
|
||||
generationAnchorNodesRef.current = selectedContextNodes.slice(0, 4);
|
||||
const selectedIds = new Set(selectedContextNodes.map((node) => node.id));
|
||||
@ -5361,6 +5369,13 @@ export function CanvasChatPanel({
|
||||
]
|
||||
: imageNodes;
|
||||
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(
|
||||
new Set(
|
||||
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 {
|
||||
source: "canvas" as const,
|
||||
projectId: isValidChatUuid(canvasId) ? canvasId : undefined,
|
||||
selectedNodeIds: selectedContextNodes.map((node) => node.id).slice(0, 20),
|
||||
projectId,
|
||||
selectedNodeIds: selectedCanvasNodes.map((node) => node.id).slice(0, 20),
|
||||
selectedAssetIds,
|
||||
focusPolicy: selectedContextNodes.length > 0 ? ("selected" as const) : ("auto" as const),
|
||||
focusPolicy: agentFocusPolicy,
|
||||
canvasImageNodes: contextNodes.map((node) => ({
|
||||
nodeId: node.id,
|
||||
assetId: isValidChatUuid((node.data as any)?.assetId)
|
||||
@ -5398,8 +5611,9 @@ export function CanvasChatPanel({
|
||||
height: Number(node.size?.height || 0) || undefined,
|
||||
isSelected: selectedIds.has(node.id),
|
||||
})),
|
||||
canvasAgentContext,
|
||||
};
|
||||
}, [activeComposerMode, canvasId, nodes, selectedImageNodes]);
|
||||
}, [activeComposerMode, canvasId, nodes, selectedImageNodes, selectedNodeIds, viewport]);
|
||||
|
||||
const updateAttachmentAtVideoSlot = useCallback(
|
||||
(slotIndex: number, attachment: PickedObjectAttachment | null) => {
|
||||
@ -6557,7 +6771,7 @@ export function CanvasChatPanel({
|
||||
? "首屏封面规则:必须做成海报主视觉,单一主体强焦点,产品主体占画面55%-70%,顶部标题区清晰留白,禁止拼贴式说明图。"
|
||||
: "",
|
||||
useModelForScene
|
||||
? `人物规则:本屏涉及人物场景,固定使用提供的模特图同一人物(面部、发型、体型、服装风格保持一致)。`
|
||||
? `人物规则:本屏涉及人物场景,必须固定使用提供的模特图同一人物;脸型五官、发型发色、体型肤色、穿搭风格保持一致,不得换脸、换人、换发型或改成无关人物。`
|
||||
: "人物规则:本屏不要出现人物或人体部位,只展示产品与场景元素。",
|
||||
screen?.modelReason ? `人物融入依据:${screen.modelReason}` : "",
|
||||
bundleShowcase
|
||||
|
||||
@ -395,7 +395,7 @@ type LocalEditRegionInteractionState = {
|
||||
};
|
||||
|
||||
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[] = [
|
||||
-30,
|
||||
@ -404,6 +404,8 @@ const CANVAS_SCALE_ADJUST_OPTIONS: CanvasScaleAdjustPercent[] = [
|
||||
10,
|
||||
20,
|
||||
30,
|
||||
50,
|
||||
100,
|
||||
];
|
||||
|
||||
function getCanvasScaleAdjustLabel(scalePercent: CanvasScaleAdjustPercent) {
|
||||
@ -421,6 +423,7 @@ function getCanvasScaleAdjustRegionPrompt(scalePercent: CanvasScaleAdjustPercent
|
||||
const scaleVerb = scalePercent > 0 ? "放大" : "缩小";
|
||||
return [
|
||||
`对框选范围内的主物体执行等比${actionLabel},调整后主体视觉尺寸必须约为原来的 ${targetLabel},需要肉眼明显看出已经${scaleVerb}。`,
|
||||
"框选区域只用于识别要调整的主体,不是放大后的边界;放大时主体可以自然超出原始框选范围,并由周边背景补全衔接。",
|
||||
"只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
|
||||
scalePercent < 0
|
||||
? "主体缩小后露出的背景、桌面、墙面、阴影和遮挡关系必须自然补全。"
|
||||
@ -8671,7 +8674,7 @@ export function ImageNode({
|
||||
)}
|
||||
|
||||
{/* 处理中遮罩 */}
|
||||
{isProcessing && (
|
||||
{isProcessing && !isSubmittingLocalEdit && (
|
||||
<ImageNodeProcessingOverlay
|
||||
detailProgressLabel={detailProgressLabel || undefined}
|
||||
generationProgress={generationProgress}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { DBMessage } from "@/lib/db/schema";
|
||||
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
|
||||
|
||||
export interface CanvasImageNodeContext {
|
||||
nodeId: string;
|
||||
@ -18,8 +19,9 @@ export interface ContextPayload {
|
||||
projectId?: string;
|
||||
selectedNodeIds?: string[];
|
||||
selectedAssetIds?: string[];
|
||||
focusPolicy?: "auto" | "selected" | "latest_result" | "original";
|
||||
focusPolicy?: "auto" | "selected" | "latest_result" | "original" | "slot";
|
||||
canvasImageNodes?: CanvasImageNodeContext[];
|
||||
canvasAgentContext?: CanvasAgentContext;
|
||||
}
|
||||
|
||||
export interface ResolvedImageRef {
|
||||
|
||||
392
lib/canvas/agent-context.ts
Normal file
392
lib/canvas/agent-context.ts
Normal 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";
|
||||
}
|
||||
@ -340,7 +340,7 @@ export const imageGenerationService = {
|
||||
imageSize?: string;
|
||||
aspectRatio?: string;
|
||||
operation?: "scale-adjust";
|
||||
scalePercent?: -30 | -20 | -10 | 10 | 20 | 30;
|
||||
scalePercent?: -30 | -20 | -10 | 10 | 20 | 30 | 50 | 100;
|
||||
}): Promise<
|
||||
ApiResponse<{
|
||||
annotatedReferenceUrl: string;
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
normalizeMediaUrl,
|
||||
stripMediaMarkup,
|
||||
} from "@/components/canvas/chat-panel/canvas-chat-media";
|
||||
import { normalizeCanvasAgentContext } from "@/lib/canvas/agent-context";
|
||||
import {
|
||||
buildDetailResultMarkdown,
|
||||
buildDetailReviewAppendMarkdown,
|
||||
@ -49,6 +50,81 @@ import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canv
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
|
||||
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", () => {
|
||||
expect(normalizeMediaUrl(" HTTPS://EXAMPLE.COM/assets/image.png?sig=secret#preview ")).toBe(
|
||||
"https://example.com/assets/image.png"
|
||||
|
||||
161
types/canvas/agent-protocol.ts
Normal file
161
types/canvas/agent-protocol.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user