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 ? (