diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index ea3f26604..6bd1b7d5e 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -697,28 +697,14 @@ function buildScaleAdjustPrompt( : "最终输出必须保持与输入图片1完全相同的画幅和像素尺寸。"; const regionLines = regions .map((region) => { - const left = Math.round(region.rect.x * 1000) / 10; - const top = Math.round(region.rect.y * 1000) / 10; - const width = Math.round(region.rect.width * 1000) / 10; - const height = Math.round(region.rect.height * 1000) / 10; - const targetRect = scaleRectAroundCenter(region.rect, scalePercent); - const targetLeft = Math.round(targetRect.x * 1000) / 10; - const targetTop = Math.round(targetRect.y * 1000) / 10; - const targetWidth = Math.round(targetRect.width * 1000) / 10; - const targetHeight = Math.round(targetRect.height * 1000) / 10; - const pixel = region.pixelRect; - const right = pixel.left + pixel.width; - const bottom = pixel.top + pixel.height; - const centerX = pixel.left + Math.round(pixel.width / 2); - const centerY = pixel.top + Math.round(pixel.height / 2); const cropIndex = cropReferences.findIndex( (reference) => reference.regionIndex === region.index ); const cropNote = cropIndex >= 0 - ? `;输入图片${cropIndex + 3}是该区域的干净局部裁切参考图,只用于识别目标主体的轮廓与原始比例` + ? `;输入图片${cropIndex + 3}是该区域的干净局部裁切参考图,只用于识别目标主体的身份、轮廓与原始比例` : ""; - return `区域 ${region.index}(原始红色实线框:x=${left}%,y=${top}%,w=${width}%,h=${height}%;目标虚线框:x=${targetLeft}%,y=${targetTop}%,w=${targetWidth}%,h=${targetHeight}%;像素范围 left=${pixel.left}, top=${pixel.top}, right=${right}, bottom=${bottom}, center=(${centerX}, ${centerY})${cropNote}):红色实线框只是“寻找目标主体”的范围,不是要缩放或贴回的矩形补丁;目标虚线框只是表达目标尺寸范围,不是要求主体填满矩形或变成矩形比例。请识别此范围内最主要的商品/物体主体,以主体自身轮廓、原始宽高比和视觉中心为准,执行整体等比${actionLabel},宽度与高度必须使用同一个缩放倍率,目标主体视觉尺寸约为原主体的 ${targetLabel}。严禁宽度变窄但高度变高,严禁横向挤压、纵向拉伸或改变主体透视形体。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`; + return `区域 ${region.index}${cropNote}:请在输入图片1中找到输入图片2红色实线框所标记范围内最主要、可独立缩放的商品/物体主体。红色实线框只是“寻找主体”的范围,不是要缩放或贴回的矩形补丁;目标虚线框只是表达目标尺寸范围,不是要求主体填满矩形或变成矩形比例。请以主体自身轮廓、原始宽高比和视觉中心为准,执行整体等比${actionLabel},宽度与高度必须使用同一个缩放倍率,目标主体视觉尺寸约为原主体的 ${targetLabel}。严禁宽度变窄但高度变高,严禁横向挤压、纵向拉伸或改变主体透视形体。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`; }) .join("\n"); const cropLines = cropReferences @@ -732,23 +718,23 @@ function buildScaleAdjustPrompt( "这是一项局部主体比例 inpaint/edit 任务,不是整图缩放、整图裁切或重新构图任务。", sourceSizeLine, "输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。除目标主体尺寸变化与必要背景补全外,输入图片1中的文字、Logo、装饰、排版、背景、光线、构图边界和所有未选区域必须保持不变。", - "输入图片2是低噪声定位参考图:红色实线框用于寻找当前主体,青绿色或绿色虚线框用于表达目标尺寸范围。输入图片2不是输出底图,不得学习或复刻它的框线、编号、半透明遮罩或 UI 标记。", + "输入图片2是白底选区参考图,只用于说明输入图片1上的红色实线框和目标虚线框位置;它不包含场景内容,不是输出底图,不得学习或复刻它的框线、编号、半透明遮罩或 UI 标记。", cropLines ? `输入图片3及之后是框选区域的干净局部裁切参考图,作用是帮助你确认“要缩放的是哪个主体”。这些裁切图不是输出底图,不得被整体放大、贴回或覆盖到原图上。\n${cropLines}` : "", - "输入图片2中的红色实线框表示目标主体当前大致位置;青绿色或绿色虚线框只表示比例调整后的安全尺寸范围。最终画面不得出现这些框线,主体也不需要填满虚线矩形;必须保持主体自身原始宽高比和透视形体。", + "输入图片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 控件或半透明色块。", "", - `任务:先在输入图片1中识别红色实线框附近最主要、可独立缩放的主体物体,再只对这个主体做局部比例调整,统一执行${actionLabel}。目标主体调整后的视觉尺寸必须约为原主体的 ${targetLabel},普通用户肉眼应能清楚看出主体尺寸已经变化;如果输出看起来和原图一样,就是失败。`, + `任务:先在输入图片1中识别红色实线框范围内最主要、可独立缩放的主体物体,再只对这个“主体物体”做局部比例调整,统一执行${actionLabel}。目标主体调整后的视觉尺寸必须约为原主体的 ${targetLabel},普通用户肉眼应能清楚看出主体尺寸已经变化;如果输出看起来和原图一样,就是失败。`, regionLines, "", "关键要求:", `1. 只调整目标主体物体本身的整体视觉比例,必须等比缩放到约 ${targetLabel},不得保留原尺寸;主体轮廓宽度和高度必须使用同一个倍率同步变化,宽高比必须与原主体一致,不能为了贴合虚线框而拉宽、压窄、拉高或压扁。`, - "2. 严禁把整个矩形框选区域当成图片块整体缩放、平移、复制、裁切或贴回;最终画面不得出现矩形拼接边缘、色差硬边、补丁感、裁切线或局部贴片痕迹。", + "2. 严禁把整个矩形框选区域、输入图片1整张图或任意裁切图当成图片块整体缩放、平移、复制、裁切或贴回;最终画面不得出现矩形拼接边缘、色差硬边、补丁感、裁切线或局部贴片痕迹。", "2.1 严禁把输入图片1整张图整体放大、缩小、裁切或重新构图;如果画面中的标题、Logo、页面文字、边框、背景家具或非目标主体发生整体位移/缩放/消失,必须视为失败并重新生成。", `3. 缩小时,目标主体应围绕原位置视觉中心自然变小到约 ${targetLabel};主体缩小后,原来被遮挡的背景、桌面、墙面、阴影、纹理和透视必须被重新生成并无缝补全,选区内应出现明显新增留白或背景。`, `4. 放大时,目标主体应围绕原位置视觉中心自然变大到约 ${targetLabel};宽度和高度都要同步变大,不能出现“高度变高但宽度变窄”的非等比变形;主体边缘与遮挡关系要合理,不能产生突兀裁切。`, @@ -876,7 +862,17 @@ export async function POST(request: NextRequest) { regions, width: sourceWidth, }); - const annotatedBuffer = await sharp(sourceBuffer, { failOn: "none" }) + const annotatedBase = isScaleAdjustOperation + ? sharp({ + create: { + background: "#ffffff", + channels: 3, + height: sourceHeight, + width: sourceWidth, + }, + }) + : sharp(sourceBuffer, { failOn: "none" }); + const annotatedBuffer = await annotatedBase .composite([{ input: annotationSvg, left: 0, top: 0 }]) .png({ compressionLevel: 6 }) .toBuffer(); @@ -963,6 +959,9 @@ export async function POST(request: NextRequest) { debugMeta: { annotatedReferenceUrl: annotatedUpload.url, annotatedReferenceSentToModel: isScaleAdjustOperation, + annotatedReferenceType: isScaleAdjustOperation + ? "white-selection-guide" + : "source-overlay", cleanSourceReferenceIndex: 1, configuredDetailModelId: configuredDetailModel || null, detailFixedModelApplied: Boolean(detailFixedModel), @@ -980,7 +979,7 @@ export async function POST(request: NextRequest) { scalePercent, scaleAdjustCropReferenceCount: scaleAdjustCropReferenceImageUrls.length, scaleAdjustPromptStrategy: isScaleAdjustOperation - ? "locked-plate-local-subject-v2" + ? "locked-plate-local-subject-v3" : undefined, scaleAdjustUsesFullFrameOutput: isScaleAdjustOperation, sourceHeight, diff --git a/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md b/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md new file mode 100644 index 000000000..da5cda88a --- /dev/null +++ b/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md @@ -0,0 +1,1525 @@ +# 无限画布对话框 Agent 模式升级开发文档 + +> 日期:2026-06-22 +> 参考项目:Cowart(https://github.com/zhongerxin/cowart) +> 目标:把当前“对话框 Agent 能理解画布上下文并生成结果”,升级为“Agent 能通过稳定协议读写画布、填充画布意图、保留版本链、低打扰执行”的可落地系统。 + +--- + +## 1. 背景与结论 + +### 1.1 当前系统基础 + +当前项目已经具备比较完整的画布与 Agent 基础: + +- 无限画布节点体系:`types/canvas/index.ts` +- 画布 Zustand 状态:`store/canvas/index.ts` +- 画布对话框:`components/canvas/CanvasChatPanel.tsx` +- 画布上下文传入 Chat API:`contextPayload` +- 目标图解析:`lib/ai/context-memory/media-input.ts` +- 目标图锁定:`lib/ai/context-memory/target-resolution.ts` +- Agent 自动规划:`lib/ai/auto-agent-graph.ts` +- 画布 Agent 澄清门:`lib/ai/runtime/middlewares/canvas-agent-clarification-gate.ts` + +当前能力已经可以做到: + +- 选中画布图片后在对话框里发起处理 +- 将画布图片作为上下文传给 Agent +- 解析“左边那张 / 第2张 / 选中的图 / 上一版结果图” +- 生成图片、编辑图片、生成视频,并在部分链路中同步回画布 + +### 1.2 当前主要问题 + +1. 画布上下文仍偏“请求参数”,不是稳定的 Agent 画布操作协议。 +2. 对话框 Agent 更像“拿画布图片做输入”,还不是“可读写画布的协作者”。 +3. 结果落点缺少明确契约,经常依赖前端自动同步或消息媒体反推。 +4. 缺少画布上的“输出占位 / 生成槽位”概念,用户不能直接在画布中指定生成位置、比例与用途。 +5. 标注、圈选、批注还没有成为 Agent 编辑任务的一等输入。 +6. 版本迭代关系不够显性,原图、标注、新版本之间缺少稳定链路。 + +### 1.3 Cowart 带来的核心启发 + +Cowart 不是功能复杂的产品,而是边界非常清晰的 Agent + Canvas 协作样板: + +- 用 `AI image holder` 表达“这里要生成图”。 +- 用画布选中态表达“这次操作目标”。 +- 用标注截图表达“怎么改”。 +- 用 MCP 工具读取 selection、插入 image、保存 page-local asset。 +- 默认非破坏式迭代:新结果放在原图旁边,不覆盖、不移动、不删除原图与标注。 +- 没有选中 holder 也继续生成,插入当前页空白区,减少无谓澄清。 + +### 1.4 本项目升级结论 + +不建议替换现有画布引擎,也不建议照搬 Cowart/tldraw。 + +建议做一层 **Canvas Agent Operation Protocol**: + +- 前端画布继续使用现有节点系统。 +- Agent 不直接读写 Zustand 内部结构。 +- Chat API 通过统一协议接收画布状态摘要与操作意图。 +- 执行层通过统一 Canvas mutation API 写回节点、版本链、边与元数据。 + +一句话目标: + +> 让无限画布从“AI 结果展示面板”升级成“Agent 可读、可写、可追踪的工作台”。 + +### 1.5 对话框驱动画布的可行性判断 + +可以做到,而且这是本次升级最重要的产品方向。 + +Cowart 证明的关键不是“AI 可以像人一样点击画布”,而是: + +> AI 不需要模拟鼠标操作 UI,AI 应该通过受控协议读写画布状态。 + +也就是说,对话框驱动画布的正确链路不是: + +```text +用户对话 -> AI 控制鼠标 -> 点击画布 -> 拖拽节点 +``` + +而是: + +```text +用户对话 -> Agent 理解意图 -> 读取画布上下文 -> 生成画布操作计划 -> 系统安全执行画布 mutation +``` + +这条路更稳定,也更适合我们现有架构。因为当前系统已经具备: + +- 前端能知道当前选中节点:`selectedNodeIds` +- 前端能把画布图片节点传给后端:`canvasImageNodes` +- 后端能解析目标图:`resolveChatMediaInput()` / `resolveTargetAssets()` +- Agent 已经能规划生成、编辑、视频等任务:`runAutoAgentGraph()` +- 画布 store 已支持 `addNode` / `updateNode` / `addEdge` +- 服务端已有 `canvasProject.canvasData` 持久化 + +因此缺的不是“让 AI 会操作画布”,而是给 Agent 一套明确、安全、可审计的画布操作协议。 + +第一版要优先支持这些对话驱动画布动作: + +- “在这个空框里生成一张主图。” +- “把选中图片背景换成白色,新图放右边。” +- “按批注修改这张图。” +- “给这三张图各出一版变体。” +- “把上一版结果放到当前画布中心。” +- “把这两张图连成参考关系。” +- “创建一个视频槽位并填充生成结果。” +- “帮我聚焦到正在编辑的这张图。” +- “把画布缩小一点,我想看全局。” +- “放大选中的主体图,方便继续编辑。” + +首期只开放非破坏式操作: + +- 填充空 slot +- 插入到锚点旁边 +- 追加版本 +- 自动连线 +- 更新文本节点 +- 聚焦 / 缩放 / 平移当前画布视角 + +替换、删除、覆盖、批量移动等高风险动作必须后置,并通过确认门控制。 + +### 1.6 最终产品定位:Agent 模型直接驱动画布 + +本次升级的最终目标不是“生成结果后同步到画布”,而是让 Agent 模型具备和参考项目类似的直接画布操作能力: + +> 用户只描述需求,Agent 模型理解画布状态、决定操作步骤,并通过受控操作协议直接改变画布内容和视角。 + +这里的“直接操作画布”不是让模型任意修改前端状态,也不是让模型模拟鼠标,而是采用更可靠的分工: + +- **模型负责决策**:理解用户需求、识别目标节点、选择操作类型、生成操作计划。 +- **系统负责执行**:校验权限、限制风险、计算布局、写入节点、更新视角。 +- **用户负责确认高风险动作**:覆盖、删除、替换、批量移动等必须可拦截。 + +最终体验应该支持: + +- Agent 根据“这张图”“左边那张”“上一版结果”“选中的图”自动锁定目标。 +- Agent 根据需求自动创建、填充或追加图片/视频/UI slot。 +- Agent 能新增、更新、移动、连接画布节点。 +- Agent 能按批注、框选、箭头、文字说明修改图片。 +- Agent 能生成新版本并自动建立版本关系。 +- Agent 能控制画布视角,把用户带到当前编辑对象。 +- Agent 能在多图场景下整理、分组、对比、聚焦关键结果。 + +目标链路应该从: + +```text +用户需求 -> Agent 生成图片/视频 -> 前端把结果同步回画布 +``` + +升级为: + +```text +用户需求 -> Agent 生成 CanvasOperationPlan + CanvasViewOperationPlan -> 系统执行画布操作 -> 用户看到画布被直接更新 +``` + +这也是后续所有实现要围绕的主线。 + +--- + +## 2. 产品目标 + +### 2.1 用户体验目标 + +用户在画布对话框中可以自然完成这些任务: + +- “在这个空框里生成一张主图。” +- “按选中这张做一版白底图。” +- “把右边那张的背景改成夏天户外。” +- “按我画的箭头和批注修改。” +- “再出三版放到这张图右边。” +- “保留原图和标注,新图放旁边。” +- “把这几张都统一成同一风格。” +- “放大当前编辑对象。” +- “缩小画布看整体。” +- “把选中的图移动到视野中央。” + +### 2.2 系统体验目标 + +- Agent 首先利用画布状态,而不是反复询问用户。 +- 非破坏性任务默认执行。 +- Agent 能控制当前画布视角,帮助用户在大量图片中定位编辑对象。 +- 高风险任务才澄清,例如覆盖、删除、多图不明确、批量编辑不明确。 +- 所有 Agent 写回画布的动作都有可追踪 metadata。 +- 所有生成结果都能回溯到源节点、提示词、模型、任务、版本链。 + +### 2.3 Agent 直接操作画布能力分层 + +为了避免一次性做成高风险大工程,建议按能力等级推进。 + +L1:结果落画布 + +- Agent 生成图片/视频后,系统自动插入画布。 +- 支持插入到选中图右侧、当前视口中心、选中 slot。 +- 默认不覆盖原节点。 + +L2:画布结构操作 + +- Agent 能创建 slot、文本节点、图片节点、视频节点。 +- Agent 能给节点建立 reference / revision 边。 +- Agent 能按版本链组织“原图 -> 标注 -> 新版本”。 + +L3:视角操作 + +- Agent 能放大、缩小、平移、聚焦节点、聚焦选区。 +- 生成或编辑完成后自动把用户带到新结果。 +- 多图对比时自动缩放到一组节点的包围盒。 + +L4:内容编辑操作 + +- Agent 能按批注、框选、箭头、文字说明修改图片。 +- Agent 能更新文本节点内容。 +- Agent 能基于已有节点生成新变体。 + +L5:布局与整理操作 + +- Agent 能按用户要求整理节点布局,例如三列排列、按版本分组、把参考图放左侧、结果放右侧。 +- 这一级涉及移动多个节点,默认需要确认。 + +L6:高风险替换操作 + +- Agent 能替换已有节点媒体、删除节点、覆盖结果。 +- 必须有确认门、撤销能力和操作审计。 + +首期建议实现 L1 + L3 的核心能力,并补上 L2 中的 revision/link,这样最快形成“Agent 真的在操作画布”的体感。 + +--- + +## 3. 范围定义 + +### 3.1 本期要做 + +1. 新增画布 Agent 操作协议类型。 +2. 新增 `agent-slot` 能力,用于表达画布上的生成占位。 +3. 新增前端画布上下文摘要 builder,输出更稳定的 selected / viewport / slots / annotations。 +4. 新增后端 Canvas mutation API,用于安全写回画布。 +5. 改造 Agent 生成与编辑链路,支持: + - 填充选中 slot + - 插入锚点右侧 + - 创建非破坏式 revision + - 写入版本边 +6. 新增画布视角控制能力: + - 聚焦选中节点 + - 聚焦刚生成的新结果 + - 缩放到选区 + - 缩小查看全局 + - 平移到指定锚点附近 +7. 新增标注输入的最小闭环: + - 前端支持图片节点附带标注摘要 + - Agent 可根据标注摘要执行编辑 + - 结果放到原图旁边 + +### 3.2 本期不做 + +- 不替换现有 CanvasWorkspace。 +- 不引入 tldraw。 +- 不做完整多人协同。 +- 不做完整 MCP 插件化。 +- 不做复杂自由绘图标注编辑器。 +- 不做跨项目画布资产迁移。 + +### 3.3 实施前必须理解的现有画布功能 + +本次升级不能只理解“画布能放图片”,还必须尊重现有工具的真实语义。当前画布至少包含这些功能面: + +- 普通无限画布:选择、平移、缩放、撤销、重做、添加图片、添加文本、新建画布。 +- 图片节点:上传、预览、选中、移动、缩放、批量下载、添加到对话框、AI 编辑、局部编辑、裁剪、调整、备注、重命名、删除、下载、前后对比。 +- 视频工作流画布:保存、云文件、标记面板、连线工具、添加图片输入、脚本节点、视频输入、工作流节点库、导入导出。 +- 子工作区入口:风格化模式、风格化 PRO、批量处理、实时画布、图像增强模式、3D 生成。 +- 其他节点:文本节点、UI Screen 节点、视频节点、工作流节点、3D 模型节点、3D 世界节点。 +- 对话框工具栏:Agent 模式、AI 详情、生图、视频、附件、深度思考、模型选择、积分、发送。 + +关键实现入口: + +- 工具栏配置:`lib/canvas-toolbar-config.ts` +- 主画布工具栏:`app/canvas/[id]/CanvasEditorLegacyPageClient.tsx` +- 画布渲染与视角:`components/canvas/CanvasWorkspace.tsx` +- 图片节点浮层工具栏:`components/canvas/nodes/ImageNode.tsx` +- 画布状态与基础操作:`store/canvas/index.ts` + +因此 Agent 第一期不应该试图覆盖全部画布功能。更合理的范围是: + +- 优先覆盖图片节点操作、选区上下文、非破坏式结果插入、版本链、视角聚焦。 +- 视频工作流、实时画布、3D 世界漫游、复杂 UI Screen 编辑先作为上下文可读,后续再开放写操作。 +- 对删除、覆盖、批量移动、导入替换项目等高风险动作默认不开放自动执行。 + +### 3.4 选中图片浮层工具栏语义盘点 + +当前选中图片浮层工具栏由 `ImageNode.tsx` 根据 `canvasToolbarConfig` 渲染。默认内联显示 7 个图片动作,其余进入“更多操作”。“添加对话”是独立按钮,不是复制图片;当前选中图片工具栏里没有“复制图片节点”能力。3D 节点里存在“复制链接”,那是复制 3D/World 资源 URL,不等于复制画布图片节点。 + +图片工具必须按行为类型分层: + +| 工具 | 当前行为 | 对原节点影响 | Agent 首期策略 | +| --- | --- | --- | --- | +| 添加对话 | 将当前图片解析成对话附件,加入聊天输入上下文 | 不改画布 | 可作为 `attach_to_chat_context`,但通常由用户点击触发 | +| 识物 | 进入图片点击识别模式,点击局部物体后识别 | 不改画布 | 首期只读上下文,不自动模拟点击 | +| 高清 | 调用增强服务,在原图右侧生成结果图 | 新建节点 | 可开放为非破坏式 `append_revision` | +| 抠图 | 移除背景,在原图右侧生成结果图 | 新建节点 | 可开放为非破坏式 `append_revision` | +| AI 洗图 | 打开快捷面板,按预设生成清洗/重绘结果 | 新建节点 | 可开放,但需把 preset/model/levels 显式协议化 | +| 文字 | OCR 检测并编辑图片中文字,生成新结果 | 新建节点,同时缓存识别项 | 可开放,但需传递 text edit items | +| 框选抠图 | 用户框选区域,确认后生成抠图结果 | 新建节点 | 可开放为 `local_region_cutout`,区域坐标必须标准化 | +| 局部编辑 | 用户框选多个区域,每个区域可有提示词和参考图,提交后生成结果 | 新建节点 | 应作为 Agent 编辑核心能力之一 | +| 尺寸 | 打开尺寸/标注编辑器,完成后生成新图 | 新建节点 | 可开放为 `resize_image`,需要明确目标尺寸 | +| 多视角 | 根据角度、俯仰、景别和提示词生成视角变体 | 新建节点 | 可开放为 `generate_angle_variant` | +| 3D 模型 | 从图片生成 `model3d` 节点并轮询任务 | 新建 3D 节点 | 后置开放,首期只保留结果插入协议兼容 | +| 3D 世界 | 从图片生成 `world3d` 节点并轮询任务 | 新建 3D 世界节点 | 后置开放,涉及独立 viewer 与资源持久化 | +| 图层拆分 | 拆出多层图片,叠放在原图右侧 | 新建多图节点 | 可读可展示,自动执行需处理多结果 `mediaItems` | +| 扩图 | 扩展画幅,在原图右侧生成结果图 | 新建节点 | 可开放为非破坏式 `append_revision` | +| 裁剪 | 打开裁剪面板,完成后上传裁剪图并替换当前节点 `src` | 修改当前节点 | 高风险,首期不自动执行或必须确认 | +| 调整 | 打开调色面板,调整值写入当前节点 `metadata.imageAdjustments`,以 CSS filter/overlay 呈现 | 修改当前节点 metadata | 可作为低风险元数据操作,但需要可撤销 | +| 翻转与旋转 | 生成翻转/旋转后的图片并替换当前节点 `src`,保留 reset 来源 | 修改当前节点 | 高风险,首期不自动执行或必须确认 | +| 重绘 | 基于当前图片和新提示词生成一张新结果 | 新建节点 | 可开放为 `repaint_from_reference` | +| 备注 | 打开备注弹窗,保存到节点数据 | 修改当前节点 metadata/data | 可开放为 `update_note` | +| 改名 | 修改图片节点标题 | 修改当前节点 title | 可开放为低风险 `rename_node` | +| 下载 | 服务端转成 PS 兼容 PNG 后触发下载 | 不改画布 | 不属于 Agent 画布写操作 | +| 删除 | 删除图片节点 | 删除节点 | 高风险,必须确认 | +| 开启对比 | 使用 `originalSrc`、连接源或 compareSourceUrl 打开前后滑杆对比 | 不改画布 | 可作为 view/UI 状态,首期不持久化 | +| 确认抠图/取消抠图 | 框选抠图模式下确认生成或退出模式 | 确认后新建节点 | 作为 `box_cutout` 子状态处理 | + +这个盘点直接影响 Agent 操作白名单: + +- 首期默认允许:高清、抠图、去水印、扩图、重绘、局部编辑、框选抠图、尺寸、多视角、备注、改名、插入新结果、聚焦视角。 +- 首期需要确认:裁剪、翻转旋转、删除、覆盖当前图、批量移动、导入替换。 +- 首期暂不自动化:识物点击、3D 模型、3D 世界、实时画布内部画笔/图层、视频工作流复杂节点执行。 + +### 3.5 对 Agent 操作协议的约束 + +基于现有工具语义,`CanvasOperationPlan` 不能只设计成“add/update/delete node”三类粗操作。至少要表达: + +- 操作目标:当前选中图、指定节点、slot、区域、最近生成结果、连接源。 +- 操作风险:`non_destructive`、`metadata_only`、`replace_media`、`delete`、`batch_layout`。 +- 结果落点:右侧追加、填充 slot、当前视口中心、多结果网格、覆盖前确认。 +- 版本关系:source node、revision edge、compare source、生成参数、任务 ID。 +- 区域坐标:局部编辑和框选抠图必须使用图片归一化坐标,不使用屏幕像素。 +- 多结果:图层拆分、三版变体等必须支持 `mediaItems` 或多条 operation,不能只支持单个 `media`。 + +换句话说,Agent 模型可以决定“做什么”,但执行器必须知道“这类动作在现有画布里到底会不会改原节点”。这是防止误改画布的底线。 + +--- + +## 4. 总体架构 + +### 4.1 升级前 + +```mermaid +flowchart LR + A["CanvasChatPanel"] --> B["contextPayload"] + B --> C["Chat API"] + C --> D["Agent Graph"] + D --> E["Image / Video Executor"] + E --> F["Assistant Message Media"] + F --> G["前端同步回画布"] +``` + +问题:写回画布依赖结果消息与前端约定,Agent 不能明确声明“我要填充哪个 slot / 放到哪个锚点旁边 / 形成哪个版本链”。 + +### 4.2 升级后 + +```mermaid +flowchart LR + A["CanvasWorkspace / CanvasChatPanel"] --> B["Canvas Agent Context"] + B --> C["Chat API"] + C --> D["Agent Graph"] + D --> E["Direct Agent Executor"] + E --> F["Canvas Operation Plan"] + F --> G["Canvas Mutation API"] + G --> H["CanvasProject.canvasData"] + H --> I["CanvasWorkspace Revalidate"] +``` + +关键变化: + +- `contextPayload` 升级为可扩展的 `CanvasAgentContext`。 +- Agent 产出 `CanvasOperationPlan`。 +- 执行器调用服务端 mutation,而不是只靠前端消息媒体同步。 +- 画布写入形成明确 metadata 与边关系。 + +### 4.3 对话框驱动画布执行链路 + +对话框驱动画布的完整闭环如下: + +```mermaid +sequenceDiagram + participant U as 用户 + participant C as CanvasChatPanel + participant A as Chat API / Agent + participant E as Executor + participant M as Canvas Mutation API + participant W as CanvasWorkspace + + U->>C: 输入“把选中图右边再生成3个版本” + C->>A: message + CanvasAgentContext + A->>A: 解析目标图与画布意图 + A->>E: 执行图片生成或编辑 + E->>A: 返回生成媒体与 CanvasOperationPlan + A->>M: applyCanvasOperationPlan + M->>M: 校验权限、计算位置、写入节点与边 + M->>A: 返回 createdNodeIds / canvasData + A->>C: stream data-canvasOperation + A->>C: stream data-canvasViewOperation + C->>W: revalidate 或 patch 本地画布 + C->>W: 聚焦 / 缩放 / 平移视角 + W->>U: 展示新节点、版本连线与正确视角 +``` + +关键原则: + +- Agent 只产出意图明确的 `CanvasOperationPlan`。 +- Canvas Mutation API 负责真正修改画布,避免 Agent 任意写 JSON。 +- 视角控制通过 `CanvasViewOperationPlan` 在前端执行,不写入项目数据。 +- 高风险操作必须经过确认门。 +- 默认不破坏原图、原节点、原批注。 + +--- + +## 5. 数据协议设计 + +### 5.1 新增类型文件 + +建议新增: + +```text +types/canvas/agent-protocol.ts +``` + +### 5.2 CanvasAgentContext + +```ts +export interface CanvasAgentContext { + version: 1; + source: "canvas" | "realtime-canvas"; + projectId?: string; + viewport?: CanvasViewportContext; + selectedNodeIds: string[]; + selectedNodes: CanvasAgentNodeRef[]; + candidateNodes: CanvasAgentNodeRef[]; + slots: CanvasAgentSlotRef[]; + annotations: CanvasAgentAnnotationRef[]; + focusPolicy: "auto" | "selected" | "latest_result" | "original" | "slot"; +} +``` + +### 5.3 CanvasAgentNodeRef + +```ts +export interface CanvasAgentNodeRef { + nodeId: string; + type: "image" | "video" | "text" | "ui-screen" | "workflow" | "model3d" | "world3d" | "agent-slot"; + 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; + }; +} +``` + +### 5.4 CanvasAgentSlotRef + +```ts +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">; +} +``` + +### 5.5 CanvasAgentAnnotationRef + +```ts +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; +} +``` + +### 5.6 CanvasOperationPlan + +```ts +export type CanvasOperationKind = + | "fill_slot" + | "insert_beside" + | "append_revision" + | "replace_node_media" + | "add_annotation" + | "link_nodes" + | "move_nodes" + | "update_text"; + +export interface CanvasOperationPlan { + version: 1; + operation: CanvasOperationKind; + projectId: string; + sourceNodeIds?: string[]; + targetSlotId?: string; + anchorNodeId?: string; + placement?: "right" | "left" | "below" | "above" | "viewport-center"; + preserveOriginal?: boolean; + media?: { + type: "image" | "video"; + url: string; + assetId?: string; + width?: number; + height?: number; + }; + metadata?: Record; +} +``` + +### 5.7 CanvasViewOperationPlan + +视角控制不应该混进 `CanvasOperationPlan`。它不修改 `canvasProject.canvasData`,只改变当前用户客户端的 viewport。 + +建议新增: + +```ts +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; + operation: CanvasViewOperationKind; + projectId?: string; + nodeIds?: string[]; + anchorNodeId?: string; + zoom?: number; + padding?: number; + durationMs?: number; + center?: { + x: number; + y: number; + }; + reason?: string; +} +``` + +设计原则: + +- `focus_node`:聚焦单个节点,适合“放大这张图继续编辑”。 +- `focus_nodes`:缩放到多个节点的包围盒,适合“看这几张对比图”。 +- `focus_selection`:聚焦当前选区。 +- `focus_latest_result`:生成完成后自动聚焦新结果。 +- `fit_all`:缩小到画布主要内容范围,适合“看全局”。 +- `zoom_in` / `zoom_out` / `zoom_to`:只调缩放。 +- `pan_to`:平移到画布坐标或节点附近。 + +第一期推荐只实现: + +- `focus_node` +- `focus_selection` +- `focus_latest_result` +- `fit_all` +- `zoom_in` +- `zoom_out` + +### 5.8 首期受控操作白名单 + +Agent 首期只能请求以下操作,后端 mutation 层也只接受这些操作: + +| 操作 | 用途 | 默认风险等级 | 是否需要确认 | +| --- | --- | --- | --- | +| `fill_slot` | 填充选中的图片/视频/UI 槽位 | 低 | 否 | +| `insert_beside` | 在锚点旁边插入新结果 | 低 | 否 | +| `append_revision` | 基于源节点追加一个新版本 | 低 | 否 | +| `link_nodes` | 给两个节点建立参考/版本关系 | 低 | 否 | +| `update_text` | 修改选中文本节点内容 | 中 | 视情况 | +| `replace_node_media` | 替换已有图片/视频内容 | 高 | 是 | +| `move_nodes` | 批量移动或整理节点 | 中 | 视情况 | + +第一版正式实现时,建议只开放: + +- `fill_slot` +- `insert_beside` +- `append_revision` +- `link_nodes` + +这样可以先把“对话框驱动画布”跑通,同时把误操作风险降到最低。 + +视角操作第一版同步开放: + +- `focus_node` +- `focus_selection` +- `focus_latest_result` +- `fit_all` +- `zoom_in` +- `zoom_out` + +这类操作不修改画布数据,风险较低,可以作为对话框 Agent 体验的第一批增强能力。 + +--- + +## 6. 画布节点升级 + +### 6.1 新增 NodeType + +修改: + +```text +types/canvas/index.ts +``` + +新增: + +```ts +export type NodeType = + | "message" + | "image" + | "video" + | "text" + | "ui-screen" + | "workflow" + | "scene-fusion" + | "model3d" + | "world3d" + | "agent-slot"; +``` + +### 6.2 新增 AgentSlotNodeData + +```ts +export interface AgentSlotNodeData { + slotType: "image" | "video" | "ui-screen"; + label?: string; + status: "empty" | "generating" | "completed" | "failed"; + acceptedSources?: Array<"text" | "image" | "selection" | "annotation">; + fillPolicy?: "replace-empty" | "append-version" | "replace-existing"; + filledNodeIds?: string[]; + metadata?: Record; +} +``` + +### 6.3 slot 的行为 + +空 slot: + +- 显示虚线框。 +- 显示图片/视频/UI 图标。 +- 可调整大小。 +- 可被选中。 +- 可作为 Agent 输出目标。 + +填充后: + +- 默认不删除 slot。 +- 生成结果作为 slot 子结果或相邻结果。 +- `filledNodeIds` 记录生成节点。 +- 如果 `fillPolicy = replace-existing`,才替换已有结果。 + +--- + +## 7. 前端改造 + +### 7.1 CanvasChatPanel context builder 升级 + +当前位置: + +```text +components/canvas/CanvasChatPanel.tsx +``` + +现有 `buildContextPayload()` 已经输出: + +- `source` +- `projectId` +- `selectedNodeIds` +- `selectedAssetIds` +- `focusPolicy` +- `canvasImageNodes` + +升级为: + +```ts +function buildCanvasAgentContext(): CanvasAgentContext | undefined +``` + +输出: + +- selected nodes +- candidate image/video/ui nodes +- selected slots +- visible viewport +- annotation summaries +- focus policy + +兼容期内同时保留旧字段: + +```ts +return { + ...legacyContextPayload, + canvasAgentContext, +}; +``` + +### 7.2 schema 兼容 + +修改: + +```text +app/(chat)/api/chat/schema.ts +``` + +在 `contextPayloadSchema` 中新增: + +```ts +canvasAgentContext: z.unknown().optional() +``` + +第一期不强校验复杂结构,先在后端做 normalize,避免前后端联调成本过高。第二期再补完整 zod schema。 + +### 7.3 新增 AgentSlotNode 组件 + +建议新增: + +```text +components/canvas/nodes/AgentSlotNode.tsx +``` + +最低要求: + +- 空状态展示。 +- 支持选中态。 +- 支持 resize。 +- 支持右键菜单: + - “填充此槽位” + - “设为图片槽” + - “设为视频槽” + - “允许替换 / 保留版本” + +### 7.4 工具栏新增 slot 工具 + +修改: + +```text +components/canvas/CanvasWorkspace.tsx +app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +``` + +新增工具: + +- `agent-slot-image` +- `agent-slot-video` + +用户点击后在当前视口中心创建 slot。 + +### 7.5 画布视角控制执行器 + +当前位置已有基础能力: + +- `store/canvas/index.ts` 中已有 `setViewport()`、`zoomIn()`、`zoomOut()`、`zoomTo()`、`fitToScreen()`、`centerNode()`。 +- `components/canvas/CanvasChatPanel.tsx` 中已有 `focusCanvasOnNode()`,可作为视觉聚焦的起点。 + +建议新增前端 helper: + +```text +lib/canvas/view-operation.ts +``` + +职责: + +- 根据 `CanvasViewOperationPlan` 计算新的 viewport。 +- 支持按单节点或多节点 bounding box 聚焦。 +- 支持 padding,避免图片贴边。 +- 支持动画过渡,避免视角跳变。 + +建议函数: + +```ts +export function resolveCanvasViewOperation(params: { + operationPlan: CanvasViewOperationPlan; + nodes: CanvasNode[]; + viewport: Viewport; + containerSize: { width: number; height: number }; +}): Viewport | null; +``` + +前端收到 `data-canvasViewOperation` 后: + +1. 根据 `nodeIds` 找到节点。 +2. 计算包围盒。 +3. 调用 `setViewport()`。 +4. 必要时同步 `selectNodes()`,让用户知道 Agent 聚焦的是谁。 + +视角控制不需要保存到数据库,除非后续要做“共享演示视角”。 + +--- + +## 8. 后端改造 + +### 8.1 新增 normalize 模块 + +建议新增: + +```text +lib/canvas/agent-context.ts +``` + +职责: + +- 从旧 `contextPayload` 和新 `canvasAgentContext` 中归一化上下文。 +- 兼容没有新字段的前端。 +- 限制节点数量,防止请求过大。 +- 提供 helper: + - `getSelectedSlots()` + - `getSelectedMediaNodes()` + - `getBestAnchorNode()` + - `getCanvasAgentFocusPolicy()` + +### 8.2 新增 mutation 服务 + +建议新增: + +```text +lib/canvas/agent-mutations.ts +``` + +核心函数: + +```ts +export async function applyCanvasOperationPlan(params: { + userId: string; + plan: CanvasOperationPlan; +}): Promise +``` + +内部流程: + +1. 读取 `canvasProject`。 +2. 校验 userId 权限。 +3. 校验 source/slot/anchor 节点存在。 +4. 计算插入位置。 +5. 创建或更新节点。 +6. 创建 edge。 +7. 写入 metadata。 +8. 调用 `syncCanvasNodesForProject()`。 +9. 保存 `canvasProject.canvasData`。 + +### 8.3 新增 API + +建议新增: + +```text +app/api/canvas/[id]/agent-operations/route.ts +``` + +接口: + +```http +POST /api/canvas/:id/agent-operations +``` + +请求: + +```json +{ + "operationPlan": { + "version": 1, + "operation": "append_revision", + "projectId": "...", + "sourceNodeIds": ["..."], + "anchorNodeId": "...", + "placement": "right", + "preserveOriginal": true, + "media": { + "type": "image", + "url": "https://..." + } + } +} +``` + +返回: + +```json +{ + "success": true, + "data": { + "createdNodeIds": ["..."], + "updatedNodeIds": [], + "createdEdgeIds": ["..."], + "canvasData": {} + } +} +``` + +--- + +## 9. Agent 编排改造 + +### 9.1 AutoAgentInput 扩展 + +修改: + +```text +lib/ai/auto-agent-graph.ts +``` + +新增: + +```ts +canvasAgentContext?: CanvasAgentContext | null; +selectedSlotCount?: number; +hasSelectedAgentSlot?: boolean; +``` + +规划提示词中新增规则: + +- 如果存在选中的空 slot,优先 `fill_slot`。 +- 如果是编辑选中图,默认 `append_revision`。 +- 如果是生成新图且无 slot,默认 `insert_beside` 或 `viewport-center`。 +- 如果用户说“整理 / 排列 / 放到右边 / 连起来”,先输出低风险布局或连线计划,不直接改媒体内容。 +- 只有用户明确说“替换 / 覆盖 / 删除原来的”,才使用 `replace_node_media`。 + +### 9.1.1 对话指令到画布操作的映射 + +| 用户表达 | 推荐操作 | 目标解析 | +| --- | --- | --- | +| “在这个空框里生成一张图” | `fill_slot` | selected slot | +| “把选中图片背景换成白色” | `append_revision` | selected image | +| “再出三版放右边” | `insert_beside` | selected image 或 latest result | +| “按批注修改” | `append_revision` | selected image + annotations | +| “把这两张连起来” | `link_nodes` | selected nodes | +| “把这段文字改成更高级” | `update_text` | selected text node | +| “替换掉原图” | `replace_node_media` | selected image,需要确认 | +| “把这些整理成三列” | `move_nodes` | selected nodes,建议确认 | +| “放大选中的图” | `focus_selection` | selected nodes | +| “聚焦刚生成的结果” | `focus_latest_result` | latest result | +| “缩小看全局” | `fit_all` | all visible nodes | +| “对比原图和新图” | `focus_nodes` | source + result nodes | + +### 9.2 Direct Agent Executor 扩展 + +相关文件: + +```text +lib/ai/runtime/direct-agent-execution.ts +lib/ai/runtime/direct-agent-media-executors.ts +lib/ai/runtime/direct-agent-image-edit-executor.ts +``` + +新增输出: + +```ts +canvasOperationPlan?: CanvasOperationPlan; +``` + +图片生成: + +- 有 selected slot:`fill_slot` +- 有 selected image:`insert_beside` +- 无选中:`insert_beside` 当前视口或 `viewport-center` + +图片编辑: + +- 默认 `append_revision` +- `sourceNodeIds = target refs nodeId` +- `anchorNodeId = target nodeId` +- `preserveOriginal = true` + +视频生成: + +- 有 video slot:`fill_slot` +- 有 image anchor:`insert_beside` + +### 9.3 Chat API 写回 + +修改: + +```text +app/(chat)/api/chat/route.ts +``` + +在生成/编辑成功后: + +1. 如果请求来自 canvas。 +2. 如果有 `projectId`。 +3. 如果 executor 返回 `canvasOperationPlan`。 +4. 调用 `applyCanvasOperationPlan()`。 +5. stream 一个 canvas operation chunk 给前端。 +6. 如果需要聚焦新结果或当前编辑对象,额外 stream 一个 canvas view operation chunk。 + +建议新增 stream part: + +```ts +data-canvasOperation +``` + +payload: + +```json +{ + "projectId": "...", + "operation": "append_revision", + "createdNodeIds": ["..."], + "anchorNodeId": "...", + "message": "已放到原图右侧" +} +``` + +前端收到后: + +- mutate 当前画布项目。 +- 或直接 patch 本地 store。 +- 执行 `CanvasViewOperationPlan`,把视角移动到目标节点或选区。 + +第一期建议 mutate 重新拉取,稳定优先。 + +建议新增 stream part: + +```ts +data-canvasViewOperation +``` + +payload: + +```json +{ + "operation": "focus_latest_result", + "projectId": "...", + "nodeIds": ["..."], + "zoom": 0.9, + "padding": 120, + "reason": "聚焦刚生成的新版本" +} +``` + +### 9.4 视角控制 Agent 规则 + +Agent 需要识别两类视角请求: + +1. 单纯视角请求,不触发生成或编辑: + - “放大选中的图” + - “缩小一点” + - “看全局” + - “聚焦到右边那张” +2. 执行后的自动视角请求: + - 生成完成后聚焦新图 + - 编辑完成后展示原图和新版本 + - 多版本生成后缩放到这组结果 + +规则: + +- 用户只要求视角变化时,不调用生成/编辑 executor。 +- 有选中图时,“放大这张 / 聚焦这个”优先 `focus_selection`。 +- 生成/编辑成功后,默认返回 `focus_latest_result`。 +- 如果用户要求“对比原图和新图”,返回 `focus_nodes`,包含 source 与 result。 +- 如果用户要求“看全部 / 缩小看全局”,返回 `fit_all`。 +- 如果没有选中且目标不明确,只做 `fit_all` 或提示用户先选中。 + +--- + +## 10. 标注编辑最小闭环 + +### 10.1 本期标注模型 + +不做复杂绘图系统,先支持三类: + +- 文本批注 +- 框选区域 +- 箭头指向 + +可以先存到图片节点 metadata: + +```ts +metadata: { + annotations: CanvasAgentAnnotationRef[] +} +``` + +### 10.2 前端入口 + +图片节点右键菜单新增: + +- “添加批注” +- “按批注修改” + +“按批注修改”行为: + +1. 选中当前图片。 +2. 将 annotations 放入 `CanvasAgentContext`。 +3. 自动在输入框插入一句:“按这张图上的批注修改”。 +4. 用户可直接发送,也可补充文字。 + +### 10.3 Agent 规则 + +如果目标图存在 annotations 且用户说“按批注 / 按标注 / 按箭头 / 按框选”: + +- 使用 annotations 作为编辑 brief。 +- 输出 clean revised image。 +- 不把批注文字、箭头、框线生成进新图。 +- 默认 `append_revision`。 + +--- + +## 11. 插入与布局规则 + +### 11.1 insert beside + +默认: + +- `placement = right` +- `margin = 40` +- 新节点尺寸优先匹配 anchor。 +- 如右侧重叠,继续向右寻找空位。 +- 超出合理距离后改为下方。 + +### 11.2 fill slot + +默认: + +- 新媒体节点尺寸匹配 slot。 +- 结果节点与 slot 建立 edge。 +- slot `filledNodeIds` 追加新节点。 +- slot 本身保留。 + +### 11.3 append revision + +默认: + +- 新节点放 source 右侧。 +- 创建 edge: + - `source -> newNode` + - type: `reference` +- 新节点 metadata: + +```ts +metadata: { + generatedByAgent: true, + revisionOf: sourceNodeId, + sourceNodeIds: [...], + prompt, + model, + messageId, + operation: "append_revision" +} +``` + +--- + +## 12. 澄清策略升级 + +修改: + +```text +lib/ai/runtime/middlewares/canvas-agent-clarification-gate.ts +``` + +### 12.1 默认不澄清 + +以下场景默认继续执行: + +- 单选图片 + 明确编辑意图。 +- 单选 slot + 明确生成意图。 +- 无选中 + 明确生成意图。 +- 有 annotations + “按标注修改”。 +- 生成变体、追加版本、放旁边。 + +### 12.2 必须澄清 + +以下场景必须澄清: + +- 多选图片 + 编辑意图 + 没有“全部/批量/统一”。 +- 用户说“替换/覆盖/删除”,但目标不唯一。 +- 用户只说“就这个/开始吧”,且 action 不明确。 +- 目标解析低置信度,并且会修改已有节点。 + +### 12.3 可执行但提示 + +以下场景可以执行,但回复中提示选择: + +- 无 slot 的生成:默认放当前画布空白处。 +- 无选中但有唯一候选图:默认处理唯一候选。 +- 目标图来自上一版结果:提示“已按上一版继续”。 + +--- + +## 13. 分阶段实施计划 + +### 阶段 1:协议与上下文,不写回 + +目标:前后端能稳定传输 `CanvasAgentContext`。 + +任务: + +- 新增 `types/canvas/agent-protocol.ts` +- 扩展 `contextPayloadSchema` +- 新增 `lib/canvas/agent-context.ts` +- 改造 `CanvasChatPanel.buildContextPayload()` +- 保持旧字段兼容 +- 增加单元测试: + - selected image 归一化 + - selected slot 归一化 + - annotations 归一化 + - 超量节点裁剪 + +验收: + +- 现有画布对话不受影响。 +- Chat API 日志能看到 normalized canvas agent context。 +- 未升级前端仍可调用 Chat API。 + +### 阶段 2:Agent Slot 前端能力 + +目标:用户能在画布创建和选中输出槽位。 + +任务: + +- 新增 `AgentSlotNodeData` +- 新增 `agent-slot` NodeType +- 新增 `AgentSlotNode.tsx` +- 工具栏增加图片 slot +- 右键菜单支持“填充此槽位” +- `buildCanvasAgentContext()` 输出 slots + +验收: + +- 创建 slot 后保存/刷新不丢。 +- 选中 slot 后发送“生成一张图”,后端能识别 selected slot。 +- 旧图片/视频节点不受影响。 + +### 阶段 3:Canvas Mutation API + +目标:后端能安全写回画布。 + +任务: + +- 新增 `lib/canvas/agent-mutations.ts` +- 新增 `/api/canvas/[id]/agent-operations` +- 实现: + - `insert_beside` + - `fill_slot` + - `append_revision` +- 写入 metadata 与 edges +- 调用 `syncCanvasNodesForProject()` + +验收: + +- API 能在指定 anchor 右侧插入图片节点。 +- API 能填充 slot 并记录 `filledNodeIds`。 +- API 能创建 revision edge。 +- 非当前用户无法写入。 + +### 阶段 4:Agent 执行链路接入 + +目标:生成/编辑成功后自动写回画布。 + +任务: + +- 扩展 direct executor 返回 `canvasOperationPlan` +- Chat API 成功后调用 mutation +- stream `data-canvasOperation` +- stream `data-canvasViewOperation` +- 前端收到后刷新画布 + +验收: + +- 选中 slot 后:“生成一张赛博风主图”能填充 slot。 +- 选中图片后:“换成白底”能在右侧生成新版本。 +- 原图不被覆盖。 +- 画布出现 source -> result 边。 +- 生成后视角自动聚焦新结果。 + +### 阶段 5:画布视角控制 + +目标:用户能用对话框控制画布缩放、平移和聚焦。 + +任务: + +- 新增 `CanvasViewOperationPlan` +- 新增 `lib/canvas/view-operation.ts` +- 前端消费 `data-canvasViewOperation` +- 支持: + - `focus_selection` + - `focus_latest_result` + - `focus_node` + - `fit_all` + - `zoom_in` + - `zoom_out` + +验收: + +- 用户说“放大选中的图”,画布聚焦选中节点。 +- 用户说“缩小看全局”,画布缩放到主要内容范围。 +- 生成新图后,画布自动聚焦新结果。 +- 用户说“对比原图和新图”,画布缩放到两者的共同包围盒。 + +### 阶段 6:标注编辑最小闭环 + +目标:用户能基于画布批注让 Agent 修图。 + +任务: + +- 图片节点 metadata 支持 annotations +- 图片节点 UI 支持添加文本批注/框选批注 +- `CanvasAgentContext` 输出 annotations +- Agent prompt 加入标注编辑规则 +- 编辑结果使用 `append_revision` + +验收: + +- 用户给图片加批注“背景换浅灰”,发送“按批注修改”,能生成新图。 +- 新图不包含批注文字和框线。 +- 原图与批注保留。 + +--- + +## 14. 测试计划 + +### 14.1 单元测试 + +新增或扩展: + +```text +tests/routes/canvas-chat-helpers.test.ts +``` + +覆盖: + +- canvas agent context normalize +- selected slot 优先级 +- selected image 优先级 +- annotation brief 提取 +- operation plan placement 计算 +- view operation viewport 计算 +- focus selection / fit all / zoom in / zoom out +- 多选澄清策略 + +### 14.2 API 测试 + +新增: + +```text +tests/routes/canvas-agent-operations.test.ts +``` + +覆盖: + +- 未登录 401 +- 非拥有者 404/403 +- anchor 不存在 400 +- fill slot 成功 +- append revision 成功 +- replace 需要显式 operation + +### 14.3 E2E 手测路径 + +1. 创建画布。 +2. 添加图片。 +3. 选中图片,在 Agent 对话框输入“换成白底”。 +4. 验证右侧出现新图,原图保留。 +5. 创建图片 slot。 +6. 选中 slot,输入“生成一张电商主图”。 +7. 验证结果填入 slot。 +8. 对原图添加批注,输入“按批注修改”。 +9. 验证新图放右侧且不包含批注痕迹。 +10. 选中一张图,输入“放大选中的图”,验证画布聚焦该图。 +11. 输入“缩小看全局”,验证画布缩放到主要内容范围。 +12. 输入“对比原图和新图”,验证画布同时框住两个节点。 + +--- + +## 15. 灰度与开关 + +建议新增站点设置: + +```ts +canvasAgentOperationProtocolEnabled: boolean +canvasAgentSlotEnabled: boolean +canvasAgentViewOperationEnabled: boolean +canvasAgentAnnotationEditEnabled: boolean +``` + +灰度顺序: + +1. 管理员账号开启。 +2. 内部测试账号开启。 +3. 默认开启 slot,但 mutation 写回只对 Agent 模式开启。 +4. 开启视角操作能力,只影响当前用户客户端。 +5. 全量开启非破坏式 `insert_beside` / `append_revision`。 +6. 最后开放 `replace_node_media`。 + +--- + +## 16. 风险与规避 + +### 16.1 误改画布 + +规避: + +- 默认非破坏。 +- 替换/删除必须显式。 +- metadata 记录操作来源。 + +### 16.2 画布状态过大 + +规避: + +- context 只传最多 12 个 candidate nodes。 +- annotations 最多传 20 条。 +- 后端再次裁剪。 + +### 16.3 前后端协议漂移 + +规避: + +- `types/canvas/agent-protocol.ts` 单点定义。 +- 后端 normalize 兼容旧字段。 +- 第一阶段不强制完整 zod,第二阶段补。 + +### 16.4 生成结果插入位置不稳定 + +规避: + +- 统一 placement helper。 +- 默认右侧,冲突则递进。 +- 最终 fallback 到 viewport center。 + +### 16.5 视角跳变影响用户操作 + +规避: + +- 只有用户明确要求聚焦/缩放,或生成完成后才自动聚焦。 +- 视角操作使用短动画过渡。 +- 用户正在拖拽、框选、编辑文本时不强制切换视角。 +- 提供前端开关 `canvasAgentViewOperationEnabled` 独立灰度。 + +--- + +## 17. 推荐首个 PR 切分 + +### PR 1:协议与 normalize + +- 新增协议类型 +- 扩展 schema +- 前端输出 `canvasAgentContext` +- 后端 normalize +- 测试 normalize + +### PR 2:Agent Slot 节点 + +- 新增 NodeType +- 新增节点组件 +- 工具栏创建 slot +- 保存/加载验证 + +### PR 3:Canvas Mutation API + +- 新增 mutation service +- 新增 API route +- 支持 `insert_beside` / `fill_slot` / `append_revision` +- API 测试 + +### PR 4:Agent 生成写回 + +- direct executor 生成 operation plan +- Chat API 调 mutation +- 前端刷新画布 + +### PR 5:画布视角控制 + +- 新增 view operation 类型 +- 新增 view operation resolver +- Chat API stream `data-canvasViewOperation` +- 前端执行聚焦、缩放、看全局 + +### PR 6:标注编辑 MVP + +- annotation metadata +- 图片节点批注入口 +- Agent 按批注编辑 +- append revision 验证 + +--- + +## 18. 最小可交付定义 + +第一版上线只要满足: + +1. 用户能在画布创建一个图片 slot。 +2. 选中 slot 后,在对话框说“生成一张 xxx”,结果能填充 slot。 +3. 选中图片后,说“把背景换成白色”,结果能放在原图右侧。 +4. 原图不被覆盖。 +5. 新图 metadata 能记录 source、prompt、model、messageId。 +6. 刷新页面后版本关系仍在。 +7. 对话框能执行“放大选中的图 / 缩小看全局 / 聚焦刚生成结果”。 + +这 7 点完成,就已经从“结果同步到画布”升级到了“Agent 按画布意图工作”。 diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index 4885e4fbf..b13baa40f 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -129,6 +129,9 @@ const LOCAL_EDIT_COMPOSITE_DIFF_HIGH_THRESHOLD = 34; const SCALE_ADJUST_VISUAL_DIFF_THRESHOLD = 14; const SCALE_ADJUST_OUTSIDE_DIFF_THRESHOLD = 18; const SCALE_ADJUST_OUTSIDE_DIFF_MIN_CHANGED_RATIO = 0.28; +const SCALE_ADJUST_OUTSIDE_PIXEL_DIFF_THRESHOLD = 16; +const SCALE_ADJUST_OUTSIDE_PIXEL_CHANGED_RATIO_THRESHOLD = 0.045; +const SCALE_ADJUST_OUTSIDE_PIXEL_MEAN_DIFF_THRESHOLD = 4.5; const SCALE_ADJUST_OUTSIDE_PROTECTED_MARGIN = 0.015; const SCALE_ADJUST_DIFF_SAMPLE_SIZE = 112; @@ -632,6 +635,89 @@ function isScaleAdjustOutsideChanged(scores: number[]) { return changedCount >= requiredCount; } +async function getScaleAdjustOutsidePixelChangeStats(params: { + generatedBuffer: Buffer; + sourceBuffer: Buffer; + sourceWidth: number; + sourceHeight: number; + verification: CanvasScaleAdjustVerificationConfig; +}) { + const { generatedBuffer, sourceBuffer, sourceHeight, sourceWidth, verification } = + params; + const protectedRegions = getScaleAdjustProtectedRegions(verification); + const normalizeToRaw = (buffer: Buffer) => + sharp(buffer, { failOn: "none" }) + .rotate() + .resize(sourceWidth, sourceHeight, { + fit: "cover", + position: "center", + }) + .removeAlpha() + .toColorspace("srgb") + .raw() + .toBuffer(); + const [sourceRaw, generatedRaw] = await Promise.all([ + normalizeToRaw(sourceBuffer), + normalizeToRaw(generatedBuffer), + ]); + const step = Math.max(2, Math.ceil(Math.max(sourceWidth, sourceHeight) / 1200)); + let changedPixelCount = 0; + let sampledPixelCount = 0; + let diffSum = 0; + + for (let y = 0; y < sourceHeight; y += step) { + const normalizedY = y / sourceHeight; + for (let x = 0; x < sourceWidth; x += step) { + const normalizedX = x / sourceWidth; + if ( + protectedRegions.some( + (region) => + normalizedX >= region.x && + normalizedX <= region.x + region.width && + normalizedY >= region.y && + normalizedY <= region.y + region.height + ) + ) { + continue; + } + const rawIndex = (y * sourceWidth + x) * 3; + const diff = + Math.abs(generatedRaw[rawIndex] - sourceRaw[rawIndex]) * 0.299 + + Math.abs(generatedRaw[rawIndex + 1] - sourceRaw[rawIndex + 1]) * 0.587 + + Math.abs(generatedRaw[rawIndex + 2] - sourceRaw[rawIndex + 2]) * 0.114; + diffSum += diff; + sampledPixelCount += 1; + if (diff >= SCALE_ADJUST_OUTSIDE_PIXEL_DIFF_THRESHOLD) { + changedPixelCount += 1; + } + } + } + + const changedRatio = + sampledPixelCount > 0 ? changedPixelCount / sampledPixelCount : 0; + const meanDiff = sampledPixelCount > 0 ? diffSum / sampledPixelCount : 0; + + return { + changedPixelCount, + changedRatio, + meanDiff, + sampledPixelCount, + step, + }; +} + +function isScaleAdjustOutsidePixelChanged(params: { + changedRatio: number; + meanDiff: number; + sampledPixelCount: number; +}) { + if (params.sampledPixelCount < 100) return false; + return ( + params.changedRatio >= SCALE_ADJUST_OUTSIDE_PIXEL_CHANGED_RATIO_THRESHOLD && + params.meanDiff >= SCALE_ADJUST_OUTSIDE_PIXEL_MEAN_DIFF_THRESHOLD + ); +} + async function loadScaleAdjustSourceImage( verification: CanvasScaleAdjustVerificationConfig ) { @@ -664,13 +750,86 @@ async function applyScaleAdjustVerificationToImages(params: { const { images, verification, debugLabel } = params; if (!verification || images.length === 0) return images; - console.info("[Canvas Generate Image] Scale adjust verification disabled:", { - debugLabel, - imageCount: images.length, - protectedRegionCount: verification.protectedRegions?.length || 0, - regionCount: verification.regions.length, - scalePercent: verification.scalePercent, - }); + const sourceImage = await loadScaleAdjustSourceImage(verification); + if (!sourceImage) { + console.warn("[Canvas Generate Image] Skip scale adjust result guard: source image unavailable", { + debugLabel, + imageCount: images.length, + regionCount: verification.regions.length, + scalePercent: verification.scalePercent, + }); + return images; + } + + for (const [index, image] of images.entries()) { + const generatedUrl = image.originalUrl || image.thumbnailUrl; + const generatedBuffer = await fetchImageBufferFromUrl(generatedUrl); + const [diffScores, outsideDiffScores, outsidePixelStats] = await Promise.all([ + getScaleAdjustDiffScores({ + generatedBuffer, + regions: verification.regions, + sourceBuffer: sourceImage.sourceBuffer, + sourceHeight: sourceImage.sourceHeight, + sourceWidth: sourceImage.sourceWidth, + }), + getScaleAdjustOutsideDiffScores({ + generatedBuffer, + sourceBuffer: sourceImage.sourceBuffer, + sourceHeight: sourceImage.sourceHeight, + sourceWidth: sourceImage.sourceWidth, + verification, + }), + getScaleAdjustOutsidePixelChangeStats({ + generatedBuffer, + sourceBuffer: sourceImage.sourceBuffer, + sourceHeight: sourceImage.sourceHeight, + sourceWidth: sourceImage.sourceWidth, + verification, + }), + ]); + const changedInside = diffScores.some( + (score) => score >= SCALE_ADJUST_VISUAL_DIFF_THRESHOLD + ); + const outsideChanged = + isScaleAdjustOutsideChanged(outsideDiffScores) || + isScaleAdjustOutsidePixelChanged(outsidePixelStats); + + console.info("[Canvas Generate Image] Scale adjust result guard:", { + debugLabel, + changedInside, + imageIndex: index, + outsideChanged, + outsideDiffScores: outsideDiffScores.map((score) => Number(score.toFixed(2))), + outsidePixelStats: { + changedPixelCount: outsidePixelStats.changedPixelCount, + changedRatio: Number(outsidePixelStats.changedRatio.toFixed(4)), + meanDiff: Number(outsidePixelStats.meanDiff.toFixed(2)), + sampledPixelCount: outsidePixelStats.sampledPixelCount, + step: outsidePixelStats.step, + }, + protectedRegionCount: verification.protectedRegions?.length || 0, + regionCount: verification.regions.length, + regionDiffScores: diffScores.map((score) => Number(score.toFixed(2))), + scalePercent: verification.scalePercent, + }); + + if (outsideChanged) { + throw new ScaleAdjustOutputRejectedError({ + diffScores, + outsideDiffScores, + reason: "outside-frame-changed", + }); + } + + if (!changedInside) { + throw new ScaleAdjustOutputRejectedError({ + diffScores, + outsideDiffScores, + reason: "no-effective-scale-change", + }); + } + } + return images; } @@ -1710,30 +1869,17 @@ export async function generateCanvasImage( } const baseImages = collectedImages.slice(0, targetImageCount); - const shouldCompositeBeforeScaleAdjustVerification = Boolean( - scaleAdjustVerification && localEditComposite - ); - const protectedImages = shouldCompositeBeforeScaleAdjustVerification - ? await applyLocalEditCompositeToImages({ - composite: localEditComposite, - debugLabel, - images: baseImages, - }) - : baseImages; - const scaleCheckedImages = await applyScaleAdjustVerificationToImages({ debugLabel, - images: protectedImages, + images: baseImages, verification: scaleAdjustVerification, }); - const finalImages = shouldCompositeBeforeScaleAdjustVerification - ? scaleCheckedImages - : await applyLocalEditCompositeToImages({ - composite: localEditComposite, - debugLabel, - images: scaleCheckedImages, - }); + const finalImages = await applyLocalEditCompositeToImages({ + composite: localEditComposite, + debugLabel, + images: scaleCheckedImages, + }); console.info("[Canvas Generate Image] Completed:", { debugLabel, elapsedMs: Date.now() - startedAt,