From dd66be15cba7f79972aa247c5f2abd7c9d03efe5 Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:19:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E7=94=BB=E5=B8=83=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=20-=20=E7=B4=A0=E6=9D=90=E7=BC=BA=E5=A4=B1=E6=97=B6?= =?UTF-8?q?=E5=8F=AF=E5=BC=BA=E5=88=B6=E8=A6=86=E7=9B=96=E5=B9=B6=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E6=96=87=E6=9C=AC=E4=B8=8E=E8=BF=9E=E7=BA=BF=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 云端缺少素材时可用本地版强制覆盖,只清失效媒体引用。文本节点不再把资源地址当正文,合并时保留引用槽位顺序。 --- docs/content/docs/progress/pending-test.mdx | 13 ++ .../components/canvas/canvas-node-content.tsx | 15 ++- web/src/lib/canvas/agent-canvas-patch.ts | 47 ++++++++ web/src/pages/canvas/canvas-sync-status.tsx | 59 ++++++--- web/src/pages/canvas/project.tsx | 4 +- .../canvas/use-canvas-project-lifecycle.ts | 2 +- web/src/services/canvas-missing-resources.ts | 114 ++++++++++++++++++ web/src/services/user-data-sync-media.ts | 62 ++++++++-- web/src/services/user-data-sync.ts | 46 ++++--- web/test/agent-canvas-patch.test.ts | 26 ++++ web/test/canvas-missing-resources.test.ts | 33 +++++ web/test/user-data-sync-media.test.ts | 67 ++++++++++ 12 files changed, 442 insertions(+), 46 deletions(-) create mode 100644 web/src/services/canvas-missing-resources.ts create mode 100644 web/test/canvas-missing-resources.test.ts create mode 100644 web/test/user-data-sync-media.test.ts diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 9038bf7cd..39e0b15ec 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,19 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 画布素材缺失时的本地强制覆盖 + +- 已实现:云端保存返回 `canvas_history_resources_missing` 时,画布同步面板提供“用本地版强制覆盖云端”。操作会先保存本地草稿,探测资源状态,清理确认已删除或不存在的资源定位,保留节点布局、提示词和连线后再提交;普通自动保存仍执行完整素材校验。 +- [x] 专项测试覆盖嵌套资源定位收集、失效媒体字段清理、素材绑定移除以及画布性能回归;前端构建、后端画布/处理器/仓储测试通过。 +- [ ] 使用已登录浏览器打开具体画布,点击强制覆盖并刷新确认:失效图片节点仍保留为可重新上传的空节点,本地草稿可从版本记录恢复。 + +## Agent 只读循环软收敛 + +- 已实现:缓存只读结果重复回放、图片同版本重复识别或本轮只读快照达到累计上限时,记录 `read_loop_guard` 并在下一次模型请求暂时收起只读工具;运行保持 `running`,仍可回复、提问或执行获准写操作,不再由该护栏写入 `run_failed`。 +- 已调整:能力错误提示优先复用本回合 `model_list`,只有素材、模式或模型选择变化后才重新查询,避免纠偏消息诱发重复读取。 +- [x] `internal/app` 专项测试覆盖缓存回放、跨参数读取上限、图片重复识别、Pi 请求工具过滤、批量读取回执和运行状态;完整 `go test ./internal/app` 通过。 +- [ ] 在真实 Agent 会话中复现长时间重复读取,确认模型使用已有结果收束、写操作和 `ask_user` 仍可用,并观察护栏事件与费用变化。 + ## Agent 画布权限与大事件审批 - 已修复:权限查询使用 `canvas_projects`;大画布增量超过事件上限时改为完整快照刷新通知,避免审批执行事务回滚。 diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx index 69ce1a44b..bf8f182fb 100644 --- a/web/src/components/canvas/canvas-node-content.tsx +++ b/web/src/components/canvas/canvas-node-content.tsx @@ -13,6 +13,7 @@ import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-refer import type { CanvasTheme } from "@/lib/canvas-theme"; import { formatBytes } from "@/lib/image-utils"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; +import { isResourceUrl } from "@/services/api/resources"; import { getNodeDefinition } from "@/lib/canvas/node-registry"; import { ART_CRITIQUE_NODE_TYPE } from "@/lib/art-critique/contracts"; import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; @@ -59,7 +60,8 @@ export type CanvasNodeContentProps = { export function CanvasNodeContent(props: CanvasNodeContentProps) { if (props.node.metadata?.fileUpload) return ; - // Keep the image renderer mounted while node LOD changes. Visibility still gates first load. + // Preserve the decoded image across LOD changes; only its surrounding + // controls and batch previews become lighter in overview mode. if (props.node.type === CanvasNodeType.Image && props.renderLOD !== "full" && (props.node.metadata?.content || props.node.metadata?.storageKey)) { return ; } @@ -98,7 +100,7 @@ function CanvasNodeShellContent({ node, theme }: { node: CanvasNodeData; theme: function CanvasNodePreviewContent({ node, theme }: { node: CanvasNodeData; theme: CanvasTheme }) { if (node.type === CanvasNodeType.Image && (node.metadata?.content || node.metadata?.storageKey)) { - return } />; + return } />; } if (node.type === CanvasNodeType.Video && (node.metadata?.content || node.metadata?.storageKey)) { return } />; @@ -218,6 +220,11 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere const fontSize = canvasTextFontSize(node.width, node.height, node.metadata?.fontSize); const textStyle = { fontSize: `${fontSize}px`, lineHeight: `${Math.round(fontSize * 1.65)}px`, color: theme.node.text, boxSizing: "border-box" } as CSSProperties; const richTextHTML = useMemo(() => canvasRichTextHTML(node.metadata?.richText), [node.metadata?.richText]); + const rawContent = node.metadata?.content || ""; + const prompt = node.metadata?.prompt || ""; + // 兼容已经被旧同步逻辑写成资源 URL 的文本节点:上传文本时 prompt + // 保存的是原文,可在远端修复完成前先恢复展示与编辑内容。 + const textContent = isResourceUrl(rawContent) && prompt && !isResourceUrl(prompt) ? prompt : rawContent; return (
@@ -226,7 +233,7 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere ref={textareaRef} className="thin-scrollbar m-0 block h-full w-full resize-none appearance-none overflow-y-auto whitespace-pre-wrap break-words border-none bg-transparent px-4 pb-4 pt-0 font-mono outline-none select-text" style={textStyle} - value={node.metadata?.content || ""} + value={textContent} references={mentionReferences} highlightLabels={false} onChange={(value) => onContentChange(node.id, value)} @@ -247,7 +254,7 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere /> ) : (
event.stopPropagation()}> - {node.metadata?.content || 双击编辑文字} + {textContent || 双击编辑文字}
)}
diff --git a/web/src/lib/canvas/agent-canvas-patch.ts b/web/src/lib/canvas/agent-canvas-patch.ts index 0a222ee60..3cec016f6 100644 --- a/web/src/lib/canvas/agent-canvas-patch.ts +++ b/web/src/lib/canvas/agent-canvas-patch.ts @@ -87,6 +87,52 @@ function mergeItems(items: T[], changes: Change[]): return result.length === items.length && result.every((item, index) => item === items[index]) ? items : result; } +/** + * Connection order is part of the canvas contract: it determines @图片N/ + * @角色N slots. A three-way item merge cannot observe a reorder because the + * individual connection objects are unchanged, so apply the incoming order + * only when the editor has not reordered the same list locally. + */ +function mergeConnectionOrder(previous: CanvasConnection[], incoming: CanvasConnection[], editor: CanvasConnection[], merged: CanvasConnection[]) { + const targetIds = new Set([...previous, ...incoming, ...editor].map((connection) => connection.toNodeId)); + const next = [...merged]; + for (const targetId of targetIds) { + const targetOrder = (items: CanvasConnection[]) => items.filter((item) => item.toNodeId === targetId).map((item) => item.id); + const previousIds = targetOrder(previous); + const editorIds = targetOrder(editor); + // A local reorder for this target is an intentional edit and wins over + // the remote snapshot. Unrelated target connections can still merge. + const editorSharedIds = editorIds.filter((id) => previousIds.includes(id)); + if (previousIds.length !== editorSharedIds.length || previousIds.some((id, index) => id !== editorSharedIds[index])) continue; + + const mergedById = new Map(next.filter((item) => item.toNodeId === targetId).map((item) => [item.id, item])); + const ordered = incoming + .filter((item) => item.toNodeId === targetId) + .flatMap((item) => { + const current = mergedById.get(item.id); + if (!current) return []; + mergedById.delete(item.id); + return [current]; + }); + // Keep locally added connections that are absent from the incoming + // snapshot after the server-defined order of shared connections. + editor + .filter((item) => item.toNodeId === targetId) + .forEach((item) => { + const current = mergedById.get(item.id); + if (!current) return; + mergedById.delete(item.id); + ordered.push(current); + }); + const slots = next.flatMap((item, index) => (item.toNodeId === targetId ? [index] : [])); + slots.forEach((index, orderIndex) => { + const connection = ordered[orderIndex]; + if (connection) next[index] = connection; + }); + } + return next; +} + export function applyAgentCanvasPatch(project: CanvasProject, patch: AgentCanvasPatch): CanvasProject { if (patch.canvasId !== project.id || !Array.isArray(patch.nodes) || !Array.isArray(patch.connections)) throw new Error("画布增量不属于当前画布或格式无效"); const byId = new Map(project.nodes.map((node) => [node.id, node])); @@ -130,6 +176,7 @@ export function mergeAgentCanvasEditor(previous: CanvasProject, incoming: Canvas connections: changes(previous.connections, incoming.connections), }, ); + projected.connections = mergeConnectionOrder(previous.connections, incoming.connections, editorState.connections, projected.connections); const merged = { ...projected } as CanvasProject & Record; const before = previous as CanvasProject & Record; const after = incoming as CanvasProject & Record; diff --git a/web/src/pages/canvas/canvas-sync-status.tsx b/web/src/pages/canvas/canvas-sync-status.tsx index 316718fec..a558530b0 100644 --- a/web/src/pages/canvas/canvas-sync-status.tsx +++ b/web/src/pages/canvas/canvas-sync-status.tsx @@ -17,6 +17,7 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: { const localPhase = progress?.localPhase; const localError = localPhase === "error"; const localPending = localPhase === "pending"; + const missingResources = progress?.errorKind === "resources"; const retryScheduled = cloudError && progress?.message?.includes("自动重试"); const saving = phase === "pending" || phase === "saving" || phase === "uploading"; const reconciling = phase === "reconciling"; @@ -41,7 +42,9 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: { const description = conflict ? progress?.message || "本地与云端存在冲突,当前编辑内容保持不变。" : cloudError - ? progress?.message || "本地内容仍然保留,云端暂未确认。" + ? missingResources + ? "检测到云端缺失素材;可以先保留原始草稿,再用本地版强制覆盖并移除失效素材引用。" + : progress?.message || "本地内容仍然保留,云端暂未确认。" : localError ? progress?.localError || "本地保存没有完成,请立即重试;当前编辑内容保持不变。" : reconciling @@ -140,7 +143,13 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: { ) : cloudError ? (
{retryScheduled ? "本地内容仍在,正在等待重试" : "本地内容仍在,未覆盖云端"} - {retryScheduled ? "网络恢复后会自动重试;也可以点击“立即重试”。" : "请处理上面的错误后点击“立即重试”;在此之前本地内容不会被覆盖。"} + + {missingResources + ? "可点击“用本地版强制覆盖云端”;操作前会自动保留本地草稿,失效图片节点会保留布局和提示词。" + : retryScheduled + ? "网络恢复后会自动重试;也可以点击“立即重试”。" + : "请处理上面的错误后点击“立即重试”;在此之前本地内容不会被覆盖。"} +
) : localError ? (
@@ -150,19 +159,39 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: { ) : null}
{cloudError ? ( - + <> + {!missingResources ? ( + + ) : null} + {missingResources ? ( + + ) : null} + ) : localError ? (