From 6f452405993e73f28fdf5d7badd0cd75da8d942d Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Mon, 21 Sep 2026 21:13:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E7=94=BB=E5=B8=83=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E7=94=9F=E6=88=90=20-=20=E4=BF=AE=E5=A4=8D=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E4=B8=A2=E5=A4=B1=E3=80=81=E8=B5=84=E6=BA=90=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E5=8F=8A=E5=88=B7=E6=96=B0=E7=8A=B6=E6=80=81=E4=B8=8D?= =?UTF-8?q?=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/docs/backend/code-map.mdx | 4 +- docs/content/docs/progress/pending-test.mdx | 12 +++ docs/design/canvas-consistency-repair.mdx | 40 +++++++++ docs/index.md | 2 + web/src/lib/canvas/canvas-editor-state.ts | 16 ++++ .../lib/canvas/canvas-generation-layout.ts | 2 +- .../lib/canvas/canvas-generation-result.ts | 32 +++++++ .../lib/canvas/canvas-generation-task-sync.ts | 5 +- .../lib/canvas/canvas-image-batch-retry.ts | 34 ++++--- web/src/lib/canvas/canvas-node-visibility.ts | 32 +++++++ web/src/lib/canvas/canvas-performance-mode.ts | 4 +- .../lib/canvas/canvas-project-generation.ts | 29 +----- .../lib/canvas/canvas-resource-references.ts | 50 +++++------ web/src/lib/canvas/canvas-task-state.ts | 36 ++++++++ .../canvas-image-generation-executor.ts | 17 ++-- web/src/pages/canvas/project.tsx | 22 ++--- web/src/pages/canvas/use-canvas-generation.ts | 83 ++++++++--------- .../pages/canvas/use-canvas-render-model.ts | 33 +++---- .../services/canvas-generation-consumer.ts | 32 +++++-- web/test/canvas-editor-consistency.test.ts | 88 +++++++++++++++++++ web/test/canvas-generation-layout.test.ts | 4 + web/test/canvas-generation-task-sync.test.ts | 8 +- web/test/canvas-image-batch-retry.test.ts | 26 ++++++ .../canvas-node-generation-mentions.test.ts | 36 +++++++- .../generation-storage-consistency.test.ts | 15 +++- .../generation-storage-consistency.worker.ts | 50 ++++++++++- 26 files changed, 533 insertions(+), 179 deletions(-) create mode 100644 docs/design/canvas-consistency-repair.mdx create mode 100644 web/src/lib/canvas/canvas-editor-state.ts create mode 100644 web/src/lib/canvas/canvas-generation-result.ts create mode 100644 web/src/lib/canvas/canvas-node-visibility.ts create mode 100644 web/src/lib/canvas/canvas-task-state.ts create mode 100644 web/test/canvas-editor-consistency.test.ts diff --git a/docs/content/docs/backend/code-map.mdx b/docs/content/docs/backend/code-map.mdx index bb5e7a519..320504bfc 100644 --- a/docs/content/docs/backend/code-map.mdx +++ b/docs/content/docs/backend/code-map.mdx @@ -66,7 +66,9 @@ description: 从产品功能快速定位主要代码入口 | 3D 导演台 | `web/src/components/canvas/director/` | | 节点生成上下文 | `web/src/components/canvas/canvas-node-generation.ts` | | 布局和连线算法 | `web/src/lib/canvas/canvas-layout.ts`、`canvas-frame.ts` | -| 任务结果回填 | `web/src/lib/canvas/canvas-generation-task-sync.ts` | +| 任务结果准备与按节点提交 | `web/src/lib/canvas/canvas-generation-task-sync.ts`、`canvas-generation-result.ts`;持久化协调在 `web/src/services/canvas-generation-consumer.ts` | +| 编辑命令与批次状态投影 | `web/src/lib/canvas/canvas-editor-state.ts`、`canvas-task-state.ts`、`canvas-image-batch-retry.ts` | +| 视口节点选择与输入关系解析 | `web/src/lib/canvas/canvas-node-visibility.ts`、`canvas-resource-references.ts` | | 本地持久化状态 | `web/src/stores/canvas/use-canvas-store.ts` | | 云端 Agent 运行与事件 API | `web/src/services/api/agent.ts`、`web/src/components/canvas/canvas-cloud-agent-panel.tsx` | | Agent 画布上下文、操作与历史 | `web/src/lib/canvas/canvas-context-discovery.ts`、`canvas-operation-contract.ts`、`web/src/pages/canvas/use-canvas-operation-history.ts` | diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 364104f1e..7c21177f0 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,18 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 批量生图、刷新恢复与画布节点一致性 + +- [x] 专项回归 127 项通过,覆盖同步编辑命令、按节点结果提交、批次汇总、资源槽位、空间索引、相邻媒体逻辑、revision 合并与存储清理;前端类型检查通过。 +- [x] 真实生成消费及持久化模块集成测试:两张成功、三张失败,在结果保存期间新增节点、改名和移动;重新读取模拟浏览器存储后,结果与用户编辑均保留。该测试曾复现“内存有节点、存储缺节点”,修复后通过。 +- [ ] 使用真实登录账号连接两张已有参考图,提示词包含 `@图片1` 和 `@图片2`,批量生成五张并重试失败子图;验证生成、提示词标签和参考图顺序一致,分组父图不冒充输入。 +- [ ] 真实任务两成三败后刷新,检查两张成功图、三个失败节点、父图失败数量和素材 ID;在生成中拖动、改名、新增、删除其他节点,保存后刷新复核。 +- [ ] 模拟预览尚未恢复但已有 storageKey,取消或重新生成批次,确认不会删除成功图片;删除后重新分组的子图不受旧批次清理影响。 +- [ ] 真实大画布在明暗主题下反复平移缩放,覆盖 280/420/720 原预算边界、批次折叠/展开和多选拖拽;确认可见节点不缺失,并记录极端缩小的帧率和内存。纯函数可见性测试不代替渲染性能验收。 +- [ ] 任务发现接口断网、最近任务超过 100 条、切换画布和账号时验证:不能把未知状态标成失败,旧任务不能覆盖新任务,任务中心仍可核对结果。 +- [ ] 验证浏览器真实存储故障、服务端同步失败、跨设备并发编辑及生成后立即关闭页面;本轮没有启动开发服务、访问真实用户数据或调用付费模型。 +- [ ] 历史已丢节点需单独恢复;本次修复不自动重建原位置、连线和提示词。架构与已知限制见 `docs/design/canvas-consistency-repair.mdx`。 + ## 回收站彻底删除与画布工具引用 - [ ] 素材卡片选择框恢复左上角,徽章为选择框和更多菜单让位;批量操作条取消吸顶覆盖,并与卡片网格保留间距。需在真实登录页面检查多选边框、长列表滚动、窄屏换行和明暗主题;自动浏览器访问被登录页拦截,样式约束测试不代替视觉验收。 diff --git a/docs/design/canvas-consistency-repair.mdx b/docs/design/canvas-consistency-repair.mdx new file mode 100644 index 000000000..1cdff4b24 --- /dev/null +++ b/docs/design/canvas-consistency-repair.mdx @@ -0,0 +1,40 @@ +--- +title: 画布批量生成一致性治理 +description: 节点丢失、批次状态、引用解析与持久化边界的调查和修复 +--- + +# 批量生成与画布一致性边界 + +“素材库里有图”只能证明素材存在,不能证明画布节点已经保存;“屏幕上看不见”也不能直接认定节点被删除。排查应分别核对任务终态、素材资源、编辑器节点数组、已保存画布和视口渲染列表。 + +### 已确认的问题机制 + +| 现象 | 可复现或由实现确认的原因 | 当前处理 | +| --- | --- | --- | +| 连线后批次子图提示缺少引用 | 子图继承父图提示词,但把父图到子图的分组边当成媒体输入;原来的第二张参考图因此不在子图槽位中 | 提示词展示与生成提交共用输入解析;分组边不充当参考图,子图无显式输入时继承父批次输入 | +| 并发生成覆盖其他节点或编辑 | 媒体解析跨越异步等待,备用结果回填直接替换等待前的完整节点数组 | 结果准备与提交分离,提交时按目标节点及任务身份合并到最新文档 | +| 内存存在,重新读取后新增节点或编辑消失 | 持久化结果与实时编辑合并后,整份快照被跳过保存;store 已接受新编辑,后续同内容更新也不再触发保存 | 只有与已落盘内容一致的快照才能跳过保存;合并进来的编辑继续进入普通保存队列 | +| 成功子图刷新后被错误汇总或被清理 | 多处只以 `content` 判断结果存在,忽略预览未恢复时的 `storageKey`;父图可能保留旧任务身份 | 批次结果同时识别预览与存储标识;父图统一由子图投影,不携带独立任务身份;清理验证子图归属 | +| 缩放或平移时图片不见 | 空间查询先按预算截断,再过滤折叠节点和屏外缓冲区;真实屏内节点也可能超过硬预算 | 先做可见性过滤,屏内及交互节点不按预算截断;预算只限制屏外预加载 | +| 刷新把未知任务误报为失败 | 最近任务列表不完整或查询失败,却被解释成不存在任务 | 已绑定任务独立恢复;发现失败或未命中保留原状态并提示核对任务中心,不伪造终态 | + +这些机制不等于已确认每一份用户现场数据的唯一原因。现场还需检查保存请求、账号作用域、任务 ID、资源 ID,以及刷新前后的画布快照。 + +### 职责划分 + +1. **编辑命令与 React 显示分离**:`canvas-editor-state.ts` 同步更新编辑器 ref,再向 React 发布快照。命令里的 updater 不交给 React 重放;节点和连线使用同一规则。加载文档仍可显式替换快照,任务结果不能借用这种全量替换权限。 +2. **异步结果只提交自己的变化**:`canvas-generation-task-sync.ts` 准备结果;`canvas-generation-result.ts` 校验目标节点和任务绑定,合并生成字段,保留其他节点、移动、手动尺寸和改名。目标删除或已经换任务时拒绝回填,不复活节点。 +3. **持久化继续沿用现有协调机制**:`canvas-generation-consumer.ts` 保留账号作用域锁、revision 合并、幂等标记及持久化确认;真实提交完成前不把普通编辑伪装成已确认生成效果。提交期间的新编辑进入普通保存队列,不能因为生成效果已保存就把新编辑也当作已保存。浏览器本地确认不等于服务端已同步。 +4. **父批次是子图结果的投影**:生成完成和刷新恢复都调用 `reconcileImageBatchRoot`,汇总主图、存储标识、素材 ID 和失败数量。成功与失败可以同时存在,例如两张成功、三张失败;父图不再继承旧的单图任务终态。重置任务身份放在无副作用的 `canvas-task-state.ts`,不依赖生成请求服务。 +5. **输入关系与分组关系分开解释**:`canvas-resource-references.ts` 统一解析;保留现有 Config 输入优先级和按连线顺序编号的合同,去重并防止批次循环。子图显式输入优先于父批次继承。 +6. **媒体身份不依赖预览是否恢复**:有 `storageKey` 的图片不能因为 `content` 暂时为空被当作可删除占位或空白原地生成节点。真正读取资源失败仍需报错;存储标识存在不等于文件已经成功读取。 +7. **视口只决定绘制,不决定数据存活**:`canvas-node-visibility.ts` 选择渲染节点,不修改节点数组或持久化内容。折叠等显式隐藏规则继续生效。 + +### 验证与仍需完善的边界 + +- 专项行为测试覆盖:并发回填、同步命令、已删除目标、任务重置、缺预览资源、两成三败汇总、分组引用、错误的批次归属,以及同屏 1000 节点超过 280 预算仍可见。 +- 存储集成测试调用真实生成消费、store、锁与 revision 合并实现,仅替换浏览器存储及资源输入;模拟两份结果并发提交,并在落盘期间新增、改名和移动节点,最后重新读取存储验证两张成功、三张失败及用户编辑均保留。它不是实际浏览器刷新或真实模型端到端验收。 +- 当前任务列表接口按数量返回最近记录,没有精确的画布节点历史任务查询或完整游标分页。没有 taskId 且超出发现窗口的节点不会被自动认定失败,但仍需在任务中心人工核对;后续应增加按节点及提交标识精确恢复的服务端合同,而不是无限放大列表上限。 +- 取消屏内节点硬裁剪后,极端缩小、海量节点同屏可能增加渲染负担。后续应采用保留所有节点位置的缩略层/细节层分级绘制,不能再以随机缺失节点换取性能。 +- 现有加载、远端同步、历史撤销仍保留各自入口,本次并未迁移成统一的规范化文档 store。跨设备并发编辑和立即关闭页面仍需真实场景验收。 +- 已经被旧快照覆盖的历史节点不会由本次修复自动重建。素材可用于重新插入,但原节点位置、连线及提示词只能从历史画布或可核对的任务上下文恢复,不能凭空猜测。 diff --git a/docs/index.md b/docs/index.md index 1d4355cde..046f09a30 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,8 @@ ## 设计沉淀 +- [画布批量生成一致性治理](design/canvas-consistency-repair.mdx):节点丢失、批次状态与引用解析的根因、已实施边界重构、回归证据和仍待验收的性能/同步场景。 + - [画布 Agent 外观与 Live2D](content/docs/backend/canvas-agent-appearance.mdx):独立助手名称、文案模板、模型包边界、Core 部署和验收要求。 - [云端 Agent 架构优化方案](design/cloud-agent-architecture-optimization.md):跨端生成合同、引用绑定、审批依赖、报价预算、运行存储与工作上下文的现状审查、分阶段方案和验收门槛(提案,未实施)。 diff --git a/web/src/lib/canvas/canvas-editor-state.ts b/web/src/lib/canvas/canvas-editor-state.ts new file mode 100644 index 000000000..de0a3ef36 --- /dev/null +++ b/web/src/lib/canvas/canvas-editor-state.ts @@ -0,0 +1,16 @@ +type StateUpdate = T | ((current: T) => T); + +/** 编辑命令同步提交到 ref;React 只接收快照,不执行含副作用、可被重放的 updater。 */ +export function createCanvasStateWriter( + ref: { current: T }, + publish: (snapshot: T) => void, + normalize: (before: T, after: T) => T = (_, after) => after, +) { + return (update: StateUpdate) => { + const before = ref.current; + const after = normalize(before, typeof update === "function" ? (update as (current: T) => T)(before) : update); + // 兼容先提交 ref 再发布快照的调用方;相同引用不代表 React 已收到它。 + ref.current = after; + publish(after); + }; +} diff --git a/web/src/lib/canvas/canvas-generation-layout.ts b/web/src/lib/canvas/canvas-generation-layout.ts index 2c084e005..e0e8e0aec 100644 --- a/web/src/lib/canvas/canvas-generation-layout.ts +++ b/web/src/lib/canvas/canvas-generation-layout.ts @@ -16,7 +16,7 @@ export function canGenerateImageInPlace(sourceNode: CanvasNodeData | undefined) export function canGenerateMediaInPlace(sourceNode: CanvasNodeData | undefined, mediaType: typeof CanvasNodeType.Image | typeof CanvasNodeType.Video | typeof CanvasNodeType.Audio) { if (sourceNode?.type !== mediaType) return false; if (sourceNode.metadata?.generationResultPlacement) return sourceNode.metadata.generationResultPlacement === "replace-node"; - if (!sourceNode.metadata?.content) return true; + if (!sourceNode.metadata?.content && !sourceNode.metadata?.storageKey) return true; // 兼容显式落点字段引入前创建的复制节点和版本节点。 return Boolean(sourceNode.metadata?.copiedFromNodeId || sourceNode.metadata?.versionOfNodeId); } diff --git a/web/src/lib/canvas/canvas-generation-result.ts b/web/src/lib/canvas/canvas-generation-result.ts new file mode 100644 index 000000000..22bda4e24 --- /dev/null +++ b/web/src/lib/canvas/canvas-generation-result.ts @@ -0,0 +1,32 @@ +import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas"; + +/** 异步阶段只准备结果;提交时按节点身份合并,绝不能用旧文档覆盖当前文档。 */ +export function commitCanvasGenerationResult(current: CanvasNodeData[], before: CanvasNodeData, result: CanvasNodeData, taskId: string): CanvasNodeData[] { + const live = current.find((node) => node.id === before.id); + if (!live) throw new Error("生成结果对应节点已删除,结果仍保留在任务中心"); + if (result.id !== before.id || (live.metadata?.taskId && live.metadata.taskId !== taskId) || (before.metadata?.taskId && !live.metadata?.taskId)) { + throw new Error("节点已绑定其他生成任务,旧任务结果不能覆盖当前节点"); + } + const metadata: CanvasNodeMetadata = { ...live.metadata }; + for (const key of new Set([...Object.keys(before.metadata || {}), ...Object.keys(result.metadata || {})]) as Set) { + if (JSON.stringify(before.metadata?.[key]) === JSON.stringify(result.metadata?.[key])) continue; + Object.assign(metadata, { [key]: result.metadata?.[key] }); + } + const geometryChanged = live.width !== before.width || live.height !== before.height || live.metadata?.manualSize; + const moved = live.position.x !== before.position.x || live.position.y !== before.position.y; + const merged: CanvasNodeData = { + ...live, + type: result.type === before.type ? live.type : result.type, + title: result.title === before.title || live.title !== before.title ? live.title : result.title, + width: geometryChanged ? live.width : result.width, + height: geometryChanged ? live.height : result.height, + position: moved || geometryChanged ? live.position : result.position, + metadata, + }; + const versionRootId = merged.metadata?.versionOfNodeId; + return current.map((node) => { + if (node.id === merged.id) return versionRootId ? { ...merged, metadata: { ...merged.metadata, versionPrimary: true } } : merged; + if (!versionRootId || (node.metadata?.versionOfNodeId || node.id) !== versionRootId) return node; + return { ...node, metadata: { ...node.metadata, versionPrimary: false } }; + }); +} diff --git a/web/src/lib/canvas/canvas-generation-task-sync.ts b/web/src/lib/canvas/canvas-generation-task-sync.ts index fd74ffd05..da09980fa 100644 --- a/web/src/lib/canvas/canvas-generation-task-sync.ts +++ b/web/src/lib/canvas/canvas-generation-task-sync.ts @@ -1,4 +1,5 @@ import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; +import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result"; import { fitNodeSize, nodeSizeFromRatio, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size"; import { compositeEmotionImage } from "@/lib/canvas/canvas-emotion"; import { storeGeneratedAudio } from "@/services/api/audio"; @@ -305,13 +306,13 @@ export async function syncGenerationTaskToCanvasStore(task: GenerationTask) { const updatedNode = await buildGenerationTaskNodeResult(node, task, project.nodes); const latest = useCanvasStore.getState().projects.find((item) => item.id === project.id); if (!latest?.nodes.some((item) => item.id === node.id)) return false; - useCanvasStore.getState().updateProject(project.id, { nodes: latest.nodes.map((item) => (item.id === node.id ? updatedNode : item)) }); + useCanvasStore.getState().updateProject(project.id, { nodes: commitCanvasGenerationResult(latest.nodes, node, updatedNode, task.id) }); return true; } function findGenerationTaskNode(nodes: CanvasNodeData[], task: GenerationTask, targetNodeId?: string) { const nodeId = targetNodeId || generationTaskNodeId(task); - return nodes.find((node) => node.id === nodeId || node.metadata?.taskId === task.id); + return nodeId ? nodes.find((node) => node.id === nodeId) : nodes.find((node) => node.metadata?.taskId === task.id); } function completedTaskMetadata(task: GenerationTask): CanvasNodeMetadata { diff --git a/web/src/lib/canvas/canvas-image-batch-retry.ts b/web/src/lib/canvas/canvas-image-batch-retry.ts index a24d68e3d..c5d65f93b 100644 --- a/web/src/lib/canvas/canvas-image-batch-retry.ts +++ b/web/src/lib/canvas/canvas-image-batch-retry.ts @@ -1,17 +1,22 @@ import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas"; +import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-task-state"; + +export function hasImageBatchResult(node: CanvasNodeData) { + return Boolean(node.metadata?.content || node.metadata?.storageKey); +} export function liveImageBatchChildren(root: CanvasNodeData, nodes: CanvasNodeData[]) { if (root.type !== CanvasNodeType.Image) return []; - const listed = new Set(root.metadata?.batchChildIds || []); - return nodes.filter((node) => node.id !== root.id && (node.metadata?.batchRootId === root.id || listed.has(node.id))); + // 清理是破坏性写路径,父节点的旧索引不能证明子节点仍归这个批次所有。 + return nodes.filter((node) => node.id !== root.id && node.type === CanvasNodeType.Image && node.metadata?.batchRootId === root.id); } export function retireImageBatchChildren(root: CanvasNodeData, nodes: CanvasNodeData[], connections: CanvasConnection[]) { const children = liveImageBatchChildren(root, nodes); if (!children.length) return { nodes, connections, removedIds: [] as string[] }; - const removedIds = children.filter((node) => !node.metadata?.content).map((node) => node.id); + const removedIds = children.filter((node) => !hasImageBatchResult(node)).map((node) => node.id); const removed = new Set(removedIds); - const kept = children.filter((node) => Boolean(node.metadata?.content)); + const kept = children.filter(hasImageBatchResult); const nextNodes = nodes .filter((node) => !removed.has(node.id)) .map((node) => { @@ -20,7 +25,7 @@ export function retireImageBatchChildren(root: CanvasNodeData, nodes: CanvasNode delete metadata.batchChildIds; delete metadata.primaryImageId; delete metadata.batchFailedCount; - if (!metadata.content) metadata.status = "idle"; + if (!hasImageBatchResult(node)) metadata.status = "idle"; return { ...node, metadata }; } const index = kept.findIndex((item) => item.id === node.id); @@ -42,14 +47,14 @@ export function retireImageBatchChildren(root: CanvasNodeData, nodes: CanvasNode export function cancelIncompleteImageBatch(rootId: string, childIds: string[], nodes: CanvasNodeData[], connections: CanvasConnection[]) { const childIdSet = new Set(childIds); - const removedIds = nodes.filter((node) => childIdSet.has(node.id) && !node.metadata?.content).map((node) => node.id); + const removedIds = nodes.filter((node) => childIdSet.has(node.id) && node.metadata?.batchRootId === rootId && !hasImageBatchResult(node)).map((node) => node.id); const removed = new Set(removedIds); const nextNodes = nodes .filter((node) => !removed.has(node.id)) .map((node) => { if (node.id !== rootId) return node; - const remaining = (node.metadata?.batchChildIds || []).filter((id) => !removed.has(id) && nodes.some((item) => item.id === id && item.metadata?.content)); - const hasContent = Boolean(node.metadata?.content); + const remaining = (node.metadata?.batchChildIds || []).filter((id) => !removed.has(id) && nodes.some((item) => item.id === id && item.metadata?.batchRootId === rootId && hasImageBatchResult(item))); + const hasContent = hasImageBatchResult(node); const metadata = { ...node.metadata, batchChildIds: remaining.length ? remaining : undefined, @@ -75,7 +80,7 @@ export function cancelIncompleteImageBatch(rootId: string, childIds: string[], n nextNodes.find((node) => node.id === rootId)?.metadata?.batchChildIds || [], ); const detached = nextNodes.map((node) => { - if (!childIdSet.has(node.id) || remainingIds.has(node.id)) return node; + if (!childIdSet.has(node.id) || node.metadata?.batchRootId !== rootId || remainingIds.has(node.id)) return node; const metadata = { ...node.metadata }; delete metadata.batchRootId; return { ...node, metadata }; @@ -136,7 +141,7 @@ export function reconcileImageBatchRoot(root: CanvasNodeData, nodes: CanvasNodeD .map((id) => nodeById.get(id)) .filter((node): node is CanvasNodeData => Boolean(node && node.type === CanvasNodeType.Image && node.metadata?.batchRootId === root.id)); if (!children.length) { - const metadata: CanvasNodeMetadata = { ...root.metadata }; + const metadata: CanvasNodeMetadata = resetGenerationTaskMetadata(root.metadata, root.metadata?.status); delete metadata.batchChildIds; delete metadata.batchFailedCount; delete metadata.primaryImageId; @@ -146,19 +151,21 @@ export function reconcileImageBatchRoot(root: CanvasNodeData, nodes: CanvasNodeD delete metadata.generationErrorCode; delete metadata.resourceReloadAvailable; delete metadata.failedPromptFingerprint; - if (!metadata.content) metadata.status = "idle"; + if (!hasImageBatchResult(root)) metadata.status = "idle"; return { ...root, metadata }; } - const primary = children.find((node) => node.id === root.metadata?.primaryImageId && node.metadata?.content) || children.find((node) => node.metadata?.content); + const primary = children.find((node) => node.id === root.metadata?.primaryImageId && hasImageBatchResult(node)) || children.find(hasImageBatchResult); const loading = children.some((node) => node.metadata?.status === "loading"); const failed = children.find((node) => node.metadata?.status === "error"); - const metadata: CanvasNodeMetadata = { ...root.metadata }; + // 批次父节点只是子任务的投影,不能保留上一轮单图任务的身份/终态。 + const metadata: CanvasNodeMetadata = resetGenerationTaskMetadata(root.metadata, root.metadata?.status); metadata.batchFailedCount = children.filter((node) => node.metadata?.status === "error").length; if (primary) { metadata.content = primary.metadata?.content; metadata.storageKey = primary.metadata?.storageKey; + metadata.assetId = primary.metadata?.assetId; metadata.mimeType = primary.metadata?.mimeType; metadata.bytes = primary.metadata?.bytes; metadata.naturalWidth = primary.metadata?.naturalWidth; @@ -171,6 +178,7 @@ export function reconcileImageBatchRoot(root: CanvasNodeData, nodes: CanvasNodeD } else { delete metadata.content; delete metadata.storageKey; + delete metadata.assetId; delete metadata.mimeType; delete metadata.bytes; delete metadata.naturalWidth; diff --git a/web/src/lib/canvas/canvas-node-visibility.ts b/web/src/lib/canvas/canvas-node-visibility.ts new file mode 100644 index 000000000..81362d901 --- /dev/null +++ b/web/src/lib/canvas/canvas-node-visibility.ts @@ -0,0 +1,32 @@ +import { canvasNodeBounds, type CanvasSpatialBounds, type CanvasSpatialIndex } from "@/lib/canvas/canvas-spatial-index"; +import type { CanvasNodeData } from "@/types/canvas"; + +const intersects = (a: CanvasSpatialBounds, b: CanvasSpatialBounds) => a.right > b.left && a.left < b.right && a.bottom > b.top && a.top < b.bottom; + +/** 预算只能裁减屏外预加载,不能裁掉屏内节点或正在交互的节点。 */ +export function selectCanvasVisibleNodes({ + index, nodeById, view, enter, retain, hiddenIds, retainedIds, forcedIds, budget, +}: { + index: CanvasSpatialIndex; + nodeById: ReadonlyMap; + view: CanvasSpatialBounds; + enter: CanvasSpatialBounds; + retain: CanvasSpatialBounds; + hiddenIds: ReadonlySet; + retainedIds: ReadonlySet; + forcedIds: ReadonlySet; + budget: number; +}) { + // 先做语义过滤再预算;折叠子图和屏外缓冲区不能抢占真正可见节点的名额。 + const ids = new Set([...index.query(retain), ...forcedIds]); + const visible: CanvasNodeData[] = []; + const overscan: CanvasNodeData[] = []; + for (const id of ids) { + const node = nodeById.get(id); + if (!node || hiddenIds.has(id)) continue; + const bounds = canvasNodeBounds(node); + if (forcedIds.has(id) || intersects(bounds, view)) visible.push(node); + else if (retainedIds.has(id) || intersects(bounds, enter)) overscan.push(node); + } + return [...visible, ...overscan.slice(0, Math.max(0, budget - visible.length))]; +} diff --git a/web/src/lib/canvas/canvas-performance-mode.ts b/web/src/lib/canvas/canvas-performance-mode.ts index 94a5651a5..9e395ea15 100644 --- a/web/src/lib/canvas/canvas-performance-mode.ts +++ b/web/src/lib/canvas/canvas-performance-mode.ts @@ -2,9 +2,7 @@ import { CanvasNodeType, type CanvasMediaPerformanceMode, type CanvasNodeData } const STORAGE_KEY = "canvas-media-performance-mode"; -// The DOM budget is deliberately below the connection budget. Image-heavy -// canvases pay a texture/compositing cost per mounted card, even when the -// cards themselves are already virtualized spatially. +// 屏外预加载的软预算;屏内节点不可因预算而消失,媒体仍由节点按需加载。 export const CANVAS_MAX_RENDERED_NODES = 720; export const CANVAS_MAX_RENDERED_CONNECTIONS = 5000; diff --git a/web/src/lib/canvas/canvas-project-generation.ts b/web/src/lib/canvas/canvas-project-generation.ts index a3f4fb057..352740053 100644 --- a/web/src/lib/canvas/canvas-project-generation.ts +++ b/web/src/lib/canvas/canvas-project-generation.ts @@ -170,34 +170,7 @@ export function generationTaskMetadata(task: GenerationTask): CanvasNodeMetadata }; } -// 失败节点再次提交前必须移除旧任务绑定,否则批次调度会把它误判为仍在处理。 -export function resetGenerationTaskMetadata(metadata: CanvasNodeMetadata | undefined, status: CanvasNodeMetadata["status"] = "idle"): CanvasNodeMetadata { - const next = { - ...(metadata || {}), - status, - errorDetails: undefined, - generationErrorCode: undefined, - resourceReloadAvailable: undefined, - failedPromptFingerprint: undefined, - }; - delete next.taskId; - delete next.taskClientOperationId; - delete next.retryOf; - delete next.attemptGroupId; - delete next.taskStatus; - delete next.taskProgress; - delete next.taskStage; - delete next.taskProvider; - delete next.taskStartedAt; - delete next.taskCompletedAt; - delete next.taskDurationMs; - delete next.taskErrorCode; - delete next.taskOfficialStatus; - delete next.taskReceiptRecorded; - delete next.taskCreatedAt; - delete next.taskUpdatedAt; - return next; -} +export { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-task-state"; function normalizeTaskProgress(progress: number | undefined, status: GenerationTask["status"]) { if (typeof progress === "number" && Number.isFinite(progress)) return Math.max(0, Math.min(100, Math.round(progress))); diff --git a/web/src/lib/canvas/canvas-resource-references.ts b/web/src/lib/canvas/canvas-resource-references.ts index 0d2a3e90c..a4a2dbd9e 100644 --- a/web/src/lib/canvas/canvas-resource-references.ts +++ b/web/src/lib/canvas/canvas-resource-references.ts @@ -406,6 +406,12 @@ export function buildNodeMentionReferences(node: CanvasNodeData, nodes: CanvasNo } export function buildCanvasNodeMentionReferenceMap(nodes: CanvasNodeData[], connections: CanvasConnection[], targetNodes: CanvasNodeData[] = nodes) { + const resolve = buildCanvasResourceInputResolver(nodes, connections); + return new Map(targetNodes.map((node) => [node.id, labelResourceNodes(resolve(node.id), true)])); +} + +/** 批次关系属于分组,不是媒体输入;展示与提交共用这一份输入顺序解析。 */ +function buildCanvasResourceInputResolver(nodes: CanvasNodeData[], connections: CanvasConnection[]) { const nodeById = new Map(nodes.map((node) => [node.id, node])); const resourceInputsByTargetId = new Map(); const configTargetBySourceId = new Map(); @@ -413,7 +419,7 @@ export function buildCanvasNodeMentionReferenceMap(nodes: CanvasNodeData[], conn const source = nodeById.get(connection.fromNodeId); const target = nodeById.get(connection.toNodeId); if (!source || !target) continue; - if (isResourceNode(source)) { + if (isResourceNode(source) && target.metadata?.batchRootId !== source.id) { const inputs = resourceInputsByTargetId.get(target.id) || []; inputs.push(source); resourceInputsByTargetId.set(target.id, inputs); @@ -423,15 +429,20 @@ export function buildCanvasNodeMentionReferenceMap(nodes: CanvasNodeData[], conn } } - const referencesByNodeId = new Map(); - for (const node of targetNodes) { - const configTargetId = configTargetBySourceId.get(node.id); - const configInputs = configTargetId ? (resourceInputsByTargetId.get(configTargetId) || []).filter((input) => input.id !== node.id) : []; - const ownInputs = resourceInputsByTargetId.get(node.id) || []; - const inputs = configInputs.length ? configInputs : ownInputs.filter((input) => input.id !== node.id); - referencesByNodeId.set(node.id, labelResourceNodes(inputs, true)); - } - return referencesByNodeId; + return function resolve(nodeId: string, includeSelf = false, visited = new Set()): CanvasNodeData[] { + if (visited.has(nodeId)) return []; + visited.add(nodeId); + const node = nodeById.get(nodeId); + if (!node) return []; + const configTargetId = configTargetBySourceId.get(nodeId); + const configInputs = configTargetId ? (resourceInputsByTargetId.get(configTargetId) || []).filter((input) => input.id !== nodeId) : []; + const ownInputs = (resourceInputsByTargetId.get(nodeId) || []).filter((input) => input.id !== nodeId); + if (configInputs.length) return uniqueCanvasNodes(configInputs); + if (ownInputs.length) return uniqueCanvasNodes(ownInputs); + const batchRoot = node.metadata?.batchRootId ? nodeById.get(node.metadata.batchRootId) : undefined; + if (batchRoot?.metadata?.isBatchRoot) return resolve(batchRoot.id, false, visited); + return includeSelf && isResourceNode(node) ? [node] : []; + }; } export function buildOrderedCanvasResourceReferences(nodes: CanvasNodeData[], active = true) { @@ -447,22 +458,13 @@ export function imageGenerationReferenceConnections(sourceNodeId: string, target } export function getMentionResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) { - const configInputs = getConnectedConfigResourceNodes(nodeId, nodes, connections); - if (configInputs.length) return configInputs; - const ownInputs = getContextResourceNodes(nodeId, nodes, connections); - if (ownInputs.length) return ownInputs; // 没有入边时,资源节点可以把自身当作 @图片1 / @视频1,用于图生图、视频再编辑。 // 有入边时仍只暴露上游,避免自身把槽位序号挤掉。 - const self = nodes.find((node) => node.id === nodeId); - return self && isResourceNode(self) ? [self] : []; + return buildCanvasResourceInputResolver(nodes, connections)(nodeId, true); } export function getGenerationResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) { - const configInputs = getConnectedConfigResourceNodes(nodeId, nodes, connections); - if (configInputs.length) return configInputs; - const ownInputs = getContextResourceNodes(nodeId, nodes, connections); - if (ownInputs.length) return ownInputs; - return []; + return buildCanvasResourceInputResolver(nodes, connections)(nodeId); } /** 收集节点自身及其上游链路中的视频节点,用于时间线片段导入定位真正的视频源。 */ @@ -518,12 +520,6 @@ export function reorderCanvasResourceConnections(targetNodeId: string, orderedNo return next; } -function getConnectedConfigResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) { - const configConnection = connections.find((connection) => connection.fromNodeId === nodeId && nodes.find((node) => node.id === connection.toNodeId)?.type === CanvasNodeType.Config); - if (!configConnection) return []; - return getContextResourceNodes(configConnection.toNodeId, nodes, connections).filter((node) => node.id !== nodeId); -} - function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) { const counts: Record = { image: 0, video: 0, audio: 0, text: 0, skill: 0, character: 0, tool: 0 }; let drawingCount = 0; diff --git a/web/src/lib/canvas/canvas-task-state.ts b/web/src/lib/canvas/canvas-task-state.ts new file mode 100644 index 000000000..35be9e0d2 --- /dev/null +++ b/web/src/lib/canvas/canvas-task-state.ts @@ -0,0 +1,36 @@ +import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas"; + +/** 查询/消费异常不是任务终态。只标记恢复诊断,不覆盖成功结果或新的任务绑定。 */ +export function markCanvasTaskRecoveryUnconfirmed(node: CanvasNodeData, expectedTaskId: string | undefined, detail: string): CanvasNodeData { + if (node.metadata?.status === "success" || node.metadata?.taskId !== expectedTaskId) return node; + return { ...node, metadata: { ...node.metadata, errorDetails: detail } }; +} + +// 失败节点再次提交前必须移除旧任务绑定,否则批次调度会把它误判为仍在处理。 +export function resetGenerationTaskMetadata(metadata: CanvasNodeMetadata | undefined, status: CanvasNodeMetadata["status"] = "idle"): CanvasNodeMetadata { + const next = { + ...(metadata || {}), + status, + errorDetails: undefined, + generationErrorCode: undefined, + resourceReloadAvailable: undefined, + failedPromptFingerprint: undefined, + }; + delete next.taskId; + delete next.taskClientOperationId; + delete next.retryOf; + delete next.attemptGroupId; + delete next.taskStatus; + delete next.taskProgress; + delete next.taskStage; + delete next.taskProvider; + delete next.taskStartedAt; + delete next.taskCompletedAt; + delete next.taskDurationMs; + delete next.taskErrorCode; + delete next.taskOfficialStatus; + delete next.taskReceiptRecorded; + delete next.taskCreatedAt; + delete next.taskUpdatedAt; + return next; +} diff --git a/web/src/pages/canvas/canvas-image-generation-executor.ts b/web/src/pages/canvas/canvas-image-generation-executor.ts index 773ec1b5e..ac9b7ee86 100644 --- a/web/src/pages/canvas/canvas-image-generation-executor.ts +++ b/web/src/pages/canvas/canvas-image-generation-executor.ts @@ -2,10 +2,10 @@ import { nanoid } from "nanoid"; import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { canGenerateImageInPlace, findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout"; -import { cancelIncompleteImageBatch, retireImageBatchChildren } from "@/lib/canvas/canvas-image-batch-retry"; +import { cancelIncompleteImageBatch, hasImageBatchResult, reconcileImageBatchRoot, retireImageBatchChildren } from "@/lib/canvas/canvas-image-batch-retry"; import { buildImageGenerationNodeTitle } from "@/lib/canvas/canvas-generation-title"; import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size"; -import { canvasImageReferenceLimitError, buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation"; +import { canvasImageReferenceLimitError, buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, resetGenerationTaskMetadata, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation"; import { imageGenerationReferenceConnections } from "@/lib/canvas/canvas-resource-references"; import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission"; import { CONTENT_MODERATION_ERROR_CODE, generationFailureMetadata, type GenerationFailureMetadata } from "@/lib/generation-error"; @@ -54,7 +54,7 @@ export async function executeImageGeneration({ const count = getGenerationCount(generationConfig.count); const isConfigNode = sourceNode?.type === CanvasNodeType.Config; const isImageNode = sourceNode?.type === CanvasNodeType.Image; - const isExistingImageNode = isImageNode && Boolean(sourceNode?.metadata?.content); + const isExistingImageNode = isImageNode && hasImageBatchResult(sourceNode); const reuseSourceNode = canGenerateImageInPlace(sourceNode); const retired = reuseSourceNode && sourceNode ? retireImageBatchChildren(sourceNode, canvasNodes, canvasConnections) : { nodes: canvasNodes, connections: canvasConnections, removedIds: [] as string[] }; const workingNodes = retired.nodes; @@ -70,7 +70,7 @@ export async function executeImageGeneration({ const imageConfig = requestedImageSize || imageDefaults; // auto 图生图沿用来源节点尺寸;用户明确选择比例时必须以目标比例创建节点。 const referenceNode = referenceImages.length === 1 ? canvasNodes.find((node) => node.id === referenceImages[0].id && node.type === CanvasNodeType.Image) : undefined; - const imageSizeSource = requestedImageSize ? undefined : isImageNode && sourceNode?.metadata?.content ? sourceNode : referenceNode; + const imageSizeSource = requestedImageSize ? undefined : isExistingImageNode ? sourceNode : referenceNode; const outputNodeSize = imageSizeSource ? { width: imageSizeSource.width, height: imageSizeSource.height } : imageConfig; const parentPosition = sourceNode?.position || { x: 0, y: 0 }; const parentWidth = sourceNode?.width || parentConfig.width; @@ -95,7 +95,7 @@ export async function executeImageGeneration({ width: rootWidth, height: rootHeight, metadata: { - ...(reuseSourceNode ? sourceNode?.metadata || {} : {}), + ...resetGenerationTaskMetadata(reuseSourceNode ? sourceNode?.metadata : undefined, NODE_STATUS_LOADING), ...canvasGenerationPromptMetadata(prompt, effectivePrompt), status: NODE_STATUS_LOADING, size: generationConfig.size, @@ -105,6 +105,8 @@ export async function executeImageGeneration({ batchUsesReferenceImages: referenceImages.length > 0, primaryImageId: undefined, content: reuseSourceNode ? "" : undefined, + storageKey: undefined, + assetId: undefined, ...generationMetadata, ...styleMetadata, ...skillMetadata, @@ -145,7 +147,7 @@ export async function executeImageGeneration({ ...workingNodes.map((node) => { if (node.id !== nodeId) return node; if (isConfigNode) return { ...node, metadata: { ...node.metadata, ...canvasGenerationPromptMetadata(prompt, effectivePrompt), status: NODE_STATUS_LOADING, errorDetails: undefined } }; - if (reuseSourceNode) return { ...node, position: rootNode.position, width: rootNode.width, height: rootNode.height, title: rootNode.title, metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined } }; + if (reuseSourceNode) return { ...node, position: rootNode.position, width: rootNode.width, height: rootNode.height, title: rootNode.title, metadata: rootNode.metadata }; if (isImageNode) return node; return { ...node, @@ -211,7 +213,7 @@ export async function executeImageGeneration({ setNodes((current) => { const child = current.find((node) => node.id === targetId); const root = current.find((node) => node.id === rootId); - if (!child?.metadata?.content || !root || root.metadata?.primaryImageId) return current; + if (!child || !hasImageBatchResult(child) || !root || root.metadata?.primaryImageId) return current; const center = { x: root.position.x + root.width / 2, y: root.position.y + root.height / 2 }; const geometry = root.metadata?.locked ? {} @@ -290,6 +292,7 @@ export async function executeImageGeneration({ }; } if (node.id === rootId) { + if (count > 1) return reconcileImageBatchRoot(node, current); return { ...node, metadata: { diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index f6bbb534a..761cee54a 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -1,5 +1,6 @@ import { CanvasWorkspacePanel } from "@/components/canvas/canvas-workspace-panel"; import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state"; +import { createCanvasStateWriter } from "@/lib/canvas/canvas-editor-state"; import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -275,24 +276,14 @@ function InfiniteCanvasPage() { const directorOnboardingScope = useUserStore((state) => state.user?.id?.trim() || ""); const nodesRef = useRef([]); const [nodes, setNodesState] = useState([]); - const setNodes = useCallback>>((value) => { - if (typeof value === "function") { - setNodesState((current) => { - const next = stampCanvasNodeChanges(current, value(current)); - nodesRef.current = next; - return next; - }); - return; - } - const next = stampCanvasNodeChanges(nodesRef.current, value); - nodesRef.current = next; - setNodesState(next); - }, []); + const setNodes = useMemo(() => createCanvasStateWriter(nodesRef, setNodesState, stampCanvasNodeChanges), []); const [nodeStackOrder, setNodeStackOrder] = useState([]); const bringNodeToFront = useCallback((nodeId: string) => { setNodeStackOrder((current) => bringCanvasNodeToFront(current, nodeId)); }, []); - const [connections, setConnections] = useState([]); + const [connections, setConnectionsState] = useState([]); + const connectionsRef = useRef([]); + const setConnections = useMemo(() => createCanvasStateWriter(connectionsRef, setConnectionsState), []); const [chatSessions, setChatSessions] = useState([]); const [activeChatId, setActiveChatId] = useState(null); const [viewport, setViewport] = useState({ x: 0, y: 0, k: 1 }); @@ -370,7 +361,6 @@ function InfiniteCanvasPage() { }); }, [nodes]); - const connectionsRef = useRef(connections); const chatSessionsRef = useRef(chatSessions); const activeChatIdRef = useRef(activeChatId); const selectedNodeIdsRef = useRef(selectedNodeIds); @@ -637,8 +627,6 @@ function InfiniteCanvasPage() { }, [dialogNodeId]); useLayoutEffect(() => { - nodesRef.current = nodes; - connectionsRef.current = connections; chatSessionsRef.current = chatSessions; activeChatIdRef.current = activeChatId; selectedNodeIdsRef.current = selectedNodeIds; diff --git a/web/src/pages/canvas/use-canvas-generation.ts b/web/src/pages/canvas/use-canvas-generation.ts index 2d5b5367d..ad513b491 100644 --- a/web/src/pages/canvas/use-canvas-generation.ts +++ b/web/src/pages/canvas/use-canvas-generation.ts @@ -3,6 +3,9 @@ import { useQueryClient } from "@tanstack/react-query"; import { App } from "antd"; import { applyGenerationTaskResultToNodes, generationTaskCanReloadResource, generationTaskNodeId } from "@/lib/canvas/canvas-generation-task-sync"; +import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result"; +import { reconcileImageBatchRoot } from "@/lib/canvas/canvas-image-batch-retry"; +import { markCanvasTaskRecoveryUnconfirmed } from "@/lib/canvas/canvas-task-state"; import { applyCanvasGenerationTaskNodeEffect, isCanvasGenerationDurableAckError } from "@/services/canvas-generation-consumer"; import { consumeGenerationTaskNode, ensureCanvasNodeAsset, retryCanvasAssetSyncAfterRateLimit } from "@/services/project-asset-sync"; import { listGenerationTasks, listTaskLogs, queryGenerationTask, subscribeGenerationTasks, type GenerationTask, type TaskLog } from "@/services/api/task-center"; @@ -254,7 +257,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, current.map((node) => { if (node.id !== targetNodeId) return node; const failed = task.status === "failed" || task.status === "cancelled"; - const hasCompletedContent = task.status === "succeeded" && Boolean(node.metadata?.content); + const hasCompletedContent = task.status === "succeeded" && Boolean(node.metadata?.content || node.metadata?.storageKey); const failure = failed ? generationFailureMetadata(task.error || (task.status === "cancelled" ? "任务已取消" : "任务失败"), node.metadata?.composerContent || node.metadata?.prompt || task.prompt || "") : undefined; return { ...node, @@ -274,7 +277,8 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, const saveGeneratedAsset = useCallback( async (node: CanvasNodeData, taskId: string, signal?: AbortSignal) => { const result = await retryCanvasAssetSyncAfterRateLimit(() => ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node, source: "canvas-generation", taskId, signal }), { signal }); - setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item))); + if (signal?.aborted) return; + setNodes((current) => current.map((item) => (item.id === node.id && item.metadata?.taskId === taskId ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item))); if (domainProjectId) await queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] }); }, [domainProjectId, projectId, queryClient, setNodes], @@ -283,10 +287,13 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, const applyGenerationTaskResult = useCallback( async (nodeId: string, task: GenerationTask) => { const applyStoredTaskResult = async () => { + const signal = consumerControllerRef.current.signal; + const before = nodesRef.current.find((node) => node.id === nodeId); + if (!before) throw new Error("画布中找不到对应任务节点"); const applied = await applyGenerationTaskResultToNodes(nodesRef.current, task, nodeId); + if (signal.aborted) throw new DOMException("The operation was aborted", "AbortError"); if (!applied.updated || !applied.node) throw new Error("画布中找不到对应任务节点"); - nodesRef.current = applied.nodes; - setNodes(applied.nodes); + setNodes((current) => commitCanvasGenerationResult(current, before, applied.node!, task.id)); }; if (!task.outputs?.length && task.type === "canvas_text") { await applyStoredTaskResult(); @@ -311,7 +318,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, }, { signal: consumerControllerRef.current.signal }, ); - const currentNode = nodesRef.current.find((node) => node.id === nodeId || node.metadata?.taskId === task.id); + const currentNode = nodesRef.current.find((node) => node.id === nodeId); if (task.status === "succeeded" && (!currentNode?.metadata?.content || currentNode.metadata.status !== NODE_STATUS_SUCCESS)) { // attach effect 可能已经完成,但旧画布快照仍停留在 loading。 // 最终以节点是否真实拿到媒体结果为准,不能只信幂等记录。 @@ -370,19 +377,25 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, if (!isCurrentProject()) return; const recoveryNodes = nodesRef.current.filter((node) => { const pendingAgentContinuation = node.metadata?.agentGenerationContinuation?.status === "pending"; - const aggregateBatchRoot = node.metadata?.isBatchRoot && node.metadata.batchChildIds?.length && !node.metadata.taskId; + const aggregateBatchRoot = node.metadata?.isBatchRoot && node.metadata.batchChildIds?.length; if (aggregateBatchRoot && !pendingAgentContinuation) return false; return pendingAgentContinuation || node.metadata?.status === NODE_STATUS_LOADING || node.metadata?.errorDetails === "页面刷新后生成已中断,请重新生成。" || Boolean(node.metadata?.taskId && node.metadata.status !== NODE_STATUS_SUCCESS); }); const needsDiscovery = recoveryNodes.some((node) => !node.metadata?.taskId && !node.metadata?.agentGenerationContinuation?.taskId); - const projectTasks = needsDiscovery - ? ( - await listGenerationTasks(100, { projectId: startedProjectId }, undefined, signal).catch((error) => { - if (!isCurrentProject()) throw error; - return []; - }) - ).filter((task) => task.projectId === startedProjectId && task.type.startsWith("canvas_")) - : []; + let projectTasks: GenerationTask[] = []; + if (needsDiscovery) { + try { + projectTasks = (await listGenerationTasks(100, { projectId: startedProjectId }, undefined, signal)) + .filter((task) => task.projectId === startedProjectId && task.type.startsWith("canvas_")); + } catch (error) { + if (!isCurrentProject() || (error instanceof Error && error.name === "AbortError")) return; + // 发现接口失败不影响已有 taskId 的精确恢复,也不是“查无任务”的证据。 + message.warning({ key: `canvas-task-recovery:${startedProjectId}`, content: "任务列表暂不可用,未绑定任务的节点状态尚未确认;已绑定任务继续恢复。", duration: 6 }); + } + if (isCurrentProject() && recoveryNodes.some((node) => !node.metadata?.taskId && !node.metadata?.agentGenerationContinuation?.taskId && !projectTasks.some((task) => generationTaskNodeId(task) === node.id))) { + message.warning({ key: `canvas-task-recovery:${startedProjectId}`, content: "部分节点未在最近任务中找到,已保留原状态。请到任务中心核对结果,勿重复提交。", duration: 6 }); + } + } if (!isCurrentProject()) return; await Promise.all( recoveryNodes.map(async (node) => { @@ -391,7 +404,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, if (!taskId) { if (!isCurrentProject()) return; setNodes((current) => - isCurrentProject() ? current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: "页面刷新后找不到对应任务,请重新生成。" } } : item)) : current, + isCurrentProject() ? current.map((item) => (item.id === node.id ? markCanvasTaskRecoveryUnconfirmed(item, undefined, "最近任务中未找到对应记录,状态尚未确认;请到任务中心核对,勿重复提交。") : item)) : current, ); return; } @@ -400,7 +413,8 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, const continuationOnly = !node.metadata?.taskId && node.metadata?.agentGenerationContinuation?.taskId === taskId && !discoveredTask; try { const completed = await observeSubscribedGenerationTask(taskId, signal, (task) => { - if (isCurrentProject() && !continuationOnly) bindGenerationTask(node.id, task); + const live = nodesRef.current.find((item) => item.id === node.id); + if (isCurrentProject() && !continuationOnly && live && (!live.metadata?.taskId || live.metadata.taskId === taskId)) bindGenerationTask(node.id, task); }); if (!isCurrentProject()) return; await recoverCanvasGenerationTaskNode({ @@ -420,13 +434,12 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, setNodes((current) => isCurrentProject() ? current.map((item) => - item.id === node.id + item.id === node.id && (!item.metadata?.taskId || item.metadata.taskId === taskId) ? { ...item, metadata: { ...item.metadata, - status: continuationOnly ? item.metadata?.status : NODE_STATUS_ERROR, - ...(continuationOnly ? {} : failure), + ...(continuationOnly ? {} : markCanvasTaskRecoveryUnconfirmed(item, item.metadata?.taskId, failure.errorDetails || "任务恢复暂不可用,请到任务中心核对。").metadata), ...(item.metadata?.agentGenerationContinuation?.status === "pending" ? { agentGenerationContinuation: { ...item.metadata.agentGenerationContinuation, status: "failed" as const }, @@ -447,35 +460,12 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, setNodes((current) => isCurrentProject() ? current.map((node) => { - if (!node.metadata?.isBatchRoot || !node.metadata.batchChildIds?.length || node.metadata.taskId) return node; - const children = node.metadata.batchChildIds.map((id) => current.find((item) => item.id === id)).filter(Boolean) as CanvasNodeData[]; - const primary = children.find((item) => item.id === node.metadata?.primaryImageId && item.metadata?.content) || children.find((item) => item.metadata?.content); - const loading = children.some((item) => item.metadata?.status === NODE_STATUS_LOADING); - const failed = children.find((item) => item.metadata?.status === NODE_STATUS_ERROR); - return { - ...node, - metadata: { - ...node.metadata, - ...(primary - ? { - content: primary.metadata?.content, - storageKey: primary.metadata?.storageKey, - mimeType: primary.metadata?.mimeType, - bytes: primary.metadata?.bytes, - naturalWidth: primary.metadata?.naturalWidth, - naturalHeight: primary.metadata?.naturalHeight, - primaryImageId: primary.id, - } - : {}), - status: primary ? NODE_STATUS_SUCCESS : loading ? NODE_STATUS_LOADING : NODE_STATUS_ERROR, - errorDetails: primary ? undefined : failed?.metadata?.errorDetails || "全部图片生成失败", - }, - }; + return reconcileImageBatchRoot(node, current); }) : current, ); }, - [applyGenerationTaskResult, bindGenerationTask, nodesRef, observeSubscribedGenerationTask, setNodes], + [applyGenerationTaskResult, bindGenerationTask, message, nodesRef, observeSubscribedGenerationTask, setNodes], ); useEffect(() => { @@ -491,12 +481,13 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, await runGenerationConsumer(context.signal, async (signal) => recoverInterruptedGenerationTasks(context.projectId, signal, () => !signal.aborted && context.isCurrentProject())); }) .catch((error) => { - if (!(error instanceof Error && error.name === "AbortError")) throw error; + if (error instanceof Error && error.name === "AbortError") return; + message.warning({ key: `canvas-task-recovery:${projectId}`, content: "任务恢复暂不可用,已保留画布状态。请检查网络后刷新,或到任务中心核对结果。", duration: 6 }); }); return () => { void coordinator.abortAndDrain(); }; - }, [projectId, projectLoaded, recoverInterruptedGenerationTasks]); + }, [message, projectId, projectLoaded, recoverInterruptedGenerationTasks]); useEffect( () => () => { diff --git a/web/src/pages/canvas/use-canvas-render-model.ts b/web/src/pages/canvas/use-canvas-render-model.ts index 26bc6d149..be1878740 100644 --- a/web/src/pages/canvas/use-canvas-render-model.ts +++ b/web/src/pages/canvas/use-canvas-render-model.ts @@ -7,6 +7,7 @@ import { canvasNodeRenderBudget, canvasNodeRenderPadding, CANVAS_MAX_RENDERED_CO import { buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences, buildToolMentionReference, parseToolMentionTokens } from "@/lib/canvas/canvas-resource-references"; import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions"; import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex, type CanvasSpatialIndexEntry } from "@/lib/canvas/canvas-spatial-index"; +import { selectCanvasVisibleNodes } from "@/lib/canvas/canvas-node-visibility"; import type { Skill } from "@/services/api/skills"; import type { Asset, ImageAsset } from "@/stores/use-asset-store"; import type { DirectorScene } from "@/types/director"; @@ -154,6 +155,7 @@ export function useCanvasRenderModel({ const viewWidth = viewportSize.width / viewport.k; const viewHeight = viewportSize.height / viewport.k; return { + view: { left: viewLeft, top: viewTop, right: viewLeft + viewWidth, bottom: viewTop + viewHeight }, enter: { left: viewLeft - enterPadding, top: viewTop - enterPadding, right: viewLeft + viewWidth + enterPadding, bottom: viewTop + viewHeight + enterPadding }, retain: { left: viewLeft - retainPadding, top: viewTop - retainPadding, right: viewLeft + viewWidth + retainPadding, bottom: viewTop + viewHeight + retainPadding }, }; @@ -177,30 +179,19 @@ export function useCanvasRenderModel({ const visibleNodes = useMemo(() => { const frames: CanvasNodeData[] = []; const regular: CanvasNodeData[] = []; - const renderedNodeIds = renderedNodeIdsRef.current; - const renderBudget = canvasNodeRenderBudget(viewport.k); - const forcedNodeIds = new Set([...selectedNodeIds, ...(dragPreview?.nodeIds || [])].slice(0, renderBudget)); - const candidates = nodeSpatialIndex - .query(renderBounds.retain, renderBudget + forcedNodeIds.size) - .map((nodeId) => nodeById.get(nodeId)) - .filter((node): node is CanvasNodeData => Boolean(node)); - const candidateIds = new Set(candidates.map((node) => node.id)); - for (const nodeId of forcedNodeIds) { - if (candidateIds.has(nodeId)) continue; - const node = nodeById.get(nodeId); - if (node) candidates.push(node); - } - const prioritized = candidates.filter((node) => forcedNodeIds.has(node.id)); - const remaining = candidates.filter((node) => !forcedNodeIds.has(node.id)).slice(0, Math.max(0, renderBudget - prioritized.length)); - [...prioritized, ...remaining].forEach((node) => { - if (renderHiddenNodeIds.has(node.id)) return; - const retained = forcedNodeIds.has(node.id) || renderedNodeIds.has(node.id); - const insideEnterBounds = node.position.x + node.width > renderBounds.enter.left && node.position.x < renderBounds.enter.right && node.position.y + node.height > renderBounds.enter.top && node.position.y < renderBounds.enter.bottom; - if (!retained && !insideEnterBounds) return; + selectCanvasVisibleNodes({ + index: nodeSpatialIndex, + nodeById, + ...renderBounds, + hiddenIds: renderHiddenNodeIds, + retainedIds: renderedNodeIdsRef.current, + forcedIds: new Set([...selectedNodeIds, ...(dragPreview?.nodeIds || [])]), + budget: canvasNodeRenderBudget(viewport.k), + }).forEach((node) => { (isFrameNode(node) ? frames : regular).push(node); }); return [...frames, ...regular]; - }, [dragPreview, nodeById, nodeSpatialIndex, renderBounds, renderHiddenNodeIds, selectedNodeIds]); + }, [dragPreview, nodeById, nodeSpatialIndex, renderBounds, renderHiddenNodeIds, selectedNodeIds, viewport.k]); useEffect(() => { renderedNodeIdsRef.current = new Set(visibleNodes.map((node) => node.id)); }, [visibleNodes]); diff --git a/web/src/services/canvas-generation-consumer.ts b/web/src/services/canvas-generation-consumer.ts index 441fe85e6..e814d582c 100644 --- a/web/src/services/canvas-generation-consumer.ts +++ b/web/src/services/canvas-generation-consumer.ts @@ -1,6 +1,8 @@ import type { Dispatch, SetStateAction } from "react"; import { applyMaterializedGenerationTaskResultToNodes } from "@/lib/canvas/canvas-generation-task-sync"; +import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result"; +import { sameCanvasContent } from "@/lib/canvas/canvas-content"; import { parseCanvasStorageDocument, rebaseCanvasProjects, serializeCanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision"; import { localForageStorageForScope } from "@/lib/localforage-storage"; import { getActiveUserScope } from "@/lib/user-scope"; @@ -114,18 +116,24 @@ export async function applyCanvasGenerationTaskNodeEffect(input: { setNodes: Dispatch>; }) { throwIfAborted(input.signal); - const previousNodes = input.nodesRef.current; - const applied = await applyMaterializedGenerationTaskResultToNodes(previousNodes, input.task, input.output, input.effectKey, input.nodeId); + const snapshot = input.nodesRef.current; + const before = snapshot.find((node) => node.id === input.nodeId); + if (!before) throw new Error("画布中找不到对应任务节点"); + const applied = await applyMaterializedGenerationTaskResultToNodes(snapshot, input.task, input.output, input.effectKey, input.nodeId); if (!applied.updated || !applied.node) throw new Error("画布中找不到对应任务节点"); - const persistedProject = await persistCanvasGenerationEffect({ + throwIfAborted(input.signal); + const previousNodes = input.nodesRef.current; + const nodes = commitCanvasGenerationResult(previousNodes, before, applied.node, input.task.id); + await persistCanvasGenerationEffect({ projectId: input.projectId, effectKey: input.effectKey, previousNodes, - nodes: applied.nodes, + nodes, signal: input.signal, }); - input.nodesRef.current = persistedProject.nodes; - input.setNodes(persistedProject.nodes); + throwIfAborted(input.signal); + // 持久化已完成;最后一次 await 之后仍按节点合并,保留期间发生的编辑。 + input.setNodes((current) => commitCanvasGenerationResult(current, before, applied.node!, input.task.id)); } export async function persistCanvasOperationContinuationEffect(input: { @@ -512,11 +520,19 @@ export async function persistCanvasGenerationEffect(input: CanvasGenerationEffec const persistedProject = rebaseCommittedCanvasGenerationOntoLiveProject(scope, input.projectId, finalDocument, memoryProject, baseRevision) ?? generationCommittedProject; if (!persistedProject) throw new Error("画布项目不存在,无法确认生成副作用"); if (getActiveUserScope() === scope) { - withCanvasStorePersistenceSuppressed(() => { + const publish = () => { useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === input.projectId ? persistedProject : project)), })); - }); + }; + const durableProject = finalDocument.state.projects.find((project) => project.id === input.projectId); + // 只有已落盘的快照才能跳过普通保存。rebase 带回的编辑仍是脏数据, + // 否则 store 已接受编辑、后续 updateProject 判定无变化,刷新就会丢失。 + if (sameCanvasContent(durableProject, persistedProject) && JSON.stringify(durableProject?.viewport) === JSON.stringify(persistedProject.viewport)) { + withCanvasStorePersistenceSuppressed(publish); + } else { + publish(); + } } return persistedProject; } catch (error) { diff --git a/web/test/canvas-editor-consistency.test.ts b/web/test/canvas-editor-consistency.test.ts new file mode 100644 index 000000000..5d4097ca3 --- /dev/null +++ b/web/test/canvas-editor-consistency.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { createCanvasStateWriter } from "@/lib/canvas/canvas-editor-state"; +import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result"; +import { selectCanvasVisibleNodes } from "@/lib/canvas/canvas-node-visibility"; +import { buildCanvasSpatialIndex, canvasNodeBounds } from "@/lib/canvas/canvas-spatial-index"; +import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; +import { markCanvasTaskRecoveryUnconfirmed, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-task-state"; + +const image = (id: string, x = 0): CanvasNodeData => ({ id, type: CanvasNodeType.Image, title: id, position: { x, y: 0 }, width: 20, height: 20, metadata: { taskId: `task-${id}`, status: "loading" } }); + +describe("canvas synchronous editor state", () => { + test("commands see previous commands without waiting for React, and execute once", () => { + const ref = { current: [] as string[] }; + const snapshots: string[][] = []; + const write = createCanvasStateWriter(ref, (value) => snapshots.push(value)); + let calls = 0; + write((current) => { calls++; return [...current, "first"]; }); + write((current) => { calls++; return [...current, "second"]; }); + expect(calls).toBe(2); + expect(ref.current).toEqual(["first", "second"]); + expect(snapshots).toEqual([["first"], ["first", "second"]]); + }); + + test("explicit ref writes must still publish to React", () => { + const ref = { current: [] as string[] }; + const snapshots: string[][] = []; + const write = createCanvasStateWriter(ref, (value) => snapshots.push(value)); + ref.current = ["restored"]; + write(ref.current); + expect(snapshots).toEqual([["restored"]]); + }); +}); + +describe("canvas generation node commits", () => { + test("task lookup errors preserve unknown state and cannot undo success or a new binding", () => { + const pending = image("a"); + expect(markCanvasTaskRecoveryUnconfirmed(pending, "task-a", "network unavailable").metadata).toMatchObject({ status: "loading", taskId: "task-a", errorDetails: "network unavailable" }); + expect(markCanvasTaskRecoveryUnconfirmed(pending, "old-task", "missing")).toBe(pending); + const success: CanvasNodeData = { ...pending, metadata: { ...pending.metadata, status: "success", storageKey: "resource:a" } }; + expect(markCanvasTaskRecoveryUnconfirmed(success, "task-a", "missing")).toBe(success); + expect(resetGenerationTaskMetadata({ ...success.metadata, taskStatus: "failed" }, "loading")).toMatchObject({ storageKey: "resource:a", status: "loading" }); + expect(resetGenerationTaskMetadata(success.metadata).taskId).toBeUndefined(); + }); + test("five concurrent results preserve two successes, three failures, and unrelated edits", async () => { + const before = Array.from({ length: 5 }, (_, i) => image(String(i))); + const ref = { current: [...before, image("new")] }; + const write = createCanvasStateWriter(ref, () => {}); + await Promise.all(before.map(async (node, i) => { + await Promise.resolve(); + const result: CanvasNodeData = { ...node, metadata: { ...node.metadata, status: i < 2 ? "success" : "error", storageKey: i < 2 ? `resource:${i}` : undefined } }; + write((current) => commitCanvasGenerationResult(current, node, result, `task-${i}`)); + })); + expect(ref.current.filter((node) => node.metadata?.status === "success")).toHaveLength(2); + expect(ref.current.filter((node) => node.metadata?.status === "error")).toHaveLength(3); + expect(ref.current.at(-1)?.id).toBe("new"); + }); + + test("keeps movement, manual resize, rename and other node deletion during materialization", () => { + const before = image("a"); + const live = { ...before, title: "renamed", position: { x: 300, y: 400 }, width: 600 }; + const result: CanvasNodeData = { ...before, width: 100, title: "generated", metadata: { ...before.metadata, status: "success", storageKey: "resource:a" } }; + const committed = commitCanvasGenerationResult([live], before, result, "task-a"); + expect(committed).toHaveLength(1); + expect(committed[0]).toMatchObject({ title: "renamed", position: live.position, width: 600, metadata: { storageKey: "resource:a" } }); + }); + + test("does not resurrect deleted nodes or overwrite a reset/new task", () => { + const before = image("a"); + expect(() => commitCanvasGenerationResult([], before, before, "task-a")).toThrow(); + expect(() => commitCanvasGenerationResult([{ ...before, metadata: { taskId: "new" } }], before, before, "task-a")).toThrow(); + expect(() => commitCanvasGenerationResult([{ ...before, metadata: {} }], before, before, "task-a")).toThrow(); + }); +}); + +describe("canvas viewport selection", () => { + test("hidden and overscan nodes cannot consume the viewport budget", () => { + const nodes = [...Array.from({ length: 400 }, (_, i) => image(`hidden-${i}`)), ...Array.from({ length: 400 }, (_, i) => image(`buffer-${i}`, -100)), ...Array.from({ length: 1000 }, (_, i) => image(`visible-${i}`)), image("forced", 2000)]; + const index = buildCanvasSpatialIndex(nodes.map((node) => ({ id: node.id, value: node.id, bounds: canvasNodeBounds(node) }))); + const nodeById = new Map(nodes.map((node) => [node.id, node])); + const view = { left: 0, top: 0, right: 100, bottom: 100 }; + const retain = { ...view, left: -200, right: 200 }; + const selected = selectCanvasVisibleNodes({ index, nodeById, view, enter: retain, retain, hiddenIds: new Set(nodes.filter((node) => node.id.startsWith("hidden")).map((node) => node.id)), retainedIds: new Set(), forcedIds: new Set(["forced"]), budget: 280 }); + expect(selected).toHaveLength(1001); + expect(selected.filter((node) => node.id.startsWith("visible"))).toHaveLength(1000); + expect(selected.at(-1)?.id).toBe("forced"); + expect(nodes).toHaveLength(1801); + }); +}); diff --git a/web/test/canvas-generation-layout.test.ts b/web/test/canvas-generation-layout.test.ts index 77c84ad61..543f61721 100644 --- a/web/test/canvas-generation-layout.test.ts +++ b/web/test/canvas-generation-layout.test.ts @@ -8,6 +8,10 @@ function node(id: string, x: number, y: number, width = 340, height = 240): Canv } describe("findAvailableGenerationGroupPosition", () => { + test("已有存储标识但预览未恢复的媒体不能当作空节点覆盖", () => { + expect(canGenerateImageInPlace({ ...node("stored", 0, 0), metadata: { storageKey: "resource:image" } })).toBe(false); + expect(canGenerateMediaInPlace({ ...node("video", 0, 0), type: CanvasNodeType.Video, metadata: { storageKey: "resource:video" } }, CanvasNodeType.Video)).toBe(false); + }); test("首选位置没有占用时保持原坐标", () => { expect(findAvailableGenerationGroupPosition([node("source", 0, 0)], { x: 436, y: 0 }, { width: 340, height: 240 })).toEqual({ x: 436, y: 0 }); }); diff --git a/web/test/canvas-generation-task-sync.test.ts b/web/test/canvas-generation-task-sync.test.ts index a6710b93d..0df301824 100644 --- a/web/test/canvas-generation-task-sync.test.ts +++ b/web/test/canvas-generation-task-sync.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { applyGeneratedMediaResultMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync"; +import { applyGeneratedMediaResultMetadata, applyGenerationTaskResultToNodes, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; function videoNode(assetId: string, storageKey: string): CanvasNodeData { @@ -16,6 +16,12 @@ function videoNode(assetId: string, storageKey: string): CanvasNodeData { } describe("applyGeneratedMediaResultMetadata", () => { + test("明确指定的节点已删除时不能根据 taskId 误更新其他节点", async () => { + const other = { ...videoNode("asset", "video:key"), metadata: { taskId: "task" } }; + const applied = await applyGenerationTaskResultToNodes([other], { id: "task", type: "canvas_video", status: "succeeded", prompt: "", attempts: 1, createdAt: "", updatedAt: "" }, "deleted-node"); + expect(applied.updated).toBe(false); + expect(applied.nodes).toEqual([other]); + }); test("clears the previous asset binding when a regenerated media result lands", () => { const node = videoNode("asset-old", "video:old"); const next = applyGeneratedMediaResultMetadata(node, videoMetadata({ diff --git a/web/test/canvas-image-batch-retry.test.ts b/web/test/canvas-image-batch-retry.test.ts index 10575fa55..0d0e5add8 100644 --- a/web/test/canvas-image-batch-retry.test.ts +++ b/web/test/canvas-image-batch-retry.test.ts @@ -17,6 +17,32 @@ function imageNode(id: string, status: CanvasNodeStatus, metadata: Partial { + test("旧批次索引不能删除属于其他批次的节点", () => { + const root = imageNode("root", "loading", { isBatchRoot: true, batchChildIds: ["foreign"] }); + const foreign = imageNode("foreign", "loading", { batchRootId: "other-root" }); + expect(retireImageBatchChildren(root, [root, foreign], []).removedIds).toEqual([]); + expect(cancelIncompleteImageBatch(root.id, [foreign.id], [root, foreign], []).nodes).toContain(foreign); + }); + test("清理和取消只移除无持久资源的占位,不能删除尚未恢复预览的成功图片", () => { + const root = imageNode("root", "loading", { isBatchRoot: true, batchChildIds: ["done", "pending"] }); + const done = imageNode("done", "success", { batchRootId: root.id, storageKey: "resource:done", assetId: "asset-done" }); + const pending = imageNode("pending", "loading", { batchRootId: root.id }); + const nodes = [root, done, pending]; + expect(retireImageBatchChildren(root, nodes, []).removedIds).toEqual(["pending"]); + expect(cancelIncompleteImageBatch(root.id, ["done", "pending"], nodes, []).removedIds).toEqual(["pending"]); + expect(reconcileImageBatchRoot(root, nodes).metadata).toMatchObject({ status: "success", storageKey: "resource:done", assetId: "asset-done", primaryImageId: "done" }); + }); + + test("刷新后五张图两成三败,根节点保留成功资源及准确失败数", () => { + const children = Array.from({ length: 5 }, (_, i) => imageNode(`child-${i}`, i < 2 ? "success" : "error", { + batchRootId: "root", ...(i < 2 ? { storageKey: `resource:${i}`, assetId: `asset-${i}` } : { errorDetails: "上游失败" }), + })); + const root = imageNode("root", "error", { isBatchRoot: true, batchChildIds: children.map((node) => node.id), taskId: "old-task", taskStatus: "failed" }); + const next = reconcileImageBatchRoot(root, [root, ...children]); + expect(next.metadata).toMatchObject({ status: "success", storageKey: "resource:0", batchFailedCount: 3 }); + expect(next.metadata.taskId).toBeUndefined(); + }); + test("只按批次顺序返回属于当前根节点的失败图片", () => { const root = imageNode("root", "error", { isBatchRoot: true, batchChildIds: ["failed-2", "success", "failed-1", "loading", "foreign"] }); const nodes = [ diff --git a/web/test/canvas-node-generation-mentions.test.ts b/web/test/canvas-node-generation-mentions.test.ts index e50f8157b..2ba52d9d3 100644 --- a/web/test/canvas-node-generation-mentions.test.ts +++ b/web/test/canvas-node-generation-mentions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { buildNodeGenerationContext } from "../src/components/canvas/canvas-node-generation"; -import { buildCanvasResourceReferences } from "../src/lib/canvas/canvas-resource-references"; +import { buildCanvasResourceReferences, getGenerationResourceNodes } from "../src/lib/canvas/canvas-resource-references"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas"; function node(id: string, type: CanvasNodeType, content: string): CanvasNodeData { @@ -33,6 +33,40 @@ function connection(fromNodeId: string): CanvasConnection { } describe("canvas node generation position mentions", () => { + test("父图无预览时仍可继承输入,显式子图输入优先且去重", () => { + const a = node("a", CanvasNodeType.Image, ""); + a.metadata = { storageKey: "resource:a" }; + const b = node("b", CanvasNodeType.Image, "b"); + const root = node("root", CanvasNodeType.Image, ""); + root.metadata = { isBatchRoot: true }; + const child = node("child", CanvasNodeType.Image, ""); + child.metadata = { batchRootId: root.id }; + const nodes = [a, b, root, child]; + const connections = [{ id: "a-root", fromNodeId: a.id, toNodeId: root.id }, { id: "group", fromNodeId: root.id, toNodeId: child.id }]; + expect(getGenerationResourceNodes(child.id, nodes, connections).map((item) => item.id)).toEqual(["a"]); + expect(getGenerationResourceNodes(child.id, nodes, [...connections, { id: "b-child", fromNodeId: b.id, toNodeId: child.id }, { id: "b-child-duplicate", fromNodeId: b.id, toNodeId: child.id }]).map((item) => item.id)).toEqual(["b"]); + root.metadata = { isBatchRoot: true, batchRootId: child.id }; + child.metadata = { isBatchRoot: true, batchRootId: root.id }; + expect(getGenerationResourceNodes(child.id, nodes, [])).toEqual([]); + }); + test("批次子图沿父批次继承原始参考图,不把结果分组边当作参考图", () => { + const a = node("a", CanvasNodeType.Image, "data:image/png;base64,a"); + const b = node("b", CanvasNodeType.Image, "data:image/png;base64,b"); + const root = node("root", CanvasNodeType.Image, "data:image/png;base64,result"); + root.metadata = { ...root.metadata, isBatchRoot: true, batchChildIds: ["child"] }; + const child = node("child", CanvasNodeType.Image, ""); + child.metadata = { batchRootId: root.id, composerContent: "结合 @图片1 和 @图片2" }; + const nodes = [a, b, root, child]; + const connections = [ + { id: "a-root", fromNodeId: a.id, toNodeId: root.id }, + { id: "b-root", fromNodeId: b.id, toNodeId: root.id }, + { id: "group", fromNodeId: root.id, toNodeId: child.id }, + ]; + const context = buildNodeGenerationContext(child.id, nodes, connections, child.metadata.composerContent!, []); + expect(context.referenceImages.map((image) => image.id)).toEqual([a.id, b.id]); + expect(buildCanvasResourceReferences(nodes, connections, child.id).filter((ref) => ref.active).map((ref) => ref.nodeId)).toEqual([a.id, b.id]); + }); + test("Agent 保存的素材引用块在编辑器和再次提交时保持相同编号", () => { const target = targetNode(); const character = node("character", CanvasNodeType.Image, "data:image/png;base64,a"); diff --git a/web/test/generation-storage-consistency.test.ts b/web/test/generation-storage-consistency.test.ts index 80548f759..fe1a2e28f 100644 --- a/web/test/generation-storage-consistency.test.ts +++ b/web/test/generation-storage-consistency.test.ts @@ -1,8 +1,21 @@ import { expect, test } from "bun:test"; -type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race"; +type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race"; type ScenarioResponse = { ok: true; result: T } | { ok: false; error: string }; +test("并发生成结果经真实持久化消费链路及重新读取后保留成功、失败和用户编辑", async () => { + type Node = import("../src/types/canvas").CanvasNodeData; + const result = await runScenario<{ edited: boolean; live: Node[]; restored: Node[] }>("canvas-batch-commit-race"); + expect(result.edited).toBe(true); + for (const nodes of [result.live, result.restored]) { + expect(nodes).toHaveLength(6); + expect(nodes.filter((node) => node.metadata?.status === "success")).toHaveLength(2); + expect(nodes.filter((node) => node.metadata?.status === "error")).toHaveLength(3); + expect(nodes.find((node) => node.id === "node-0")).toMatchObject({ title: "用户改名", position: { x: 700, y: 500 }, metadata: { assetId: "asset-0" } }); + expect(nodes.find((node) => node.id === "node-1")?.metadata?.assetId).toBe("asset-1"); + } +}); + function runScenario(scenario: Scenario): Promise { return new Promise((resolve, reject) => { const worker = new Worker(new URL("./helpers/generation-storage-consistency.worker.ts", import.meta.url).href, { type: "module" }); diff --git a/web/test/helpers/generation-storage-consistency.worker.ts b/web/test/helpers/generation-storage-consistency.worker.ts index 31cba39ca..eff2b1b20 100644 --- a/web/test/helpers/generation-storage-consistency.worker.ts +++ b/web/test/helpers/generation-storage-consistency.worker.ts @@ -4,7 +4,7 @@ import { getActiveUserScope, setActiveUserScope } from "../../src/lib/user-scope type InstanceHook = (storeName: string, key: string, value: unknown) => Promise | void; -type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race"; +type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race"; function installStorageHarness() { const originalCreateInstance = localforage.createInstance.bind(localforage); @@ -35,6 +35,7 @@ function installStorageHarness() { localforage.getItem = (async (key: string) => defaultValues.get(key) ?? null) as typeof localforage.getItem; localforage.setItem = (async (key: string, value: unknown) => { defaultValues.set(key, value); + await hooks.onSet?.("app_state", key, value); return value; }) as typeof localforage.setItem; localforage.removeItem = (async (key: string) => { @@ -392,6 +393,49 @@ async function runMediaCommitRace(mediaType: "video" | "audio") { } } +async function runCanvasBatchCommitRace() { + const harness = installStorageHarness(); + const previousScope = getActiveUserScope(); + let unregister: (() => void) | undefined; + try { + setActiveUserScope("canvas-batch-commit-race"); + const { useCanvasStore, flushCanvasStorePersistence, CANVAS_STORE_KEY } = await import("../../src/stores/canvas/use-canvas-store"); + const { useAssetStore } = await import("../../src/stores/use-asset-store"); + const { applyCanvasGenerationTaskNodeEffect, registerCanvasGenerationLiveProject } = await import("../../src/services/canvas-generation-consumer"); + const { createCanvasStateWriter } = await import("../../src/lib/canvas/canvas-editor-state"); + const { localForageStorageForScope } = await import("../../src/lib/localforage-storage"); + const { parseCanvasStorageDocument } = await import("../../src/lib/canvas/canvas-storage-revision"); + const { CanvasNodeType } = await import("../../src/types/canvas"); + const projectId = useCanvasStore.getState().createProject("batch race"); + const nodes = Array.from({ length: 5 }, (_, index) => ({ id: `node-${index}`, type: CanvasNodeType.Image, title: `image-${index}`, position: { x: index * 400, y: 0 }, width: 320, height: 240, metadata: { taskId: `task-${index}`, status: index < 2 ? "loading" as const : "error" as const } })); + const ref: { current: import("../../src/types/canvas").CanvasNodeData[] } = { current: nodes }; + const setNodes = createCanvasStateWriter(ref, () => {}); + useCanvasStore.getState().updateProject(projectId, { nodes }); + await flushCanvasStorePersistence(); + useAssetStore.setState({ assets: [0, 1].map((index) => ({ id: `asset-${index}`, kind: "image" as const, title: "generated", coverUrl: "/image.png", tags: [], createdAt: "2026-09-21T00:00:00Z", updatedAt: "2026-09-21T00:00:00Z", data: { dataUrl: "/image.png", storageKey: `resource:image-${index}`, width: 640, height: 480, bytes: 100, mimeType: "image/png" } })) }); + unregister = registerCanvasGenerationLiveProject({ scope: getActiveUserScope(), projectId, adapter: { read: () => ({ nodes: ref.current, connections: [], chatSessions: [], activeChatId: null }), write: (state) => setNodes(state.nodes) } }); + let edited = false; + harness.hooks.onSet = (storeName, _key, value) => { + if (edited || storeName !== "app_state" || typeof value !== "string" || !value.includes("attach:task-")) return; + edited = true; + setNodes((current) => [...current.map((node) => node.id === "node-0" ? { ...node, title: "用户改名", position: { x: 700, y: 500 } } : node), { ...nodes[0], id: "new-during-save", metadata: {} }]); + }; + await Promise.all([0, 1].map((index) => applyCanvasGenerationTaskNodeEffect({ + projectId, nodeId: `node-${index}`, nodesRef: ref, setNodes, + task: { id: `task-${index}`, projectId, type: "canvas_image", status: "succeeded", prompt: "test", attempts: 1, createdAt: "2026-09-21T00:00:00Z", updatedAt: "2026-09-21T00:00:01Z", resultJson: "{}" }, + output: { outputIndex: 0, mediaType: "image", materializedAssetId: `asset-${index}` }, effectKey: `attach:task-${index}:0`, + }))); + useCanvasStore.getState().updateProject(projectId, { nodes: ref.current }); + await flushCanvasStorePersistence(); + const stored = parseCanvasStorageDocument(await localForageStorageForScope(getActiveUserScope()).getItem(CANVAS_STORE_KEY), []); + return { edited, live: ref.current, restored: stored.state.projects.find((project) => project.id === projectId)?.nodes }; + } finally { + unregister?.(); + setActiveUserScope(previousScope); + harness.restore(); + } +} + self.onmessage = async (event: MessageEvent) => { try { const result = @@ -401,7 +445,9 @@ self.onmessage = async (event: MessageEvent) => { ? await runScopeCleanupAfterSwitch() : event.data === "scope-cleanup-late-canvas-reference" ? await runScopeCleanupAfterLateCanvasReference() - : await runMediaCommitRace(event.data === "audio-commit-race" ? "audio" : "video"); + : event.data === "canvas-batch-commit-race" + ? await runCanvasBatchCommitRace() + : await runMediaCommitRace(event.data === "audio-commit-race" ? "audio" : "video"); self.postMessage({ ok: true, result }); } catch (error) { self.postMessage({ ok: false, error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) });